Compare commits

..
Author SHA1 Message Date
MS-01-Codexandmultica-agent 228e08a5e2 BAI-15: group mentor answer bubbles
Co-authored-by: multica-agent <github@multica.ai>
2026-08-07 16:40:00 +08:00
MS-01-Codexandmultica-agent 777af0cb8b BAI-3: harden live readiness diagnostics
Co-authored-by: multica-agent <github@multica.ai>
2026-08-07 12:29:01 +08:00
MS-01-Codexandmultica-agent a326df2a8d BAI-15: refine dark mentor answer bubble
Co-authored-by: multica-agent <github@multica.ai>
2026-08-07 10:14:02 +08:00
69 changed files with 1863 additions and 14441 deletions
+2
View File
@@ -43,6 +43,7 @@ from backend.features.screener.service import (
from backend.features.sentiment import SentimentServiceMixin
from backend.features.sentiment.routes import SentimentRoutesMixin
from backend.features.system import SystemHttpMixin
from backend.features.system.health import HealthServiceMixin
from backend.features.system.routes import SystemRoutesMixin
from backend.features.system.service import SystemServiceMixin
from backend.features.themes import ThemeServiceMixin
@@ -77,6 +78,7 @@ LEGACY_SECRET_KEYS = {
class DashboardService(
HealthServiceMixin,
SystemServiceMixin,
AccountApplicationMixin,
JobServiceMixin,
+1 -7
View File
@@ -1,14 +1,8 @@
from .m0001_adopt_legacy import MIGRATION as M0001_ADOPT_LEGACY
from .m0002_job_runs import MIGRATION as M0002_JOB_RUNS
from .m0003_llm_audit import MIGRATION as M0003_LLM_AUDIT
from .m0004_mentor_notes import MIGRATION as M0004_MENTOR_NOTES
from .runner import Migration, MigrationError, MigrationRunner
MIGRATIONS = (
M0001_ADOPT_LEGACY,
M0002_JOB_RUNS,
M0003_LLM_AUDIT,
M0004_MENTOR_NOTES,
)
MIGRATIONS = (M0001_ADOPT_LEGACY, M0002_JOB_RUNS, M0003_LLM_AUDIT)
__all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"]
@@ -1,24 +0,0 @@
from __future__ import annotations
import sqlite3
from backend.database.migrations.runner import Migration
def add_mentor_note(connection: sqlite3.Connection) -> None:
columns = {
str(row["name"])
for row in connection.execute("PRAGMA table_info(mentor_preferences)")
}
if "note" not in columns:
connection.execute(
"ALTER TABLE mentor_preferences ADD COLUMN note TEXT NOT NULL DEFAULT ''"
)
MIGRATION = Migration(
version="0004",
name="add_mentor_note",
action=add_mentor_note,
signature="mentor-preferences-note:v1:note",
)
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
class HealthServiceMixin:
def health_status(self) -> dict[str, Any]:
"""Return a cheap public readiness summary without provider secrets."""
database_ready = False
database_status: dict[str, Any] = {}
try:
with self.database.connect() as connection:
database_ready = connection.execute("SELECT 1").fetchone() is not None
database_status = self.database.status()
except Exception:
database_ready = False
try:
recent_jobs = self.jobs.repository.recent(12)
except Exception:
recent_jobs = []
last_sync = database_status.get("last_sync") or {}
snapshot_dates = int(database_status.get("snapshot_dates") or 0)
data_configured = bool(self.configured)
last_sync_success = last_sync.get("status") == "success"
if data_configured and snapshot_dates and last_sync_success:
data_status = "ready"
elif snapshot_dates:
data_status = "degraded"
else:
data_status = "unavailable"
latest_job = recent_jobs[0] if recent_jobs else {}
failed_jobs = sum(1 for job in recent_jobs if job.get("status") == "failed")
if not database_ready:
jobs_status = "unavailable"
elif latest_job.get("status") == "running":
jobs_status = "running"
elif latest_job.get("status") == "failed":
jobs_status = "degraded"
else:
jobs_status = "ready"
platform = self._platform_llm_profile()
primary_ready = self._profile_configured(platform["primary"])
fallback_ready = self._profile_configured(platform["fallback"])
models_status = "ready" if primary_ready else "unavailable"
component_states = (
"ready",
"ready" if database_ready else "unavailable",
data_status,
jobs_status,
models_status,
)
return {
"ok": database_ready,
"status": (
"ready"
if all(state in {"ready", "running"} for state in component_states)
else "degraded"
),
"account_required": True,
"time": datetime.now().astimezone().isoformat(timespec="seconds"),
"components": {
"process": {"status": "ready"},
"database": {
"status": "ready" if database_ready else "unavailable",
"snapshot_dates": snapshot_dates,
"last_snapshot_at": database_status.get("updated_at") or "",
},
"data_sources": {
"status": data_status,
"configured": data_configured,
"snapshot_available": snapshot_dates > 0,
"latest_trade_date": str(last_sync.get("trade_date") or ""),
"last_success_at": (
str(last_sync.get("finished_at") or "") if last_sync_success else ""
),
},
"jobs": {
"status": jobs_status,
"recent_failures": failed_jobs,
"latest_finished_at": str(latest_job.get("finished_at") or ""),
},
"models": {
"status": models_status,
"primary": "ready" if primary_ready else "unavailable",
"fallback": "ready" if fallback_ready else "disabled",
},
},
}
+1 -9
View File
@@ -1,20 +1,12 @@
from __future__ import annotations
from datetime import datetime
from http import HTTPStatus
class SystemRoutesMixin:
def _handle_system_public_get(self, parsed) -> bool:
if parsed.path == "/api/health":
self.send_json(
{
"ok": True,
"storage": "sqlite",
"account_required": True,
"time": datetime.now().astimezone().isoformat(timespec="seconds"),
}
)
self.send_json(self.application_service.health_status())
return True
return False
-7
View File
@@ -104,13 +104,6 @@ class HttpTransportMixin:
except ValueError:
self.send_error(HTTPStatus.FORBIDDEN)
return
if candidate.is_dir():
candidate = (candidate / "index.html").resolve()
try:
candidate.relative_to(STATIC_DIR.resolve())
except ValueError:
self.send_error(HTTPStatus.FORBIDDEN)
return
if not candidate.is_file():
candidate = STATIC_DIR / "index.html"
try:
+31 -3
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timedelta
from typing import Any
from database import ReviewDatabase
@@ -27,9 +27,37 @@ class SQLiteJobRunRepository:
def start(
self, job_id: str, idempotency_key: str, output_version: str,
metadata: dict[str, Any] | None = None,
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
stale_after_seconds: int = 0,
) -> int | None:
current = datetime.now().astimezone()
now = current.isoformat(timespec="seconds")
stale_before = (
current - timedelta(seconds=max(1, int(stale_after_seconds or 1)))
).isoformat(timespec="seconds")
with self.database.connect() as connection:
# The process-local runner lock cannot protect two server processes.
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"""
UPDATE job_runs
SET status = 'failed', finished_at = ?, error_code = 'StaleRun',
message = 'Previous run exceeded its execution window.'
WHERE job_id = ? AND idempotency_key = ? AND status = 'running'
AND started_at < ?
""",
(now, job_id, idempotency_key, stale_before),
)
claimed = connection.execute(
"""
SELECT 1 FROM job_runs
WHERE job_id = ? AND idempotency_key = ?
AND status IN ('running', 'success')
LIMIT 1
""",
(job_id, idempotency_key),
).fetchone()
if claimed:
return None
row = connection.execute(
"""
SELECT COALESCE(MAX(attempt), 0) + 1 AS attempt FROM job_runs
+12 -6
View File
@@ -51,8 +51,7 @@ class InProcessJobRunner:
lock = self._lock(definition.lock_key)
if not lock.acquire(blocking=False):
return False
self._execute_locked(job_id, idempotency_key, action, metadata, lock)
return True
return self._execute_locked(job_id, idempotency_key, action, metadata, lock)
def start_scheduler(
self, callback: Callable[[], None], interval_seconds: float,
@@ -105,13 +104,19 @@ class InProcessJobRunner:
def _execute_locked(
self, job_id: str, idempotency_key: str, action: JobAction,
metadata: dict[str, Any] | None, lock: threading.Lock,
) -> None:
) -> bool:
definition = self.registry.get(job_id)
try:
for attempt in range(1, definition.max_attempts + 1):
run_id = self.repository.start(
job_id, idempotency_key, definition.output_version, metadata
job_id,
idempotency_key,
definition.output_version,
metadata,
definition.timeout_seconds,
)
if run_id is None:
return False
started = time.perf_counter()
try:
result = action()
@@ -119,7 +124,7 @@ class InProcessJobRunner:
raise RuntimeError(str(result.get("error") or "Job reported failure"))
elapsed_ms = round((time.perf_counter() - started) * 1000)
self.repository.finish(run_id, "success", elapsed_ms)
return
return True
except Exception as exc:
elapsed_ms = round((time.perf_counter() - started) * 1000)
self.repository.finish(
@@ -127,7 +132,8 @@ class InProcessJobRunner:
type(exc).__name__, str(exc),
)
if attempt >= definition.max_attempts:
return
return True
return True
finally:
lock.release()
+55 -55
View File
@@ -370,29 +370,29 @@
}
],
"css_layers": [
"/shared/tokens.css?v=20260820-3",
"/shared/tokens.css?v=20260806-2",
"/shared/base.css?v=20260806-1",
"/shared/shell.css?v=20260820-8",
"/shared/auth.css?v=20260820-5",
"/shared/components/controls.css?v=20260820-2",
"/shared/components/navigation.css?v=20260820-1",
"/shared/components/cards.css?v=20260820-1",
"/shared/components/tables.css?v=20260820-1",
"/shared/components/dialogs.css?v=20260820-3",
"/shared/shell.css?v=20260806-2",
"/shared/auth.css?v=20260806-1",
"/shared/components/controls.css?v=20260806-1",
"/shared/components/navigation.css?v=20260806-1",
"/shared/components/cards.css?v=20260806-1",
"/shared/components/tables.css?v=20260806-1",
"/shared/components/dialogs.css?v=20260806-1",
"/shared/components/feedback.css?v=20260806-1",
"/pages/market/foundation.css?v=20260820-4",
"/pages/sentiment/foundation.css?v=20260820-2",
"/pages/pools/foundation.css?v=20260820-1",
"/pages/ladder/foundation.css?v=20260820-1",
"/pages/rotation/foundation.css?v=20260820-1",
"/pages/auction/foundation.css?v=20260820-1",
"/pages/themes/foundation.css?v=20260820-1",
"/pages/popularity/foundation.css?v=20260820-1",
"/pages/dragon-tiger/foundation.css?v=20260820-1",
"/pages/screener/foundation.css?v=20260820-4",
"/pages/mentor/foundation.css?v=20260820-2",
"/pages/market/foundation.css?v=20260806-1",
"/pages/sentiment/foundation.css?v=20260806-2",
"/pages/pools/foundation.css?v=20260806-2",
"/pages/ladder/foundation.css?v=20260806-1",
"/pages/rotation/foundation.css?v=20260806-1",
"/pages/auction/foundation.css?v=20260806-2",
"/pages/themes/foundation.css?v=20260806-1",
"/pages/popularity/foundation.css?v=20260806-1",
"/pages/dragon-tiger/foundation.css?v=20260806-1",
"/pages/screener/foundation.css?v=20260806-2",
"/pages/mentor/foundation.css?v=20260807-1",
"/pages/heaven/foundation.css?v=20260806-2",
"/pages/review/foundation.css?v=20260820-4"
"/pages/review/foundation.css?v=20260806-1"
],
"frontend_composition": {
"shell": "frontend/index.html",
@@ -441,8 +441,8 @@
},
{
"path": "frontend/pages/screener/foundation.css",
"bytes": 103547,
"lines": 6576
"bytes": 102987,
"lines": 6565
},
{
"path": "frontend/pages/heaven/page.js",
@@ -451,8 +451,8 @@
},
{
"path": "frontend/shared/shell.css",
"bytes": 63550,
"lines": 3757
"bytes": 51978,
"lines": 3224
},
{
"path": "backend/features/heaven/engine.py",
@@ -461,8 +461,8 @@
},
{
"path": "frontend/index.html",
"bytes": 47871,
"lines": 661
"bytes": 45846,
"lines": 643
},
{
"path": "backend/features/screener/catalog.py",
@@ -471,8 +471,8 @@
},
{
"path": "frontend/pages/auction/foundation.css",
"bytes": 35247,
"lines": 2416
"bytes": 34990,
"lines": 2409
},
{
"path": "database.py",
@@ -531,7 +531,7 @@
},
{
"path": "frontend/pages/pools/page.html",
"bytes": 14942,
"bytes": 14958,
"lines": 235
},
{
@@ -541,8 +541,8 @@
},
{
"path": "frontend/shared/admin.js",
"bytes": 14145,
"lines": 261
"bytes": 13975,
"lines": 256
},
{
"path": "backend/features/heaven/market_context.py",
@@ -574,11 +574,6 @@
"bytes": 10539,
"lines": 244
},
{
"path": "frontend/shared/dashboard.js",
"bytes": 9993,
"lines": 220
},
{
"path": "backend/data/providers/tushare_sectors.py",
"bytes": 9876,
@@ -605,25 +600,25 @@
"lines": 238
},
{
"path": "frontend/pages/mentor/page.html",
"bytes": 8357,
"lines": 116
"path": "frontend/shared/dashboard.js",
"bytes": 8424,
"lines": 194
},
{
"path": "backend/features/screener/formula.py",
"bytes": 6983,
"lines": 146
},
{
"path": "backend/application.py",
"bytes": 6837,
"lines": 180
},
{
"path": "backend/data/providers/tushare_daily.py",
"bytes": 6837,
"lines": 160
},
{
"path": "backend/application.py",
"bytes": 6751,
"lines": 178
},
{
"path": "backend/features/market/insights_popularity.py",
"bytes": 6739,
@@ -649,6 +644,11 @@
"bytes": 6202,
"lines": 141
},
{
"path": "frontend/pages/mentor/page.html",
"bytes": 6190,
"lines": 89
},
{
"path": "backend/features/screener/selection.py",
"bytes": 6092,
@@ -704,11 +704,6 @@
"bytes": 4276,
"lines": 91
},
{
"path": "frontend/shared/theme.js",
"bytes": 4258,
"lines": 117
},
{
"path": "backend/features/screener/engine.py",
"bytes": 4242,
@@ -719,6 +714,11 @@
"bytes": 4118,
"lines": 115
},
{
"path": "frontend/shared/theme.js",
"bytes": 4118,
"lines": 115
},
{
"path": "frontend/shared/table.js",
"bytes": 3790,
@@ -736,7 +736,7 @@
},
{
"path": "frontend/pages/themes/page.html",
"bytes": 3316,
"bytes": 3309,
"lines": 55
},
{
@@ -829,11 +829,6 @@
"bytes": 1455,
"lines": 48
},
{
"path": "backend/features/system/routes.py",
"bytes": 1423,
"lines": 40
},
{
"path": "backend/features/themes/routes.py",
"bytes": 1337,
@@ -844,6 +839,11 @@
"bytes": 1195,
"lines": 30
},
{
"path": "backend/features/system/routes.py",
"bytes": 1178,
"lines": 32
},
{
"path": "frontend/pages/ladder/page.html",
"bytes": 1143,
-5
View File
@@ -1,8 +1,3 @@
> ⚠️ **本文档已过时,仅留档备查,请勿删除。**
> 本交接说明核实于 2026-08-06,其中「当前提交」「当前状态」「正在处理的事项」「验证记录」等已与代码现状不符(当时的未提交改动现已合并,项目已推进到全站视觉统一收尾阶段)。
> 最新内容请看 `docs/项目需求.md`、`docs/最新进度.md`、`docs/任务清单.md` 和 `docs/README.md`。
> 架构与维护规矩仍以根目录 `AGENTS.md`、`ARCHITECTURE.md` 为准;本文第 2、6 节(架构与决策)仍可作参考。
# 小白复盘项目交接说明
> 核实日期:2026-08-06Asia/Shanghai
+9 -33
View File
@@ -1,35 +1,11 @@
# 小白复盘 · 交接手册首页(首页说明)
# 文档索引
> 一句话:这是「小白复盘」项目的交接手册入口。新来的智能体(或人)先看这一页,再按下面顺序读四份文档,就能知道这个项目是干什么的、干到哪了、下一步做什么
- `product/小白复盘-完整产品规格说明书.md`:从零恢复产品时的完整功能与行为资产
- `HANDOFF.md`:当前仓库、架构、完成度、风险和后续验收的交接基线。
- `issues/README.md`:尚未完成事项的独立 Issue 索引;Issue 不是已创建的 Gitea 工单。
- `maintenance/人工维护指南.md`:当前正式源码的启动、修改、验收、数据和回退流程。
- `governance/`:架构决策、注册表治理和历次结构治理记录。
- `migration/`:从旧根目录保真迁入`app/`的历史账本、证据和失败版本记录。
## 先读哪些文件(按顺序)
1. `项目需求.md` —— 这个项目是干什么的、要解决什么问题、有哪些功能。
2. `最新进度.md` —— 目前整体做到哪一步了。
3. `任务清单.md` —— 正在做 / 已做完 / 还没安排,三栏一目了然。
4. 本文件 `README.md` —— 就是你现在看的这一页。
读完上面四份,就算“接手”了。想深入了解实现细节,再往下读。
## 想深入了解时再读这些
- `product/小白复盘-完整产品规格说明书.md` —— 最完整、最权威的“产品需求”说明书,从零重建项目都用它。
- `maintenance/人工维护指南.md` —— 怎么启动、怎么改代码、怎么跑测试、怎么备份和回退。
- `governance/` —— 架构决策和历次结构治理记录。
- `migration/` —— 从旧代码保真迁进 `app/` 的历史账本和证据,只用于审计和追溯,不参与运行。
- 根目录的 `AGENTS.md`(维护硬规矩)、`ARCHITECTURE.md`(技术架构)。
## 更新规矩(每完成或新增一个任务都要做)
任何智能体完成或新增一个任务后,必须顺手把这份手册更新到位,不能只改代码:
1. 任务做完或新增 → 更新 `任务清单.md`:把任务从「正在做」挪到「已做完」,或把新任务加进对应栏目。
2. 整体进度变了 → 更新 `最新进度.md`
3. 需求或功能变了 → 更新 `项目需求.md`(重大变化还要同步 `product/` 里的完整说明书)。
4. 更新完提交并推送进仓库(保存并上传到放代码的网站),不能只留在自己电脑里。
## 注意事项
- 旧文档不能删:被替代的旧文档开头要加一行「⚠️ 本文档已过时,仅留档备查,请勿删除」,再写新版。
- 用中文大白话写,专业词要带通俗解释,让不懂代码的人也能看懂。
- 「问天」板块是冻结区,任何改动都不许碰;写文档时别误导后来人去改它。
日常维护优先阅读根目录`AGENTS.md``ARCHITECTURE.md``HANDOFF.md`和维护指南。`migration/`只用于审计与
追溯,不参与应用启动、测试选择或运行时路径解析。
+1 -1
View File
@@ -16,4 +16,4 @@
| [010](ISSUE-010-live-provider-llm-readiness.md) | 数据源与 LLM 在线就绪核验 | 待运行核验 | P0 |
| [011](ISSUE-011-public-deployment-hardening.md) | 公网部署加固 | 未来范围 | P3 |
关闭任一 Issue 前,必须同步更新 `docs/最新进度.md``docs/任务清单.md` 的状态、验证日期和回档提交;不能只改 Issue 标题。旧版 `docs/HANDOFF.md` 已过时,仅留档备查。
关闭任一 Issue 前,必须更新 `docs/HANDOFF.md` 的状态、验证日期和回档提交;不能只改 Issue 标题。
-35
View File
@@ -1,35 +0,0 @@
# 任务清单
> 分三栏:正在做 / 已做完 / 还没安排。以任务板(Multica)和仓库内 `issues/` 目录为准,核实日期 2026-08-23。
## 正在做
| 任务 | 说明 | 状态 |
|---|---|---|
| 全站视觉统一改造收尾 | 主线。17 个阶段已完成,正在最终验收、代码合并 | 收尾中 |
| 手机端独立重新设计 | 先出视觉/交互规范和技术架构方案,等老板确认后再施工 | 方案送审中 |
## 已做完
| 任务 | 说明 |
|---|---|
| 全站视觉统一改造(17 个阶段) | 公共外壳、各市场数据页、智能工具页、账户页、顶栏布局返工等,逐批通过功能 + 视觉双重审核 |
| 数据库历史问题修复收口 | 老数据未被改动 |
| 顶端栏图标对齐 | 集合竞价 / 题材库 / 人气热榜三页,已部署 `.36:8765`(提交 `ed9858e` |
| 情绪周期页头改造等页面细节 | 去背景框、圆角分段控件 / 导出按钮(任务卡 HEL-31/36/42 等) |
| 架构治理(保真迁移) | 从旧根目录迁进 `app/`,模块化单体 + 注册表 + 统一验收工具,2026-08-01 人工验收 |
| 仓库交接手册整理 | 四份中文交接文档(需求 / 进度 / 任务 / 首页),已提交推送(提交 `224e2a7` |
## 还没安排
| 任务 | 说明 | 大致优先级 |
|---|---|---|
| 游资档案历史画像 | 更完整的游资历史操作画像 | 低 |
| 完整 IC 动态多因子选股 | 需要约 12 个月历史数据做因子有效性计算 | 中 |
| 公网部署加固 | 多实例、TLS、PostgreSQL 等(当前只内网用) | 低 / 未来 |
| 政策 / 宏观 / 隔夜消息数据 | 数据源还没定 | 中 |
| 分析师一致预期数据 | 数据源被阻塞 | 中低 |
| Level-2 竞价深度 | 需要授权 | 中低 |
> 说明:手机端施工,以及上面这些数据/算法类功能,做之前都要先和老板确认优先级,不要擅自开工。
> 仓库内更细的任务说明见 `issues/` 目录(ISSUE-001 到 ISSUE-011)。
-32
View File
@@ -1,32 +0,0 @@
# 最新进度
> 核实日期:2026-08-23,以代码仓库当前提交 `224e2a7` 为准(`ed9858e` 是最近一笔代码改动)。
## 一句话总结
项目主体功能早就做好并上线内网了。当前主线是「全站视觉统一改造」,已经走完 17 个阶段,正处于**最终验收、代码合并的收尾阶段**。同时新开了一条「手机端独立重新设计」的线(还在出方案,没动工)。本仓库的交接手册已整理完成(见 `任务清单.md`)。
## 已经做到哪了
- **代码结构**:已经从历史杂乱目录保真迁移进 `app/`,整理成「模块化单体」(一个 Python 进程、一个 SQLite 数据库、不需要构建工具的前端),用户在 2026-08-01 人工验收通过。
- **功能**:16 个主工作区都有正式实现(见 `项目需求.md`)。
- **视觉统一**:17 个阶段全部完成,每批都过了功能和视觉双重审核,返修版已部署到内网验收地址 `192.168.200.36:8765`
- **数据库历史问题**:已修复收口,老数据没有被改动。
- **最近的代码改动**(2026-08 下旬):主要是视觉收尾的细节——顶端栏图标对齐、设计令牌统一、问师/情绪周期等页面样式修整等,已合并进 `main`,并部署到内网 `.36:8765`
## 正在做的事
1. **全站视觉统一改造收尾**:最终验收 + 代码合并(主线)。
2. **手机端独立重新设计**:先出视觉规范和架构方案,等老板确认后再施工(见 `任务清单.md`)。
## 哪些还不能算“完成”
- **手机端**:当前有样式但基本是电脑端缩小,不可用。正在按“独立手机产品”重新设计,还没施工。
- **一些依赖外部数据/算法的高级功能还没做**:完整 IC 动态多因子选股、政策/宏观/隔夜消息、分析师一致预期、Level-2 竞价深度、游资档案历史画像(详见 `任务清单.md`)。
- **公网部署**:现在只在内网用,还没做公网加固。
## 关键时间点
- 2026-08-01`app/` 保真迁移完成,用户人工验收通过。
- 2026-08-06:上一版交接说明(`HANDOFF.md`)生成(现已过时,仅留档)。
- 2026-08-21:视觉收尾提交 `ed9858e` 合并进 `main`,部署到内网 `.36:8765`
-46
View File
@@ -1,46 +0,0 @@
# 项目需求
> 大白话版。逐页、逐字段的完整需求请看 `product/小白复盘-完整产品规格说明书.md`(那是最权威的“说明书”,从零重建项目都用它)。
## 这个项目是干什么的
「小白复盘」是一个给老板个人用的股票复盘工具网站,放在内网访问(地址 `192.168.200.36:8765`)。
它不是炒股下单软件,而是「收盘后和开盘前」用来复盘、观察市场的工具:
- 把当天(或最近)的真实行情、涨停跌停、板块轮动、集合竞价等信息整理清楚,帮老板复盘。
- 提供智能选股、问师(跟“游资思维”老师对话)等辅助分析。
- 记录自己的交易和复盘。
一句话:它帮你“看清市场、想清楚思路、记下来”,但不替你做买卖决定。
## 要解决什么问题
1. **市场信息太散**:涨停池、炸板池、跌停板、龙虎榜、人气榜、板块轮动这些信息本来分散在各处,这个网站把它们集中到一处,还配了日间/夜间两套配色,看起来统一。
2. **复盘靠脑子记不住**:提供“我的复盘”和交易日志,把每天的判断、操作、情绪记录下来。
3. **选股没思路**:智能选股用规则和因子(影响股价的数据指标)帮你筛出候选股票。
4. **想听“高手”怎么看**:问师模块可以按不同的游资思维(比如佛山无影脚、北京炒家等)跟老师对话。
## 主要功能(板块)
登录后侧栏有 16 个主工作区,默认进入「情绪周期」:
- **市场类(12 个)**:情绪周期、涨停池、炸板池、跌停板、昨日涨停、涨停表现、市场天梯、板块轮动、集合竞价、题材库、人气热榜、龙虎榜。
- **智能工具类(3 个)**:智能选股、问师、问天。
- **个人类(1 个)**:我的复盘。
其中「问天」是冻结区(见下面的硬规矩)。
## 几条硬规矩(不能破坏的边界)
- 「问天」板块是**冻结区**,任何改动都不许碰它。
- **不用假数据冒充真行情**;数据缺失就明说“没有/不可用”,不能编。
- **每个用户自己的数据互相隔离**(自选、复盘、对话、问天历史等),看不到别人的。
- **计算由程序确定性完成**(情绪周期、智能选股、问天排盘等),AI 大模型(LLM,就是会聊天的那个 AI)只负责解释或编译自然语言条件,不能改计算结果。
- **不接券商、不自动下单**,不承诺收益。
## 权限
- **普通用户**:能用大部分市场页面和「我的复盘」。
- **会员**:额外能用智能选股、问师、问天(需要管理员开通)。
- **管理员**:管理公共行情密钥、会员额度和系统配置等。
+71 -89
View File
@@ -5,21 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>小白复盘</title>
<script>
(() => {
const params = new URLSearchParams(window.location.search);
const ui = params.get("ui");
const path = window.location.pathname;
const alreadyMobile = path === "/m" || path.indexOf("/m/") === 0;
const forcedMobile = ui === "mobile";
const forcedDesktop = ui === "desktop";
const autoMobile = window.matchMedia && window.matchMedia("(max-width: 720px)").matches;
const mobileUA = /Android|iPhone|iPad|iPod|Mobile|Windows Phone/i.test(navigator.userAgent || "");
if (!forcedDesktop && !alreadyMobile && (forcedMobile || autoMobile || mobileUA)) {
window.location.replace("/m/" + window.location.search + window.location.hash);
}
})();
</script>
<script>
(() => {
let theme = "light";
@@ -32,29 +17,29 @@
document.documentElement.style.colorScheme = theme;
})();
</script>
<link rel="stylesheet" href="/shared/tokens.css?v=20260820-3">
<link rel="stylesheet" href="/shared/tokens.css?v=20260806-2">
<link rel="stylesheet" href="/shared/base.css?v=20260806-1">
<link rel="stylesheet" href="/shared/shell.css?v=20260820-8">
<link rel="stylesheet" href="/shared/auth.css?v=20260820-5">
<link rel="stylesheet" href="/shared/components/controls.css?v=20260820-2">
<link rel="stylesheet" href="/shared/components/navigation.css?v=20260820-1">
<link rel="stylesheet" href="/shared/components/cards.css?v=20260820-1">
<link rel="stylesheet" href="/shared/components/tables.css?v=20260820-1">
<link rel="stylesheet" href="/shared/components/dialogs.css?v=20260820-3">
<link rel="stylesheet" href="/shared/shell.css?v=20260806-2">
<link rel="stylesheet" href="/shared/auth.css?v=20260806-1">
<link rel="stylesheet" href="/shared/components/controls.css?v=20260806-1">
<link rel="stylesheet" href="/shared/components/navigation.css?v=20260806-1">
<link rel="stylesheet" href="/shared/components/cards.css?v=20260806-1">
<link rel="stylesheet" href="/shared/components/tables.css?v=20260806-1">
<link rel="stylesheet" href="/shared/components/dialogs.css?v=20260806-1">
<link rel="stylesheet" href="/shared/components/feedback.css?v=20260806-1">
<link rel="stylesheet" href="/pages/market/foundation.css?v=20260820-4">
<link rel="stylesheet" href="/pages/sentiment/foundation.css?v=20260820-2">
<link rel="stylesheet" href="/pages/pools/foundation.css?v=20260820-1">
<link rel="stylesheet" href="/pages/ladder/foundation.css?v=20260820-1">
<link rel="stylesheet" href="/pages/rotation/foundation.css?v=20260820-1">
<link rel="stylesheet" href="/pages/auction/foundation.css?v=20260820-1">
<link rel="stylesheet" href="/pages/themes/foundation.css?v=20260820-1">
<link rel="stylesheet" href="/pages/popularity/foundation.css?v=20260820-1">
<link rel="stylesheet" href="/pages/dragon-tiger/foundation.css?v=20260820-1">
<link rel="stylesheet" href="/pages/screener/foundation.css?v=20260820-4">
<link rel="stylesheet" href="/pages/mentor/foundation.css?v=20260820-2">
<link rel="stylesheet" href="/pages/market/foundation.css?v=20260806-1">
<link rel="stylesheet" href="/pages/sentiment/foundation.css?v=20260806-2">
<link rel="stylesheet" href="/pages/pools/foundation.css?v=20260806-2">
<link rel="stylesheet" href="/pages/ladder/foundation.css?v=20260806-1">
<link rel="stylesheet" href="/pages/rotation/foundation.css?v=20260806-1">
<link rel="stylesheet" href="/pages/auction/foundation.css?v=20260806-2">
<link rel="stylesheet" href="/pages/themes/foundation.css?v=20260806-1">
<link rel="stylesheet" href="/pages/popularity/foundation.css?v=20260806-1">
<link rel="stylesheet" href="/pages/dragon-tiger/foundation.css?v=20260806-1">
<link rel="stylesheet" href="/pages/screener/foundation.css?v=20260806-2">
<link rel="stylesheet" href="/pages/mentor/foundation.css?v=20260807-1">
<link rel="stylesheet" href="/pages/heaven/foundation.css?v=20260806-2">
<link rel="stylesheet" href="/pages/review/foundation.css?v=20260820-4">
<link rel="stylesheet" href="/pages/review/foundation.css?v=20260806-1">
</head>
<body>
<section id="authGate" class="auth-gate" aria-label="账号登录">
@@ -83,56 +68,12 @@
<span id="mobilePageGroup">行情</span>
<strong id="mobilePageTitle">情绪周期</strong>
</div>
<div class="app-page-context" aria-live="polite">
<strong id="currentPageTitle">小白复盘</strong>
<span id="currentPageSubtitle"></span>
<div class="market-tape" aria-label="市场概况">
<span class="market-item up">上涨 <strong id="tapeUp">--</strong></span>
<span class="market-item down">下跌 <strong id="tapeDown">--</strong></span>
<span class="market-item">涨停 <strong id="tapeLimit">--</strong></span>
<span class="market-item">成交额 <strong id="tapeAmount">--</strong></span>
</div>
<section class="overview-strip" aria-label="当日复盘指标" data-overview-expanded="false">
<div class="market-tape" aria-label="市场概况">
<div class="tape-metric tape-sentiment sentiment-block">
<span class="metric-label">市场情绪</span>
<span class="tape-metric-value">
<strong id="sentimentScore">--</strong>
<em id="sentimentText" class="sentiment-text">等待数据</em>
</span>
<div class="sentiment-gauge" id="sentimentGauge" aria-hidden="true"><span>--</span></div>
</div>
<div class="tape-metric market-item">
<span class="metric-label">涨停</span>
<strong id="limitUpMetric" class="metric-value up">--</strong>
</div>
<div class="tape-metric market-item">
<span class="metric-label">跌停</span>
<strong id="limitDownMetric" class="metric-value down">--</strong>
</div>
<div class="tape-metric tape-optional market-item">
<span class="metric-label">炸板</span>
<strong id="brokenMetric" class="metric-value warning">--</strong>
</div>
<div class="tape-metric tape-optional market-item">
<span class="metric-label">封板率</span>
<strong id="sealRateMetric" class="metric-value">--</strong>
</div>
<div class="tape-metric market-item">
<span class="metric-label">两市成交</span>
<strong id="amountMetric" class="metric-value">--</strong>
</div>
<button id="overviewToggle" class="overview-toggle" type="button" aria-expanded="false" title="展开市场详情">
<span>详情</span><i data-lucide="chevron-down" aria-hidden="true"></i>
</button>
</div>
<div class="tape-detail">
<div class="tape-detail-item market-item"><span>市场情绪</span><strong class="tape-metric-value"><b id="detailSentimentScore">--</b><em id="detailSentimentText" class="sentiment-text">等待数据</em></strong></div>
<div class="tape-detail-item market-item"><span>上涨家数</span><strong id="tapeUp" class="up">--</strong></div>
<div class="tape-detail-item market-item"><span>下跌家数</span><strong id="tapeDown" class="down">--</strong></div>
<div class="tape-detail-item market-item"><span>涨停</span><strong id="tapeLimit" class="up">--</strong></div>
<div class="tape-detail-item market-item"><span>跌停</span><strong id="tapeLimitDown" class="down">--</strong></div>
<div class="tape-detail-item market-item"><span>炸板</span><strong id="detailBroken" class="warning">--</strong></div>
<div class="tape-detail-item market-item"><span>封板率</span><strong id="detailSealRate">--</strong></div>
<div class="tape-detail-item market-item"><span>两市成交</span><strong id="tapeAmount">--</strong></div>
<div class="tape-detail-item tape-detail-date market-item"><span>数据日期</span><strong id="dataDateMetric" class="metric-value small">--</strong></div>
</div>
</section>
<div class="header-actions">
<div class="header-date-group">
@@ -141,7 +82,7 @@
<button id="nextDate" class="icon-button" type="button" title="后一个交易日" aria-label="后一个交易日"><i data-lucide="chevron-right"></i></button>
</div>
<button id="globalSearchButton" class="icon-button global-search-button" type="button" title="全局搜索(Ctrl+K" aria-label="全局搜索"><i data-lucide="search"></i></button>
<button id="themeToggle" class="icon-button theme-toggle" type="button" title="切换到夜间模式" aria-label="切换到夜间模式" aria-pressed="false"><i data-lucide="moon"></i><span id="themeModeText">日间模式</span></button>
<button id="themeToggle" class="icon-button theme-toggle" type="button" title="切换到夜间模式" aria-label="切换到夜间模式" aria-pressed="false"><i data-lucide="moon"></i></button>
<button id="alertButton" class="icon-button alert-button" type="button" title="提醒中心" aria-label="提醒中心"><i data-lucide="bell"></i><span id="alertBadge" class="alert-badge" hidden>0</span></button>
<button id="assistantButton" class="icon-button assistant-button" type="button" title="复盘助手" aria-label="复盘助手"><i data-lucide="message-circle-more"></i></button>
<button id="headerMenuButton" class="icon-button header-menu-button" type="button" title="打开命令菜单" aria-label="打开命令菜单" aria-expanded="false" aria-controls="headerCommandGroup"><i data-lucide="ellipsis"></i></button>
@@ -152,12 +93,12 @@
<button class="button mobile-command-shortcut" type="button" data-mobile-command-target="alertButton"><i data-lucide="bell"></i><span>提醒</span></button>
<button class="button mobile-command-shortcut" type="button" data-mobile-command-target="assistantButton"><i data-lucide="message-circle-more"></i><span>助手</span></button>
</div>
<button id="refreshButton" class="button command-button" type="button" title="刷新"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
<button id="syncButton" class="button primary command-button" type="button" title="后台刷新" hidden><i data-lucide="cloud-download"></i><span>后台刷新</span></button>
<button id="settingsButton" class="button command-button" type="button" title="系统管理" hidden><i data-lucide="settings-2"></i><span>系统管理</span></button>
<button id="refreshButton" class="button command-button" type="button"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
<button id="syncButton" class="button primary command-button" type="button" hidden><i data-lucide="cloud-download"></i><span>后台刷新</span></button>
<button id="settingsButton" class="button command-button" type="button" hidden><i data-lucide="settings-2"></i><span>系统管理</span></button>
<div class="account-menu-shell">
<div id="accountRoleBadges" class="account-role-badges" aria-label="账号身份">
<span id="accountAdminBadge" class="account-role-badge admin-role-badge" title="管理员" hidden><i data-lucide="shield-check"></i><span>管理员</span></span>
<span id="accountAdminBadge" class="account-role-badge admin-role-badge" hidden><i data-lucide="shield-check"></i><span>管理员</span></span>
<button id="accountVipBadge" class="account-role-badge vip-role-badge" type="button" title="查看会员状态" hidden><b aria-hidden="true">V</b><span id="accountVipLabel">非会员</span></button>
</div>
<button id="accountButton" class="button account-button command-button" type="button" title="账号菜单" aria-haspopup="menu" aria-expanded="false" aria-controls="accountDropdown"><i data-lucide="circle-user-round"></i><span id="accountName">--</span><i class="account-menu-chevron" data-lucide="chevron-down"></i></button>
@@ -228,6 +169,47 @@
</select>
<i data-lucide="chevron-down" aria-hidden="true"></i>
</label>
<section class="overview-strip" aria-label="当日复盘指标" data-overview-expanded="false">
<div class="row">
<div class="sentiment-block">
<div class="sentiment-gauge" id="sentimentGauge">
<span id="sentimentScore">--</span>
</div>
<div>
<span class="metric-label">市场情绪</span>
<strong id="sentimentText" class="sentiment-text">等待数据</strong>
</div>
</div>
<div class="metric">
<span class="metric-label">涨停</span>
<strong id="limitUpMetric" class="metric-value up">--</strong>
</div>
<div class="metric">
<span class="metric-label">跌停</span>
<strong id="limitDownMetric" class="metric-value down">--</strong>
</div>
<div class="metric">
<span class="metric-label">炸板</span>
<strong id="brokenMetric" class="metric-value warning">--</strong>
</div>
<div class="metric">
<span class="metric-label">封板率</span>
<strong id="sealRateMetric" class="metric-value">--</strong>
</div>
<div class="metric">
<span class="metric-label">两市成交</span>
<strong id="amountMetric" class="metric-value">--</strong>
</div>
<div class="metric metric-wide">
<span class="metric-label">数据日期</span>
<strong id="dataDateMetric" class="metric-value small">--</strong>
</div>
<button id="overviewToggle" class="overview-toggle" type="button" aria-expanded="false" title="展开市场详情">
<span>展开详情</span><i data-lucide="chevron-down" aria-hidden="true"></i>
</button>
</div>
</section>
<!-- Registered page fragments mount here. -->
</main>
</div>
-302
View File
@@ -1,302 +0,0 @@
(function (global) {
"use strict";
const nav = {
entries: [
{ key: "market", title: "行情数据", subtitle: "情绪 · 梯队 · 题材 · 龙虎榜", icon: "bar-chart-3" },
{ key: "tools", title: "智能工具", subtitle: "智能选股 · 策略跟踪 · 问师", icon: "wand-2" },
{ key: "review", title: "我的复盘", subtitle: "自选 · 交易 · 复盘 · 笔记 · 提醒", icon: "notebook-pen" },
{ key: "assistant", title: "复盘助手", subtitle: "AI 对话复盘", icon: "message-square" },
{ key: "system", title: "系统管理", subtitle: "账号 · 密码 · 会员 · 设置", icon: "settings" }
],
hubs: {
market: {
title: "行情数据",
items: [
{ key: "market/sentiment", label: "情绪周期", icon: "activity" },
{ key: "market/limit-up", label: "涨停池", icon: "trending-up" },
{ key: "market/broken", label: "炸板池", icon: "zap" },
{ key: "market/limit-down", label: "跌停池", icon: "trending-down" },
{ key: "market/yesterday", label: "昨日涨停", icon: "history" },
{ key: "market/performance", label: "涨停表现", icon: "chart-line" },
{ key: "market/ladder", label: "市场天梯", icon: "layers" },
{ key: "market/rotation", label: "主题轮动", icon: "refresh-cw" },
{ key: "market/auction", label: "竞价", icon: "gavel" },
{ key: "market/themes", label: "题材库", icon: "book-open" },
{ key: "market/popularity", label: "人气榜", icon: "flame" },
{ key: "market/dragon", label: "龙虎榜", icon: "crown" }
]
},
tools: {
title: "智能工具",
items: [
{ key: "tools/screener", label: "智能选股", icon: "filter" },
{ key: "tools/tracking", label: "策略跟踪", icon: "target" },
{ key: "tools/mentor", label: "问师", icon: "bot" }
]
},
review: {
title: "我的复盘",
items: [
{ key: "review/watchlist", label: "自选股", icon: "star" },
{ key: "review/trades", label: "交易日志", icon: "scroll-text" },
{ key: "review/daily", label: "每日复盘", icon: "calendar-check" },
{ key: "review/notes", label: "个股笔记", icon: "sticky-note" },
{ key: "review/alerts", label: "提醒中心", icon: "bell" }
]
},
system: {
title: "系统管理",
items: [
{ key: "system/profile", label: "账号资料", icon: "user" },
{ key: "system/password", label: "修改密码", icon: "lock" },
{ key: "system/membership", label: "会员状态", icon: "gem" },
{ key: "system/admin", label: "系统设置", icon: "sliders-horizontal", adminOnly: true },
{ key: "system/members", label: "会员管理", icon: "users", adminOnly: true }
]
}
},
tableDefaults: {
frozenColumns: ["name", "code"],
primaryColumns: [],
scrollColumns: []
},
tableColumns: {
"market/limit-up": {
summary: "limit",
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "streak", label: "连板", type: "streak" },
{ key: "change", label: "涨幅%", type: "change" },
{ key: "price", label: "价格", type: "price" }
],
scrollColumns: [
{ key: "sector", label: "所属板块", type: "text" },
{ key: "first_time", label: "首封", type: "text" },
{ key: "last_time", label: "最后封板", type: "text" },
{ key: "open_times", label: "开板", type: "int" },
{ key: "turnover_rate", label: "换手%", type: "rate" },
{ key: "amount_billion", label: "成交额亿", type: "money" },
{ key: "seal_amount_million", label: "封单额万", type: "int", hideZero: true },
{ key: "reason", label: "涨停原因", type: "text", wide: true }
]
},
"market/broken": {
summary: "broken",
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "change", label: "现价涨幅%", type: "change" },
{ key: "limitGap", label: "距涨停%", type: "gap" },
{ key: "price", label: "价格", type: "price" }
],
scrollColumns: [
{ key: "sector", label: "所属板块", type: "text" },
{ key: "first_time", label: "首次触板", type: "text" },
{ key: "open_times", label: "开板", type: "int" },
{ key: "turnover_rate", label: "换手%", type: "rate" },
{ key: "amount_billion", label: "成交额亿", type: "money" },
{ key: "reason", label: "炸板原因", type: "text", wide: true }
]
},
"market/limit-down": {
summary: "down",
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "change", label: "跌幅%", type: "change" },
{ key: "price", label: "价格", type: "price" }
],
scrollColumns: [
{ key: "sector", label: "所属板块", type: "text" },
{ key: "turnover_rate", label: "换手%", type: "rate" },
{ key: "amount_billion", label: "成交额亿", type: "money" },
{ key: "streak", label: "连续跌停", type: "int", hideZero: true },
{ key: "reason", label: "风险线索", type: "text", wide: true }
]
},
"market/yesterday": {
summary: "yesterday",
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "current_change", label: "今日涨幅%", type: "change" },
{ key: "outcome", label: "今日结果", type: "outcome" },
{ key: "prior_streak", label: "昨日高度", type: "int" }
],
scrollColumns: [
{ key: "current_streak", label: "当前高度", type: "height" },
{ key: "sector", label: "所属板块", type: "text" },
{ key: "reason", label: "涨停逻辑", type: "text", wide: true }
]
},
"market/performance": {
summary: "performance",
frozenColumns: [
{ key: "label", label: "梯队", type: "text", width: 96 }
],
primaryColumns: [
{ key: "advance_rate", label: "晋级率%", type: "advance" },
{ key: "advanced", label: "晋级", type: "int" },
{ key: "positive_rate", label: "收红率%", type: "rate" }
],
scrollColumns: [
{ key: "count", label: "样本", type: "int" },
{ key: "average_change", label: "平均涨幅%", type: "change" }
]
},
"market/popularity": {
summary: "popularity",
sources: ["combined", "ths", "dc"],
columns: {
combined: {
frozenColumns: [
{ key: "rank", label: "#", type: "rank", width: 40 },
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "price", label: "最新价", type: "price" },
{ key: "change", label: "涨跌幅%", type: "change" }
],
scrollColumns: [
{ key: "ths_rank", label: "同花顺", type: "int", hideZero: true },
{ key: "dc_rank", label: "东方财富", type: "int", hideZero: true },
{ key: "rank_change", label: "排名变化", type: "move" },
{ key: "concepts", label: "热门概念", type: "concepts" }
]
},
ths: {
frozenColumns: [
{ key: "rank", label: "#", type: "rank", width: 40 },
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "price", label: "最新价", type: "price" },
{ key: "change", label: "涨跌幅%", type: "change" }
],
scrollColumns: [
{ key: "dc_rank", label: "东方财富", type: "int", hideZero: true },
{ key: "rank_change", label: "排名变化", type: "move" },
{ key: "concepts", label: "热门概念", type: "concepts" },
{ key: "dual_source", label: "榜单状态", type: "dual" }
]
},
dc: {
frozenColumns: [
{ key: "rank", label: "#", type: "rank", width: 40 },
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "price", label: "最新价", type: "price" },
{ key: "change", label: "涨跌幅%", type: "change" }
],
scrollColumns: [
{ key: "ths_rank", label: "同花顺", type: "int", hideZero: true },
{ key: "rank_change", label: "排名变化", type: "move" },
{ key: "concepts", label: "热门概念", type: "concepts" },
{ key: "dual_source", label: "榜单状态", type: "dual" }
]
}
}
},
"market/sentiment/history": {
frozenColumns: [
{ key: "trade_date", label: "日期", type: "text", width: 88 }
],
primaryColumns: [
{ key: "score", label: "温度", type: "int" },
{ key: "phase", label: "阶段", type: "text" },
{ key: "direction", label: "方向", type: "text" }
],
scrollColumns: [
{ key: "limit_up_count", label: "涨停", type: "int" },
{ key: "first_board_count", label: "首板", type: "int" },
{ key: "second_board_count", label: "二板", type: "int" },
{ key: "three_plus_count", label: "三板+", type: "int" },
{ key: "max_height", label: "高度", type: "int" },
{ key: "broken_count", label: "炸板", type: "int" },
{ key: "limit_down_count", label: "跌停", type: "int" },
{ key: "seal_rate", label: "封板率%", type: "rate" },
{ key: "previous_limit_count", label: "昨涨停", type: "int" },
{ key: "previous_positive_rate", label: "昨红率%", type: "rate" }
]
},
"market/auction": {
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "attention_score", label: "关注分", type: "score" },
{ key: "change", label: "竞价涨幅%", type: "change" },
{ key: "expectation", label: "预期", type: "expectation" }
],
scrollColumns: [
{ key: "sector", label: "方向", type: "text" },
{ key: "amount_million", label: "竞价额百万", type: "money" },
{ key: "volume_ratio", label: "量比", type: "rate" }
]
},
"market/rotation/members": {
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "change", label: "涨跌幅%", type: "change" },
{ key: "close", label: "收盘", type: "price" }
],
scrollColumns: [
{ key: "open", label: "开盘", type: "price" },
{ key: "amount_billion", label: "成交额亿", type: "money" }
]
},
"market/themes/members": {
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "change", label: "涨跌幅%", type: "change" },
{ key: "price", label: "现价", type: "price" }
],
scrollColumns: [
{ key: "amount_billion", label: "成交额亿", type: "money" }
]
},
"market/dragon/operations": {
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "direction", label: "方向", type: "direction" },
{ key: "change", label: "涨幅%", type: "change" }
],
scrollColumns: [
{ key: "buy_million", label: "买入百万", type: "money" },
{ key: "sell_million", label: "卖出百万", type: "money" },
{ key: "net_buy_million", label: "净额百万", type: "change" },
{ key: "seat_name", label: "席位", type: "text", wide: true },
{ key: "reason", label: "上榜原因", type: "text", wide: true }
]
}
}
};
global.MobileNav = nav;
})(window);
File diff suppressed because it is too large Load Diff
-559
View File
@@ -1,559 +0,0 @@
[hidden] {
display: none !important;
}
#m-app,
#m-app * {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background: #f4f5f7;
}
#m-app {
min-height: 100vh;
background: var(--canvas);
color: var(--text-primary);
overflow-x: hidden;
-webkit-tap-highlight-color: transparent;
transition: background-color var(--motion-pop) var(--ease-press),
border-color var(--motion-pop) var(--ease-press);
}
#m-boot-splash {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--canvas);
z-index: 200;
}
#m-boot-splash .m-brand-mark {
margin-bottom: 0;
}
.m-motion-fade-in {
animation: m-fade-in var(--motion-fade) var(--ease-press) both;
}
@keyframes m-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.m-motion-rise-in {
animation: m-rise-in var(--motion-enter) var(--ease-enter) both;
}
@keyframes m-rise-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.m-motion-push-in {
animation: m-push-in var(--motion-enter) var(--ease-enter) both;
}
@keyframes m-push-in {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: none; }
}
.m-motion-pop-in {
animation: m-pop-in var(--motion-pop) var(--ease-enter) both;
}
@keyframes m-pop-in {
from { opacity: 0; transform: translateX(-16px); }
to { opacity: 1; transform: none; }
}
.m-motion-boot-in {
animation: m-fade-in var(--motion-press-release) var(--ease-press) both;
}
.m-header {
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
height: var(--mobile-header-height);
padding: 0 4px;
background: var(--surface);
border-bottom: 1px solid var(--border);
transition: background-color var(--motion-pop) var(--ease-press),
border-color var(--motion-pop) var(--ease-press);
}
.m-header-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--mobile-touch-size);
height: var(--mobile-touch-size);
flex: 0 0 var(--mobile-touch-size);
border: 0;
background: transparent;
color: var(--text-primary);
cursor: pointer;
transition: transform var(--motion-press-release) var(--ease-press),
background-color var(--motion-press-release) var(--ease-press);
}
.m-header-btn:active {
background: var(--surface-hover);
transform: scale(0.94);
transition-duration: var(--motion-press);
}
.m-title {
flex: 1;
margin: 0;
font-size: var(--font-size-page-title);
font-weight: 600;
text-align: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: color var(--motion-pop) var(--ease-press);
}
.m-actions {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
}
.m-view {
padding: var(--mobile-page-padding);
padding-bottom: calc(var(--mobile-page-padding) + var(--mobile-safe-bottom));
max-width: 100%;
overflow-x: hidden;
}
#m-app[data-tabbar="true"] .m-view {
padding-bottom: calc(var(--mobile-tabbar-height) + var(--mobile-safe-bottom) + var(--mobile-page-padding));
}
.m-placeholder {
padding: 40px 16px;
text-align: center;
}
.m-placeholder-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
margin-bottom: 12px;
border-radius: 50%;
background: var(--surface-muted);
color: var(--text-tertiary);
}
.m-placeholder h2 {
margin: 0 0 8px;
font-size: var(--font-size-card-title);
font-weight: 600;
}
.m-placeholder p {
margin: 0;
font-size: var(--font-size-label);
color: var(--text-secondary);
line-height: 1.5;
}
.m-hub-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.m-grid-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: var(--mobile-icon-cell);
padding: 8px 4px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--motion-press-release) var(--ease-press),
background-color var(--motion-press-release) var(--ease-press);
}
.m-grid-item:active {
background: var(--surface-hover);
transform: scale(0.94);
transition-duration: var(--motion-press);
}
.m-grid-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: var(--action-soft);
color: var(--action);
flex: 0 0 auto;
transition: background-color var(--motion-pop) var(--ease-press),
border-color var(--motion-pop) var(--ease-press);
}
.m-grid-icon svg {
width: 22px;
height: 22px;
}
.m-grid-label {
font-size: 12px;
line-height: 1.3;
color: var(--text-primary);
text-align: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
transition: color var(--motion-pop) var(--ease-press);
}
.m-auth {
padding-top: 8px;
}
.m-auth-brand {
text-align: center;
margin: 24px 0 20px;
}
.m-brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--action);
color: var(--text-inverse);
font-size: 24px;
font-weight: 600;
margin-bottom: 12px;
}
.m-auth-brand h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.m-auth-brand p {
margin: 4px 0 0;
font-size: var(--font-size-label);
color: var(--text-secondary);
}
.m-auth-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.m-auth-tab {
flex: 1;
height: 44px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface);
color: var(--text-secondary);
font-size: var(--font-size-body);
cursor: pointer;
}
.m-auth-tab.active {
background: var(--action-soft);
border-color: var(--action);
color: var(--action);
font-weight: 600;
}
.m-form-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.m-form-field span {
font-size: var(--font-size-label);
color: var(--text-secondary);
}
.m-form-field input {
height: 44px;
padding: 0 12px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface);
color: var(--text-primary);
font-size: 14px;
}
.m-form-field input:focus {
outline: none;
border-color: var(--action);
}
.m-auth-error {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 8px;
background: var(--market-up-soft);
color: var(--market-up);
font-size: var(--font-size-label);
}
.m-btn-primary {
width: 100%;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 0;
border-radius: 8px;
background: var(--action);
color: var(--text-inverse);
font-size: var(--font-size-body);
font-weight: 600;
cursor: pointer;
transition: transform var(--motion-press-release) var(--ease-press),
background-color var(--motion-press-release) var(--ease-press);
}
.m-btn-primary:active {
background: var(--action-hover);
transform: scale(0.98);
transition-duration: var(--motion-press);
}
.m-btn-primary:disabled {
opacity: 0.5;
cursor: default;
pointer-events: none;
}
.m-btn-spinner {
display: inline-block;
width: 14px;
height: 14px;
flex: 0 0 auto;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: m-spin 800ms linear infinite;
}
@keyframes m-spin {
to { transform: rotate(360deg); }
}
.m-tabbar {
position: fixed;
left: 0;
right: 0;
bottom: var(--m-keyboard-inset);
z-index: 60;
display: flex;
align-items: stretch;
height: calc(var(--mobile-tabbar-height) + var(--mobile-safe-bottom));
padding-bottom: var(--mobile-safe-bottom);
background: var(--surface);
border-top: 1px solid var(--border);
transition: background-color var(--motion-pop) var(--ease-press),
border-color var(--motion-pop) var(--ease-press);
}
.m-tabbar-item {
flex: 1;
min-width: 0;
min-height: var(--mobile-tabbar-height);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
border: 0;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--motion-press-release) var(--ease-press),
background-color var(--motion-press-release) var(--ease-press),
color var(--motion-pop) var(--ease-press);
}
.m-tabbar-item:active {
background: var(--surface-hover);
transform: scale(0.94);
transition-duration: var(--motion-press), var(--motion-press), var(--motion-pop);
}
.m-tabbar-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
flex: 0 0 auto;
transition: transform var(--motion-press-release) var(--ease-press);
}
.m-tabbar-item:active .m-tabbar-icon {
transform: scale(0.88);
transition-duration: var(--motion-press);
}
.m-tabbar-label {
font-size: var(--mobile-tabbar-label-size);
line-height: 1.2;
color: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.m-tabbar-item.active {
color: var(--action);
}
.m-theme-section {
margin-bottom: 12px;
}
.m-theme-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 12px;
background: var(--surface);
box-shadow: var(--elevation-card);
transition: background-color var(--motion-pop) var(--ease-press),
border-color var(--motion-pop) var(--ease-press);
}
.m-theme-row-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: var(--action-soft);
color: var(--action);
flex: 0 0 auto;
transition: background-color var(--motion-pop) var(--ease-press),
border-color var(--motion-pop) var(--ease-press);
}
.m-theme-row-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.m-theme-row-body strong {
font-size: var(--font-size-card-title);
font-weight: 600;
line-height: 1.4;
}
.m-theme-row-body small {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
.m-theme-switch {
position: relative;
flex: 0 0 auto;
width: 48px;
height: 28px;
padding: 0;
border: 0;
border-radius: 999px;
background: var(--border-strong);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.m-theme-switch[aria-checked="true"] {
background: var(--action);
}
.m-theme-switch-thumb {
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--text-inverse);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
transition: transform 0.15s ease;
}
.m-theme-switch[aria-checked="true"] .m-theme-switch-thumb {
transform: translateX(20px);
}
@media (prefers-reduced-motion: reduce) {
.m-motion-rise-in,
.m-motion-push-in,
.m-motion-pop-in {
animation-name: m-fade-in;
animation-duration: 100ms;
}
.m-motion-fade-in {
animation-duration: 100ms;
}
.m-grid-item:active,
.m-header-btn:active,
.m-btn-primary:active,
.m-tabbar-item:active {
transform: none;
}
.m-tabbar-item:active .m-tabbar-icon {
transform: none;
}
}
-101
View File
@@ -1,101 +0,0 @@
#m-app {
color-scheme: light;
--canvas: #f4f5f7;
--surface: #ffffff;
--surface-muted: #f2f3f5;
--surface-subtle: #f8f9fb;
--surface-raised: #ffffff;
--surface-hover: #f2f3f5;
--surface-selected: #eaf1fe;
--border: #eceded;
--border-strong: #dee0e3;
--border-subtle: #eef0f3;
--text-primary: #1f2329;
--text-secondary: #646a73;
--text-tertiary: #8f959e;
--text-inverse: #ffffff;
--action: #3370ff;
--action-hover: #2b5fd9;
--action-soft: #eaf1fe;
--market-up: #e04536;
--market-up-soft: #fdecea;
--market-down: #16a34a;
--market-down-soft: #e9f7ee;
--warning: #b45309;
--warning-soft: #fdf3e3;
--chart-up: #c93f45;
--chart-down: #087a55;
--elevation-card: 0 1px 2px rgba(31, 35, 41, .04);
--elevation-float: 0 12px 32px rgba(0, 0, 0, .14);
--backdrop: rgba(17, 24, 39, .48);
--motion-press: 100ms;
--motion-press-release: 160ms;
--motion-fade: 120ms;
--motion-enter: 260ms;
--motion-exit: 200ms;
--motion-pop: 240ms;
--ease-enter: cubic-bezier(0.22, 1, 0.36, 1);
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
--ease-press: ease-out;
--mobile-header-height: 52px;
--mobile-touch-size: 44px;
--mobile-safe-bottom: env(safe-area-inset-bottom, 0px);
--mobile-page-padding: 12px;
--mobile-chart-height: 240px;
--mobile-chart-height-compact: 168px;
--mobile-table-row-height: 44px;
--mobile-table-header-height: 40px;
--mobile-icon-cell: 78px;
--mobile-sheet-radius: 12px;
--mobile-min-width: 320px;
--mobile-tabbar-height: 56px;
--mobile-tabbar-label-size: 11px;
--m-keyboard-inset: 0px;
--font-size-aux: 11.5px;
--font-size-caption: 12.5px;
--font-size-label: 13px;
--font-size-table: 13.5px;
--font-size-body: 14px;
--font-size-card-title: 15px;
--font-size-page-title: 18px;
--font-size-metric: 24px;
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
font-size: 14px;
}
#m-app[data-theme="dark"] {
color-scheme: dark;
--canvas: #141519;
--surface: #232529;
--surface-muted: #2a2d33;
--surface-subtle: #202329;
--surface-raised: #232529;
--surface-hover: #2a2d33;
--surface-selected: #2b3b58;
--border: #2b2e34;
--border-strong: #3a3e47;
--border-subtle: #2b2e34;
--text-primary: #e8eaed;
--text-secondary: #a9adb3;
--text-tertiary: #7c828a;
--text-inverse: #ffffff;
--action: #5b8def;
--action-hover: #7ba5f5;
--action-soft: #2b3b58;
--market-up: #f26762;
--market-up-soft: #3d2829;
--market-down: #43bc8a;
--market-down-soft: #22362c;
--warning: #e2ad58;
--warning-soft: #3d3220;
--chart-up: #f06d73;
--chart-down: #43bc8a;
--elevation-card: 0 1px 2px rgba(0, 0, 0, .28);
--elevation-float: 0 12px 32px rgba(0, 0, 0, .46);
--backdrop: rgba(0, 0, 0, .66);
}
-46
View File
@@ -1,46 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>小白复盘</title>
<link rel="stylesheet" href="css/tokens.css">
<link rel="stylesheet" href="css/shell.css">
<link rel="stylesheet" href="css/features.css">
</head>
<body>
<div id="m-app" data-theme="light">
<script>
(() => {
let theme = "light";
try {
theme = localStorage.getItem("xiaobaiTheme") === "dark" ? "dark" : "light";
} catch (_error) {
theme = "light";
}
document.getElementById("m-app").setAttribute("data-theme", theme);
})();
</script>
<div id="m-boot-splash" aria-hidden="true">
<span class="m-brand-mark"></span>
</div>
<header id="m-header" class="m-header">
<button id="m-back" class="m-header-btn" type="button" aria-label="返回" hidden>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
</button>
<h1 id="m-title" class="m-title">小白复盘</h1>
<div id="m-actions" class="m-actions"></div>
</header>
<main id="m-view" class="m-view"></main>
<nav id="m-tabbar" class="m-tabbar" aria-label="主导航" hidden></nav>
</div>
<script src="config/nav.config.js"></script>
<script src="../shared/api.js"></script>
<script src="js/api.js"></script>
<script src="js/session.js"></script>
<script src="js/router.js"></script>
<script src="js/pages.js"></script>
<script src="js/boot.js"></script>
</body>
</html>
-30
View File
@@ -1,30 +0,0 @@
(function (global) {
"use strict";
// 手机端 API 薄封装:唯一 fetch 出口收敛到 shared/api.jsXiaobaiAPI)。
// 本文件不再直接调用 fetch,仅负责把 CSRF Token 配置进共享出口并转发请求,
// 保持既有 MobileAPI.request(url[, method, body]) 调用面不变。
let csrfToken = "";
function setCsrfToken(token) {
csrfToken = token || "";
global.XiaobaiAPI.configure({
csrfToken: function () {
return csrfToken;
}
});
}
async function request(url, method, body) {
return global.XiaobaiAPI.request(url, method, body);
}
// 流式聊天复用共享层的 streamNdjson:每解析一行 NDJSON 就回调 onEvent
// 调用方通过 { signal } 传入 AbortController 以支持聊天页的「停止」按钮。
function streamNdjson(url, options) {
return global.XiaobaiAPI.streamNdjson(url, options || {});
}
global.MobileAPI = { setCsrfToken: setCsrfToken, request: request, streamNdjson: streamNdjson };
})(window);
-86
View File
@@ -1,86 +0,0 @@
(function (global) {
"use strict";
const THEME_KEY = "xiaobaiTheme";
function readTheme() {
try {
return global.localStorage.getItem(THEME_KEY) === "dark" ? "dark" : "light";
} catch (_error) {
return "light";
}
}
function applyTheme(theme) {
const normalized = theme === "dark" ? "dark" : "light";
const app = document.getElementById("m-app");
app.dataset.theme = normalized;
app.style.colorScheme = normalized;
document.body.style.backgroundColor = normalized === "dark" ? "#141519" : "#f4f5f7";
return normalized;
}
function toggle() {
const next = readTheme() === "dark" ? "light" : "dark";
try {
global.localStorage.setItem(THEME_KEY, next);
} catch (_error) {
// Theme still applies for the current page when storage is unavailable.
}
applyTheme(next);
}
global.MobileTheme = { readTheme: readTheme, applyTheme: applyTheme, toggle: toggle };
function syncKeyboardInset() {
const app = document.getElementById("m-app");
const visual = global.visualViewport;
if (!visual) {
app.style.setProperty("--m-keyboard-inset", "0px");
return;
}
// 键盘高度 = 布局视口高度 - 可视视口高度。iOS 弹键盘时会同时抬升 offsetTop,
// 若再扣 offsetTop 会把 inset 算成 0,导致输入区/底栏不避让,故只按高度差计算。
const inset = Math.max(0, global.innerHeight - visual.height);
app.style.setProperty("--m-keyboard-inset", inset + "px");
}
function bindViewport() {
const visual = global.visualViewport;
if (visual) {
visual.addEventListener("resize", syncKeyboardInset);
visual.addEventListener("scroll", syncKeyboardInset);
}
syncKeyboardInset();
}
async function boot() {
applyTheme(readTheme());
bindViewport();
let session = { authenticated: false };
try {
session = await global.MobileSession.me();
} catch (_error) {
session = { authenticated: false };
}
const hash = global.location.hash || "";
if (!session.authenticated) {
if (!/^#\/?auth/.test(hash)) global.location.replace("#/auth");
} else if (/^#\/?auth/.test(hash)) {
global.location.replace("#/hub/market");
}
removeBootSplash();
global.MobileRouter.init();
}
function removeBootSplash() {
const splash = document.getElementById("m-boot-splash");
if (splash) splash.remove();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
})(window);
File diff suppressed because it is too large Load Diff
-427
View File
@@ -1,427 +0,0 @@
(function (global) {
"use strict";
const ICONS = {
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
"sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
"moon": '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
"bar-chart-3": '<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
"wand-2": '<path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72Z"/><path d="m14 7 3 3"/><path d="M5 6v4"/><path d="M19 14v4"/><path d="M10 2v2"/><path d="M7 8H3"/><path d="M21 16h-4"/><path d="M11 3H9"/>',
"notebook-pen": '<path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4"/><path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/><path d="m21.38 5.63-3-3a1 1 0 0 0-1.42 0l-5.01 5.01a2 2 0 0 0-.5.85l-.84 2.87a.5.5 0 0 0 .62.62l2.87-.84a2 2 0 0 0 .85-.5z"/>',
"message-square": '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
"settings": '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
"inbox": '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
"activity": '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
"trending-up": '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
"trending-down": '<polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/>',
"zap": '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
"history": '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
"chart-line": '<path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/>',
"layers": '<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
"refresh-cw": '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
"gavel": '<path d="m14 13-7.5 7.5c-.83.83-2.17.83-3 0 0 0 0 0 0 0a2.12 2.12 0 0 1 0-3L11 10"/><path d="m16 16 6-6"/><path d="m8 8 6-6"/><path d="m9 7 8 8"/><path d="m21 11-8-8"/>',
"book-open": '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
"flame": '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
"crown": '<path d="M11.562 3.266a.5.5 0 0 1 .876 0L15.39 8.87a1 1 0 0 0 1.516.294L21.183 5.5a.5.5 0 0 1 .798.519l-2.834 10.246a1 1 0 0 1-.956.735H5.81a1 1 0 0 1-.957-.735L2.02 6.02a.5.5 0 0 1 .798-.519l4.276 3.664a1 1 0 0 0 1.516-.294z"/><path d="M5 21h14"/>',
"filter": '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
"target": '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
"bot": '<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>',
"star": '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
"scroll-text": '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
"calendar-check": '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="m9 16 2 2 4-4"/>',
"sticky-note": '<path d="M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8Z"/><path d="M15 3v4a2 2 0 0 0 2 2h4"/>',
"bell": '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
"user": '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
"lock": '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
"gem": '<path d="M6 3h12l4 6-10 13L2 9Z"/><path d="M11 3 8 9l4 13 4-13-3-6"/><path d="M2 9h20"/>',
"sliders-horizontal": '<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>',
"users": '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'
};
function icon(name, size) {
const body = ICONS[name] || "";
return '<svg width="' + (size || 22) + '" height="' + (size || 22) + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + body + "</svg>";
}
function escapeHtml(value) {
return String(value == null ? "" : value).replace(/[&<>"']/g, function (ch) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch];
});
}
const DEFAULT_HASH = "#/hub/market";
const stack = [];
let internalNav = 0;
let authMode = "login";
const VIEW_MOTION_CLASSES = ["m-motion-fade-in", "m-motion-push-in", "m-motion-pop-in", "m-motion-boot-in"];
function applyViewMotion(motion) {
const view = document.getElementById("m-view");
VIEW_MOTION_CLASSES.forEach(function (cls) {
view.classList.remove(cls);
});
void view.offsetWidth;
view.classList.add(motion || "m-motion-fade-in");
}
function currentHash() {
let hash = window.location.hash || "";
if (!hash || hash === "#" || hash === "#/") return DEFAULT_HASH;
if (hash.charAt(0) !== "#") hash = "#" + hash;
return hash;
}
function routeOf(hash) {
const path = hash.replace(/^#\/?/, "");
const parts = path.split("/").filter(Boolean);
return { name: parts[0] || "home", params: parts.slice(1) };
}
function currentRoute() {
return routeOf(stack.length ? stack[stack.length - 1] : currentHash());
}
function entryKeyOfRoute(route) {
if (route.name === "hub") return route.params[0];
if (route.name === "feature") return route.params[0];
if (route.name === "assistant") return "assistant";
return null;
}
function entryRoute(key) {
return key === "assistant" ? "#/assistant/chat" : "#/hub/" + key;
}
function setHash(hash) {
internalNav++;
window.location.hash = hash;
}
function navigate(hash) {
stack.push(hash);
setHash(hash);
render("m-motion-push-in");
}
function replace(hash, motion) {
if (stack.length) stack[stack.length - 1] = hash;
else stack.push(hash);
setHash(hash);
render(motion);
}
function resetStack(hash) {
stack.length = 0;
stack.push(hash);
setHash(hash);
render();
}
function parentRoute(route) {
if (route.name === "feature") return "#/hub/" + route.params[0];
return null;
}
function back() {
if (stack.length > 1) {
stack.pop();
setHash(stack[stack.length - 1]);
render("m-motion-pop-in");
return;
}
const parent = parentRoute(currentRoute());
if (parent) replace(parent, "m-motion-pop-in");
}
function switchEntry(key) {
const route = currentRoute();
const current = entryKeyOfRoute(route);
if (current === key) {
if (route.name === "feature") {
resetStack(entryRoute(key));
}
return;
}
resetStack(entryRoute(key));
}
function updateHeader(options) {
document.getElementById("m-title").textContent = options.title || "小白复盘";
document.getElementById("m-back").hidden = !options.back;
document.getElementById("m-actions").innerHTML = options.actions || "";
}
function placeholderHtml(title, subtitle) {
return '<div class="m-placeholder m-motion-rise-in">' +
'<span class="m-placeholder-icon">' + icon("inbox", 26) + "</span>" +
"<h2>" + escapeHtml(title) + "</h2>" +
"<p>" + escapeHtml(subtitle || "该页面将在后续批次实现,返回即可继续浏览。") + "</p>" +
"</div>";
}
function findFeatureLabel(key) {
const hubs = global.MobileNav.hubs;
for (const hubKey in hubs) {
const items = hubs[hubKey].items || [];
for (const item of items) {
if (item.key === key) return item.label;
}
}
return null;
}
function themeToggleSection() {
const dark = document.getElementById("m-app").dataset.theme === "dark";
return '<div class="m-theme-section">' +
'<div class="m-theme-row">' +
'<span class="m-theme-row-icon">' + icon(dark ? "moon" : "sun") + "</span>" +
'<span class="m-theme-row-body"><strong>外观主题</strong><small>' + (dark ? "当前:夜间模式" : "当前:日间模式") + "</small></span>" +
'<button class="m-theme-switch" type="button" data-theme-toggle role="switch" aria-checked="' + (dark ? "true" : "false") + '" aria-label="切换日间/夜间模式"><span class="m-theme-switch-thumb"></span></button>' +
"</div></div>";
}
function syncThemeToggleUI() {
const dark = document.getElementById("m-app").dataset.theme === "dark";
const toggle = document.querySelector("[data-theme-toggle]");
if (toggle) toggle.setAttribute("aria-checked", dark ? "true" : "false");
const rowIcon = document.querySelector(".m-theme-row-icon");
if (rowIcon) rowIcon.innerHTML = icon(dark ? "moon" : "sun");
const rowBodySmall = document.querySelector(".m-theme-row-body small");
if (rowBodySmall) rowBodySmall.textContent = dark ? "当前:夜间模式" : "当前:日间模式";
}
function visibleHubItems(hub) {
const items = hub && hub.items ? hub.items : [];
return items.filter(function (item) {
return !item.adminOnly || global.MobileSession.isAdmin();
});
}
function renderHub(key) {
const hub = global.MobileNav.hubs[key];
if (!hub) {
replace(DEFAULT_HASH);
return;
}
const items = visibleHubItems(hub);
updateHeader({ title: hub.title, back: false });
const section = key === "system" ? themeToggleSection() : "";
document.getElementById("m-view").innerHTML =
section +
'<ul class="m-hub-grid">' +
items.map(function (item) {
return '<li>' +
'<button class="m-grid-item" type="button" data-route="#/feature/' + item.key + '">' +
'<span class="m-grid-icon">' + icon(item.icon) + "</span>" +
'<span class="m-grid-label">' + escapeHtml(item.label) + "</span>" +
"</button></li>";
}).join("") +
"</ul>";
}
function renderFeature(key) {
if (global.MobilePages && global.MobilePages.has(key)) {
global.MobilePages.render(key);
return;
}
const title = findFeatureLabel(key) || key;
updateHeader({ title: title, back: true });
document.getElementById("m-view").innerHTML = placeholderHtml(title, "该功能页将在后续批次实现。");
}
function renderAssistant() {
if (global.MobilePages && global.MobilePages.has("assistant/chat")) {
global.MobilePages.render("assistant/chat");
return;
}
updateHeader({ title: "复盘助手", back: false });
document.getElementById("m-view").innerHTML = placeholderHtml("复盘助手", "聊天工作台将在后续批次实现。");
}
function renderAuth() {
updateHeader({ title: "小白复盘", back: false, actions: "" });
document.getElementById("m-view").innerHTML = [
'<div class="m-auth">',
'<div class="m-auth-brand">',
'<span class="m-brand-mark">复</span>',
'<h2>小白复盘</h2>',
'<p>登录后进入你的复盘空间</p>',
"</div>",
'<div class="m-auth-tabs">',
'<button class="m-auth-tab active" type="button" data-auth-mode="login">登录</button>',
'<button class="m-auth-tab" type="button" data-auth-mode="register">注册</button>',
"</div>",
'<form id="m-auth-form">',
'<label class="m-form-field"><span>账号名</span><input id="m-auth-username" type="text" minlength="3" maxlength="30" autocomplete="username" required></label>',
'<label class="m-form-field"><span>密码</span><input id="m-auth-password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required></label>',
'<label class="m-form-field" id="m-auth-confirm-field" hidden><span>确认密码</span><input id="m-auth-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"></label>',
'<p class="m-auth-error" id="m-auth-error" hidden></p>',
'<button class="m-btn-primary" id="m-auth-submit" type="submit"><span class="m-btn-spinner" aria-hidden="true" hidden></span><span class="m-btn-label">登录</span></button>',
"</form>",
"</div>"
].join("");
authMode = "login";
bindAuth();
}
function setAuthMode(mode) {
authMode = mode === "register" ? "register" : "login";
document.querySelectorAll("[data-auth-mode]").forEach(function (button) {
button.classList.toggle("active", button.dataset.authMode === authMode);
});
document.getElementById("m-auth-confirm-field").hidden = authMode !== "register";
document.getElementById("m-auth-confirm").required = authMode === "register";
document.getElementById("m-auth-password").autocomplete = authMode === "register" ? "new-password" : "current-password";
const submitLabel = document.querySelector("#m-auth-submit .m-btn-label");
if (submitLabel) submitLabel.textContent = authMode === "register" ? "注册并进入" : "登录";
document.getElementById("m-auth-error").hidden = true;
}
function bindAuth() {
document.querySelectorAll("[data-auth-mode]").forEach(function (button) {
button.addEventListener("click", function () { setAuthMode(button.dataset.authMode); });
});
document.getElementById("m-auth-form").addEventListener("submit", submitAuth);
}
function showAuthError(element, message) {
element.textContent = message;
element.classList.remove("m-motion-fade-in");
void element.offsetWidth;
element.classList.add("m-motion-fade-in");
element.hidden = false;
}
async function submitAuth(event) {
event.preventDefault();
const username = document.getElementById("m-auth-username").value.trim();
const password = document.getElementById("m-auth-password").value;
const errorElement = document.getElementById("m-auth-error");
if (authMode === "register" && password !== document.getElementById("m-auth-confirm").value) {
showAuthError(errorElement, "两次输入的密码不一致。");
return;
}
const submit = document.getElementById("m-auth-submit");
const spinner = submit.querySelector(".m-btn-spinner");
const label = submit.querySelector(".m-btn-label");
submit.disabled = true;
spinner.hidden = false;
label.textContent = authMode === "register" ? "注册中…" : "登录中…";
try {
if (authMode === "register") {
await global.MobileSession.register(username, password);
} else {
await global.MobileSession.login(username, password);
}
replace(DEFAULT_HASH);
} catch (error) {
showAuthError(errorElement, error.message || "账号操作失败");
} finally {
submit.disabled = false;
spinner.hidden = true;
label.textContent = authMode === "register" ? "注册并进入" : "登录";
}
}
function buildTabbar() {
document.getElementById("m-tabbar").innerHTML = global.MobileNav.entries.map(function (entry) {
return '<button class="m-tabbar-item" type="button" data-tab="' + entry.key + '">' +
'<span class="m-tabbar-icon">' + icon(entry.icon, 24) + "</span>" +
'<span class="m-tabbar-label">' + escapeHtml(entry.title) + "</span>" +
"</button>";
}).join("");
}
function updateTabbar(activeKey) {
document.querySelectorAll("#m-tabbar .m-tabbar-item").forEach(function (button) {
const active = button.dataset.tab === activeKey;
button.classList.toggle("active", active);
if (active) button.setAttribute("aria-current", "page");
else button.removeAttribute("aria-current");
});
}
function render(motion) {
const route = currentRoute();
document.getElementById("m-view").classList.remove("m-view-feature");
if (route.name === "home") {
replace(DEFAULT_HASH);
return;
}
const tabbar = document.getElementById("m-tabbar");
if (route.name === "auth") {
tabbar.hidden = true;
document.getElementById("m-app").dataset.tabbar = "false";
} else {
tabbar.hidden = false;
document.getElementById("m-app").dataset.tabbar = "true";
updateTabbar(entryKeyOfRoute(route));
}
if (route.name === "hub") renderHub(route.params[0]);
else if (route.name === "feature") renderFeature(route.params.join("/"));
else if (route.name === "assistant") renderAssistant();
else if (route.name === "auth") renderAuth();
applyViewMotion(motion);
}
function bindGlobalEvents() {
document.getElementById("m-view").addEventListener("click", function (event) {
const entry = event.target.closest("[data-route]");
if (entry) {
navigate(entry.dataset.route);
}
});
document.getElementById("m-tabbar").addEventListener("click", function (event) {
const item = event.target.closest("[data-tab]");
if (item) switchEntry(item.dataset.tab);
});
document.getElementById("m-back").addEventListener("click", back);
document.addEventListener("click", function (event) {
const toggle = event.target.closest("[data-theme-toggle]");
if (!toggle) return;
global.MobileTheme.toggle();
syncThemeToggleUI();
});
window.addEventListener("hashchange", function () {
if (internalNav > 0) {
internalNav--;
return;
}
const hash = currentHash();
if (stack.length > 1 && hash === stack[stack.length - 2]) {
stack.pop();
render("m-motion-pop-in");
} else if (hash !== stack[stack.length - 1]) {
stack.push(hash);
render("m-motion-push-in");
}
});
}
function init() {
bindGlobalEvents();
buildTabbar();
stack.length = 0;
const raw = window.location.hash || "";
if (!raw || raw === "#" || raw === "#/") {
stack.push(DEFAULT_HASH);
setHash(DEFAULT_HASH);
} else {
stack.push(currentHash());
}
render("m-motion-boot-in");
}
global.MobileRouter = {
init: init,
navigate: navigate,
replace: replace,
back: back,
render: render,
currentRoute: currentRoute,
updateHeader: updateHeader
};
})(window);
-51
View File
@@ -1,51 +0,0 @@
(function (global) {
"use strict";
const state = { user: null, csrfToken: "", authenticated: false };
function applySession(payload) {
state.user = payload.user || null;
state.csrfToken = payload.csrf_token || "";
state.authenticated = Boolean(payload.authenticated);
global.MobileAPI.setCsrfToken(state.csrfToken);
return state;
}
async function me() {
const payload = await global.MobileAPI.request("/api/auth/me");
return applySession(payload);
}
async function login(username, password) {
const payload = await global.MobileAPI.request("/api/auth/login", "POST", {
username: username,
password: password
});
return applySession(payload);
}
async function register(username, password) {
const payload = await global.MobileAPI.request("/api/auth/register", "POST", {
username: username,
password: password
});
return applySession(payload);
}
async function logout() {
try {
await global.MobileAPI.request("/api/auth/logout", "POST", {});
} finally {
state.user = null;
state.csrfToken = "";
state.authenticated = false;
global.MobileAPI.setCsrfToken("");
}
}
function isAdmin() {
return Boolean(state.user && state.user.role === "admin");
}
global.MobileSession = { state: state, me: me, login: login, register: register, logout: logout, isAdmin: isAdmin };
})(window);
+14 -14
View File
@@ -42,15 +42,15 @@
];
const fragments = [
["pools", "/pages/pools/page.html?v=20260820-1", ["limitPool", "brokenView", "downView", "yesterdayView", "performanceView"]],
["pools", "/pages/pools/page.html?v=20260803-1", ["limitPool", "brokenView", "downView", "yesterdayView", "performanceView"]],
["sentiment", "/pages/sentiment/page.html?v=20260803-1", ["sentimentCycleView"]],
["heaven", "/pages/heaven/page.html?v=20260803-1", ["heavenView"]],
["ladder", "/pages/ladder/page.html?v=20260820-1", ["ladderView"]],
["ladder", "/pages/ladder/page.html?v=20260803-1", ["ladderView"]],
["screener", "/pages/screener/page.html?v=20260804-1", ["screenerView", "screenerTrackingView"]],
["mentor", "/pages/mentor/page.html?v=20260820-1", ["mentorView"]],
["rotation", "/pages/rotation/page.html?v=20260820-1", ["rotationView"]],
["mentor", "/pages/mentor/page.html?v=20260806-2", ["mentorView"]],
["rotation", "/pages/rotation/page.html?v=20260803-1", ["rotationView"]],
["auction", "/pages/auction/page.html?v=20260803-1", ["auctionView"]],
["themes", "/pages/themes/page.html?v=20260820-1", ["themeLibraryView"]],
["themes", "/pages/themes/page.html?v=20260803-1", ["themeLibraryView"]],
["popularity", "/pages/popularity/page.html?v=20260803-1", ["popularityView"]],
["dragon_tiger", "/pages/dragon-tiger/page.html?v=20260803-1", ["dragonView"]],
["review", "/pages/review/page.html?v=20260803-1", ["reviewWorkspaceView"]],
@@ -66,7 +66,7 @@
"/shared/components.js?v=20260729-1",
"/pages/runtime.js?v=20260729-1",
"/pages/sentiment/page.js?v=20260729-1",
"/pages/pools/page.js?v=20260820-1",
"/pages/pools/page.js?v=20260729-1",
"/pages/market/breadth.js?v=20260803-1",
"/pages/market/charts.js?v=20260803-1",
"/pages/market/entity-detail.js?v=20260803-1",
@@ -74,19 +74,19 @@
"/pages/market/preview.js?v=20260806-1",
"/pages/market/search.js?v=20260803-1",
"/pages/market/bindings.js?v=20260803-1",
"/pages/ladder/page.js?v=20260820-1",
"/pages/rotation/page.js?v=20260820-1",
"/pages/ladder/page.js?v=20260729-1",
"/pages/rotation/page.js?v=20260729-1",
"/pages/auction/page.js?v=20260729-1",
"/pages/themes/page.js?v=20260820-1",
"/pages/popularity/page.js?v=20260820-1",
"/pages/dragon-tiger/page.js?v=20260820-1",
"/pages/themes/page.js?v=20260729-1",
"/pages/popularity/page.js?v=20260729-1",
"/pages/dragon-tiger/page.js?v=20260806-1",
"/pages/screener/page.js?v=20260806-1",
"/pages/mentor/page.js?v=20260820-1",
"/pages/mentor/page.js?v=20260806-2",
"/pages/heaven/page.js?v=20260729-1",
"/pages/review/page.js?v=20260729-1",
"/shared/state.js?v=20260729-1",
"/shared/api.js?v=20260729-1",
"/shared/shell.js?v=20260820-2",
"/shared/shell.js?v=20260806-1",
"/shared/export.js?v=20260731-1",
"/pages/heaven/loading-v2.js?v=20260728-2",
"/shared/context.js?v=20260804-1",
@@ -94,7 +94,7 @@
"/shared/application.js?v=20260803-1",
"/shared/table.js?v=20260803-1",
"/shared/theme.js?v=20260803-1",
"/shared/dashboard.js?v=20260820-1",
"/shared/dashboard.js?v=20260803-1",
"/shared/session.js?v=20260803-1",
"/shared/admin.js?v=20260803-1",
"/app.js?v=20260803-2",
+40 -47
View File
@@ -1,8 +1,8 @@
/* Canonical CSS owner: auction. Historical layers consolidated 2026-08-02. */
#auctionView .section-toolbar h2 {
font-size: var(--font-size-page-title);
font-size: 17px;
font-weight: var(--font-weight-semibold);
font-weight: 800;
}
@media (max-width: 767px) {
@@ -354,9 +354,9 @@
}
.auction-phase-notice[data-phase="archive"] {
background: var(--surface-muted);
background: rgb(238, 242, 246);
color: var(--text-2);
color: rgb(89, 101, 116);
}
.auction-phase-marker {
@@ -374,7 +374,7 @@
.auction-table {
min-width: 860px;
font-size: var(--font-size-table);
font-size: 12.5px;
}
.auction-table tbody tr:hover td {
@@ -462,7 +462,7 @@
transition: opacity var(--motion-fast) ease;
background: var(--accent-soft);
background: rgb(201, 214, 238);
}
.auction-amount-average {
@@ -522,7 +522,7 @@
}
.auction-table td {
height: 40px;
height: 44px;
padding-right: 12px;
@@ -530,7 +530,7 @@
border-right: 0px;
border-bottom-color: var(--border);
border-bottom-color: rgb(232, 237, 241);
}
.auction-table th {
@@ -540,13 +540,13 @@
border-right: 0px;
height: 36px;
height: 38px;
color: var(--text-2);
color: rgb(107, 114, 128);
font-size: var(--font-size-caption);
font-size: 12px;
border-bottom-color: var(--border);
border-bottom-color: rgb(232, 237, 241);
background: var(--table-header);
}
@@ -694,9 +694,9 @@
color: var(--r2-ink);
font-size: var(--font-size-page-title);
font-size: 17px;
font-weight: var(--font-weight-semibold);
font-weight: 800;
letter-spacing: 0px;
}
@@ -812,13 +812,13 @@
100% {
opacity: 0.42;
box-shadow: 0 0 0 0 var(--focus-ring);
box-shadow: rgba(37, 99, 235, 0.18) 0px 0px 0px 0px;
}
50% {
opacity: 1;
box-shadow: 0 0 0 4px transparent;
box-shadow: rgba(37, 99, 235, 0) 0px 0px 0px 4px;
}
}
@@ -896,9 +896,7 @@
.auction-export-button,
.auction-refresh-button {
min-height: 32px;
height: 32px;
min-height: 30px;
display: inline-flex;
@@ -908,17 +906,17 @@
gap: 5px;
padding: 0 12px;
padding: 5px 11px;
border: 1px solid var(--border-strong);
border: 1px solid var(--r2-line);
border-radius: 8px;
border-radius: 7px;
background: var(--surface);
color: var(--text-primary);
font-size: var(--font-size-label);
font-size: 12px;
font-weight: 500;
@@ -1146,7 +1144,7 @@
.auction-search-v2 {
min-width: 0px;
height: 32px;
height: 31px;
display: flex;
@@ -1156,9 +1154,9 @@
padding: 0px 9px;
border: 1px solid var(--border-strong);
border: 1px solid var(--r2-line);
border-radius: 8px;
border-radius: 7px;
background: var(--surface);
}
@@ -1195,7 +1193,7 @@
.auction-filter-segments button:focus-visible,
.auction-refresh-button:focus-visible,
.auction-tabs-v2 button:focus-visible {
outline: 2px solid var(--accent);
outline: rgba(37, 99, 235, 0.28) solid 2px;
outline-offset: 2px;
}
@@ -1221,7 +1219,7 @@
border-collapse: collapse;
font-size: var(--font-size-table);
font-size: 12.5px;
}
.auction-table-v2 thead th {
@@ -1231,16 +1229,12 @@
z-index: 2;
height: 36px;
padding: 0 12px;
height: 35px;
background: var(--table-header);
color: var(--r2-sub);
font-size: var(--font-size-caption);
font-weight: 600;
text-align: left;
@@ -1248,11 +1242,6 @@
white-space: nowrap;
}
.auction-table-v2 thead th:first-child,
.auction-table-v2 tbody td:first-child {
text-align: left;
}
.auction-table-v2 thead th.number {
text-align: right;
}
@@ -1280,9 +1269,7 @@
}
.auction-table-v2 tbody td {
height: 40px;
padding: 0 12px;
height: 47px;
border-bottom: 1px solid var(--r2-line-soft);
@@ -1832,7 +1819,7 @@
border-radius: 3px 3px 0px 0px;
background: var(--accent-soft);
background: rgb(201, 214, 238);
transition: opacity 180ms, transform 180ms;
@@ -1928,7 +1915,7 @@
border-radius: 2px;
background: var(--accent-soft);
background: rgb(201, 214, 238);
}
.auction-volume-legend i.current {
@@ -2102,6 +2089,10 @@
overflow: hidden;
}
body[data-active-view="auctionView"] .overview-strip {
flex: 0 0 auto;
}
body[data-active-view="auctionView"] #auctionView.active-view {
min-height: 0px;
@@ -2147,9 +2138,11 @@
}
.auction-table-v2 tbody td {
height: 40px;
height: 43px;
padding: 0 12px;
padding-top: 6px;
padding-bottom: 6px;
}
}
@@ -2172,9 +2165,9 @@
}
.auc-head h2 {
font-size: var(--font-size-page-title);
font-size: 17px;
font-weight: var(--font-weight-semibold);
font-weight: 800;
}
.auc-grid {
+19 -33
View File
@@ -82,7 +82,7 @@
}
.dragon-operation-table th {
background: var(--table-header);
background: rgb(232, 240, 244);
}
.seat-cell {
@@ -952,10 +952,6 @@
margin: 0px;
color: var(--r2-ink);
font-size: var(--font-size-page-title);
font-weight: var(--font-weight-semibold);
}
.dragon-title-v2 > span {
@@ -995,7 +991,7 @@
align-items: center;
min-height: 32px;
min-height: 34px;
padding: 3px;
@@ -1007,19 +1003,19 @@
}
.dragon-view-tabs-v2 button {
min-height: 26px;
min-height: 27px;
padding: 0px 13px;
border: 0px;
border-radius: 6px;
border-radius: 5px;
background: transparent;
color: var(--r2-sub);
font-size: var(--font-size-label);
font-size: 11px;
cursor: pointer;
}
@@ -1041,15 +1037,11 @@
}
.dragon-action-v2 {
min-height: 32px;
min-height: 34px;
height: 32px;
padding: 0px 11px;
padding: 0px 12px;
border-radius: 8px;
font-size: var(--font-size-label);
border-radius: 7px;
}
.dragon-action-v2 .lucide {
@@ -1109,9 +1101,9 @@
}
.dragon-summary-v2 .dragon-metric span {
color: var(--text-3);
color: var(--r2-sub);
font-size: var(--font-size-caption);
font-size: 11px;
}
.dragon-summary-v2 .dragon-metric strong {
@@ -1119,9 +1111,9 @@
color: var(--r2-ink);
font-size: var(--font-size-metric);
font-size: 18px;
font-weight: var(--font-weight-semibold);
font-weight: 750;
font-variant-numeric: tabular-nums;
}
@@ -1253,7 +1245,7 @@
width: 230px;
height: 32px;
height: 33px;
flex: 0 0 auto;
@@ -1261,9 +1253,9 @@
padding: 0px 10px;
border: 1px solid var(--border-strong);
border: 1px solid rgb(216, 221, 229);
border-radius: 8px;
border-radius: 7px;
color: var(--r2-faint);
@@ -1271,9 +1263,9 @@
}
.dragon-search-v2:focus-within {
border-color: var(--accent);
border-color: rgb(150, 181, 242);
box-shadow: 0 0 0 2px var(--focus-ring);
box-shadow: rgba(37, 99, 235, 0.09) 0px 0px 0px 3px;
}
.dragon-search-v2 .lucide {
@@ -1429,7 +1421,7 @@
#dragonView .dragon-operation-table :is(th, td).row-number {
padding-inline: 8px;
text-align: left;
text-align: center;
}
#dragonView .dragon-operation-table td.stock-code {
@@ -1443,12 +1435,6 @@
z-index: 2;
height: 36px;
padding: 0 12px;
font-size: var(--font-size-caption);
background: var(--surface-subtle);
}
@@ -2403,7 +2389,7 @@ table.tbl {
border-collapse: collapse;
font-size: var(--font-size-table);
font-size: 12.5px;
}
.tbl .num {
+2 -2
View File
@@ -299,10 +299,10 @@ function renderDragonTraderDetail(trader) {
<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">序号</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>
<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">${index + 1}</td>
<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>
+18 -26
View File
@@ -14,7 +14,7 @@
padding: 9px 4px;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid rgb(232, 236, 239);
cursor: pointer;
}
@@ -128,7 +128,7 @@
gap: 5px;
background: var(--surface-muted);
background: rgb(247, 249, 250);
cursor: pointer;
@@ -140,11 +140,11 @@
border: 1px dashed var(--border-strong);
border-radius: 8px;
border-radius: 6px;
color: var(--text-secondary);
font-size: var(--font-size-caption);
font-size: 11px;
white-space: nowrap;
}
@@ -152,7 +152,7 @@
.ladder-gap {
min-height: 68px;
background: repeating-linear-gradient(135deg, var(--surface), var(--surface) 9px, var(--surface-subtle) 9px, var(--surface-subtle) 18px);
background: repeating-linear-gradient(135deg, rgb(255, 255, 255), rgb(255, 255, 255) 9px, rgb(250, 251, 252) 9px, rgb(250, 251, 252) 18px);
}
.ladder-gap-note {
@@ -353,12 +353,6 @@
box-shadow: none;
}
.ladder-page-head .section-title-group h2 {
font-size: var(--font-size-page-title);
font-weight: var(--font-weight-semibold);
}
.ladder-page-head {
margin-bottom: 12px;
}
@@ -390,15 +384,13 @@
}
.ladder-sort-segment button {
min-height: 28px;
padding: 4px 12px;
border-radius: 6px;
color: var(--r2-sub);
font-size: var(--font-size-label);
font-size: 12px;
}
.ladder-sort-segment button.active {
@@ -456,11 +448,11 @@
border-right: 1px solid var(--r2-line-soft);
background: linear-gradient(90deg, color-mix(in srgb, var(--tier-color) 8%, var(--surface)), var(--surface));
background: linear-gradient(90deg, color-mix(in srgb, var(--tier-color) 8%, #fff), #fff);
}
.market-ladder-tier.is-gap .market-ladder-label {
background: repeating-linear-gradient(45deg, var(--surface-subtle), var(--surface-subtle) 8px, var(--surface-muted) 8px, var(--surface-muted) 16px);
background: repeating-linear-gradient(45deg, rgb(250, 250, 250), rgb(250, 250, 250) 8px, rgb(243, 244, 246) 8px, rgb(243, 244, 246) 16px);
}
.market-ladder-level {
@@ -579,9 +571,9 @@
white-space: nowrap;
font-size: var(--font-size-table);
font-size: 13px;
font-weight: var(--font-weight-semibold);
font-weight: 800;
}
.market-ladder-stock-first .stock-code {
@@ -621,7 +613,7 @@
.market-ladder-tag.one-price {
background: var(--r2-up-soft);
color: var(--r2-up);
color: rgb(194, 46, 46);
font-weight: 700;
}
@@ -770,9 +762,9 @@
.market-ladder-insight-card > header h3 {
margin: 0px;
font-size: var(--font-size-card-title);
font-size: 13.5px;
font-weight: var(--font-weight-semibold);
font-weight: 700;
}
.market-ladder-insight-card > header span {
@@ -804,9 +796,9 @@
.market-ladder-apex strong {
color: var(--r2-up);
font-size: var(--font-size-metric);
font-size: 26px;
font-weight: var(--font-weight-semibold);
font-weight: 800;
}
.market-ladder-apex em {
@@ -908,7 +900,7 @@
}
.market-ladder-pyramid-row.is-gap > i {
background: repeating-linear-gradient(45deg, var(--border), var(--border) 4px, var(--surface-muted) 4px, var(--surface-muted) 8px);
background: repeating-linear-gradient(45deg, rgb(229, 231, 235), rgb(229, 231, 235) 4px, rgb(243, 244, 246) 4px, rgb(243, 244, 246) 8px);
}
.market-ladder-pyramid-row.is-gap > i b {
@@ -976,11 +968,11 @@
}
.market-ladder-rate-list > div > i b.is-low {
background: var(--r2-amber);
background: rgb(245, 158, 11);
}
.market-ladder-rate-list > div > i b.is-zero {
background: var(--border-strong);
background: rgb(209, 213, 219);
}
.market-ladder-rate-list > div > strong {
+3 -2
View File
@@ -33,6 +33,7 @@ function renderLadderBoard(ladders) {
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}`);
@@ -50,7 +51,7 @@ function renderLadderBoard(ladders) {
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: "#3370ff", 2: "#16a34a", 3: "#b45309", 4: "#e04536" }[level] || "#8f959e";
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>
@@ -76,7 +77,7 @@ function renderLadderBoard(ladders) {
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></div><p>${spaceNote}</p></section>
<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) => {
+5 -6
View File
@@ -790,8 +790,7 @@
border-radius: 4px;
}
:is(.global-search-dialog, .stock-dialog, .settings-dialog:not(.heaven-reading-dialog)),
:root[data-theme="dark"] :is(.global-search-dialog, .stock-dialog, .settings-dialog:not(.heaven-reading-dialog)) {
:is(.global-search-dialog, .stock-dialog, .settings-dialog:not(.heaven-reading-dialog)) {
--dialog-line: var(--border);
--dialog-line-strong: var(--border-strong);
@@ -804,17 +803,17 @@
border: 1px solid var(--dialog-line-strong);
border-radius: var(--size-radius-dialog);
border-radius: 10px;
background: var(--surface);
color: var(--dialog-ink);
box-shadow: var(--shadow-float);
box-shadow: rgba(25, 36, 48, 0.2) 0px 26px 72px, rgba(25, 36, 48, 0.08) 0px 4px 14px;
}
:is(.global-search-dialog, .stock-dialog, .settings-dialog:not(.heaven-reading-dialog))::backdrop {
background: var(--backdrop);
background: rgba(28, 39, 50, 0.46);
backdrop-filter: blur(3px);
}
@@ -1134,7 +1133,7 @@
margin: 8px auto;
border-radius: var(--size-radius-dialog);
border-radius: 8px;
}
:is(.stock-dialog, .settings-dialog:not(.heaven-reading-dialog)) .dialog-header {
+369 -807
View File
File diff suppressed because it is too large Load Diff
+32 -67
View File
@@ -1,52 +1,39 @@
<section id="mentorView" class="workspace-view page member-feature-view redesigned-mentor-view">
<div class="member-gate" hidden><div class="member-gate-icon"><i data-lucide="lock-keyhole"></i></div><div><strong>问师仅对会员开放</strong><span>开通会员后可使用思维模型进行对话。会员状态可从顶部账号标识进入。</span></div></div>
<div id="mentorNotice" class="inline-notice" hidden></div>
<header class="mentor-page-header" aria-label="问师">
<div class="mentor-page-title">
<header class="section-toolbar lad-head mentor-page-header">
<div class="section-title-group mentor-page-title">
<h2>问师</h2>
<p id="mentorPageSubtitle">与不同交易思维模型持续对话 · 数据日期 --</p>
<span class="section-subtitle">与不同交易思维模型持续对话 · <span id="mentorDataDate">--</span></span>
</div>
</header>
<div id="mentorNotice" class="inline-notice" hidden></div>
<div class="mentor-layout">
<aside class="mentor-sidebar" aria-label="思维模型目录">
<div class="mentor-directory-heading">
<div class="mentor-directory-title"><strong>联系人</strong><span id="mentorCount">0 位</span></div>
<button id="mentorSortToggle" class="mentor-sort-toggle" type="button" aria-pressed="false" title="整理联系人顺序"><i data-lucide="list-ordered"></i><span>整理</span></button>
</div>
<div class="mentor-directory-tools">
<label class="mentor-search-field">
<span class="visually-hidden">搜索思维模型</span>
<i data-lucide="search"></i>
<input id="mentorSearchInput" type="search" maxlength="50" placeholder="搜索联系人或标签" autocomplete="off">
</label>
<div class="mentor-filter-menu">
<button id="mentorFilterToggle" class="mentor-filter-toggle" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="筛选思维模型" title="筛选思维模型"><i data-lucide="filter"></i></button>
<div id="mentorFilterOptions" class="mentor-filter-options" role="menu" hidden>
<button type="button" class="active" data-mentor-grade="all" role="menuitem">全部<span id="mentorCount">0 位</span></button>
<button type="button" data-mentor-grade="A" role="menuitem">A</button>
<button type="button" data-mentor-grade="B" role="menuitem">B级</button>
<button type="button" data-mentor-grade="C" role="menuitem">C级</button>
</div>
<div class="mentor-evidence-filters" role="group" aria-label="按素材等级筛选">
<button class="active" type="button" data-mentor-grade="all">全部</button>
<button type="button" data-mentor-grade="A">A级</button>
<button type="button" data-mentor-grade="B">B级</button>
<button type="button" data-mentor-grade="C">C</button>
</div>
<button id="mentorSortToggle" class="mentor-sort-toggle" type="button" aria-pressed="false" title="整理联系人顺序"><i data-lucide="list-ordered"></i><span>整理</span></button>
<p id="mentorSortHint" class="mentor-sort-hint" hidden>拖动联系人,或使用箭头调整顺序</p>
</div>
<div id="mentorList" class="mentor-list"></div>
<div id="mentorListEmpty" class="mentor-list-empty" hidden>没有符合条件的思维模型</div>
<p id="mentorSortHint" class="mentor-sort-hint" hidden>拖动联系人,或使用箭头调整顺序</p>
<p class="mentor-evidence-legend">等级仅表示公开素材完整度</p>
</aside>
<section class="mentor-chat-panel" aria-label="问师对话">
<header class="mentor-chat-header">
<div class="mentor-chat-identity">
<span id="activeMentorAvatar" class="mentor-avatar" aria-hidden="true"></span>
<div>
<div class="mentor-active-title"><h3 id="activeMentorName">--</h3></div>
<p id="activeMentorStatus">思维模型已就绪</p>
</div>
</div>
<div class="mentor-chat-actions">
<button id="mentorPinButton" class="icon-button mentor-header-action" type="button" aria-label="置顶当前思维模型" title="置顶"><i data-lucide="pin"></i></button>
<button id="mentorNoteButton" class="icon-button mentor-header-action" type="button" aria-label="备注标签" title="备注标签"><i data-lucide="tag"></i></button>
<button id="mentorProfileButton" class="icon-button mentor-header-action" type="button" aria-label="游资档案" title="游资档案"><i data-lucide="id-card"></i></button>
<button id="clearMentorChatButton" class="icon-button mentor-clear-button" type="button" disabled aria-label="清空当前对话" title="清空对话"><i data-lucide="trash-2"></i></button>
</div>
</header>
<p id="activeMentorStatus" class="visually-hidden" aria-live="polite">思维模型已就绪</p>
<div id="mentorMessages" class="mentor-messages" aria-live="polite"></div>
<div id="mentorQuickPrompts" class="mentor-quick-prompts">
<span class="mentor-prompt-label">开始一个话题</span>
@@ -56,61 +43,39 @@
<button type="button" data-mentor-prompt="现在最需要防范的风险是什么?"><i data-lucide="shield-alert"></i>风险检查</button>
</div>
<form id="mentorChatForm" class="mentor-chat-form">
<div class="mentor-composer-main">
<label class="visually-hidden" for="mentorQuestion">向当前思维模型提问</label>
<textarea id="mentorQuestion" maxlength="2000" placeholder="输入市场、板块、个股代码或交易问题"></textarea>
<span class="mentor-composer-hint">Enter 发送 · Shift + Enter 换行</span>
</div>
<label class="visually-hidden" for="mentorQuestion">向当前思维模型提问</label>
<textarea id="mentorQuestion" maxlength="2000" placeholder="输入市场、板块、个股代码或交易问题"></textarea>
<div class="mentor-composer-actions">
<span>Enter 发送 · Shift + Enter 换行</span>
<button id="clearMentorChatButton" class="icon-button mentor-clear-button" type="button" disabled aria-label="清空当前对话" title="清空对话"><i data-lucide="trash-2"></i></button>
<button id="stopMentorQuestion" class="button ghost mentor-stop-button" type="button" hidden><i data-lucide="square"></i><span>停止</span></button>
<button id="sendMentorQuestion" class="button primary mentor-send-button" type="submit"><i data-lucide="send-horizontal"></i><span>发送</span></button>
</div>
</form>
<p class="mentor-disclaimer">基于公开资料提炼的思维模型模拟,不代表本人观点,不构成投资建议。</p>
</section>
</div>
<footer class="mentor-workspace-footer">
<span class="mentor-evidence-legend">等级仅表示公开素材完整度</span>
<p class="mentor-disclaimer">基于公开资料提炼的思维模型模拟,不代表本人观点,不构成投资建议。</p>
</footer>
<dialog id="mentorNoteDialog" class="mentor-floating-dialog mentor-note-dialog" aria-labelledby="mentorNoteDialogTitle">
<div class="mentor-dialog-head">
<div><span class="dialog-eyebrow">问师</span><h3 id="mentorNoteDialogTitle">备注标签</h3></div>
<button type="button" class="icon-button mentor-dialog-close" data-mentor-dialog-close="mentorNoteDialog" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
</div>
<div class="mentor-dialog-body">
<label class="visually-hidden" for="mentorNoteInput">备注标签</label>
<textarea id="mentorNoteInput" maxlength="2000" placeholder="为该思维模型记录备注或标签"></textarea>
</div>
</dialog>
<dialog id="mentorProfileDialog" class="mentor-floating-dialog mentor-profile-dialog" aria-labelledby="mentorProfileDialogTitle">
<div class="mentor-dialog-head">
<div><span class="dialog-eyebrow">问师</span><h3 id="mentorProfileDialogTitle">游资档案</h3></div>
<button type="button" class="icon-button mentor-dialog-close" data-mentor-dialog-close="mentorProfileDialog" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
</div>
<div class="mentor-profile-dialog-body">
<aside class="mentor-profile-panel" aria-label="当前思维模型资料">
<div class="mentor-profile-hero">
<span id="mentorProfileDialogAvatar" class="mentor-profile-avatar" aria-hidden="true"></span>
<h3 id="mentorProfileDialogName">--</h3>
<p id="mentorProfileDialogTagline">--</p>
<div id="mentorProfileDialogBadges" class="mentor-profile-badges"></div>
<span id="mentorProfileAvatar" class="mentor-profile-avatar" aria-hidden="true"></span>
<h3 id="mentorProfileName">--</h3>
<p id="mentorProfileTagline">--</p>
<div id="mentorProfileBadges" class="mentor-profile-badges"></div>
</div>
<section class="mentor-profile-section">
<h4>关注维度</h4>
<div id="mentorProfileDialogFocus" class="mentor-active-focus"></div>
<div id="activeMentorFocus" class="mentor-active-focus"></div>
</section>
<section class="mentor-profile-section">
<h4>资料依据</h4>
<strong id="mentorProfileDialogSource">--</strong>
<p id="mentorProfileDialogEvidence">--</p>
<strong id="mentorProfileSource">--</strong>
<p id="activeMentorEvidence">--</p>
</section>
<section class="mentor-profile-section mentor-profile-boundary">
<h4>数据边界</h4>
<p><i data-lucide="calendar-days"></i><span id="mentorProfileDialogDataDate">--</span></p>
<p><i data-lucide="calendar-days"></i><span id="mentorProfileDataDate">--</span></p>
<p><i data-lucide="database"></i><span>仅使用网页已提供的市场数据</span></p>
</section>
</div>
</dialog>
</aside>
</div>
</section>
+73 -236
View File
@@ -31,100 +31,25 @@ async function loadMentorSetup(force = false) {
}
}
const MENTOR_AVATAR_TONES = {
"xiaobai-perspective": "violet",
"kobe92-perspective": "blue",
"beijingchaojia-perspective": "green",
"chaojiyangjia-perspective": "orange",
"chenxiaoqun-perspective": "red",
"longfeihu-perspective": "teal",
"chuangshiji-perspective": "purple",
"foshanwuyingjiao-perspective": "yellow",
};
const MENTOR_AVATAR_TONE_CLASSES = [
"mentor-avatar-tone-violet", "mentor-avatar-tone-blue", "mentor-avatar-tone-green",
"mentor-avatar-tone-orange", "mentor-avatar-tone-red", "mentor-avatar-tone-teal",
"mentor-avatar-tone-purple", "mentor-avatar-tone-yellow",
];
function mentorAvatarTone(mentor) {
return MENTOR_AVATAR_TONES[String(mentor?.id || "")] || "blue";
}
function mentorAvatarToneClass(mentor) {
return `mentor-avatar-tone-${mentorAvatarTone(mentor)}`;
}
function applyMentorAvatarTone(element, toneClass) {
if (!(element instanceof Element)) return;
element.classList.remove(...MENTOR_AVATAR_TONE_CLASSES);
if (toneClass) element.classList.add(toneClass);
}
function renderMentorWorkspace() {
const setup = state.mentorSetup;
if (!setup) return;
const selected = setup.mentors.find((item) => item.id === state.selectedMentorId) || null;
setText("mentorPageSubtitle", `与不同交易思维模型持续对话 · 数据日期 ${displayCompactDate(setup.trade_date)}`);
setText("currentPageSubtitle", `与不同交易思维模型持续对话 · 数据日期 ${displayCompactDate(setup.trade_date)}`);
setText("activeMentorName", selected?.name || "--");
setText("activeMentorAvatar", mentorAvatarText(selected));
applyMentorAvatarTone(document.querySelector("#activeMentorAvatar"), mentorAvatarToneClass(selected));
const pinButton = document.querySelector("#mentorPinButton");
if (pinButton) {
pinButton.classList.toggle("active", Boolean(selected?.pinned));
pinButton.setAttribute("aria-label", selected?.pinned ? "取消置顶当前思维模型" : "置顶当前思维模型");
pinButton.setAttribute("aria-pressed", String(Boolean(selected?.pinned)));
}
populateMentorDialogs(selected);
setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`);
setText("mentorProfileName", selected?.name || "--");
setText("mentorProfileTagline", selected?.tagline || selected?.description || "--");
setText("mentorProfileSource", selected?.evidence?.label || "公开资料整理");
setText("mentorProfileDataDate", `行情数据 ${displayCompactDate(setup.trade_date)}`);
setText("mentorProfileAvatar", mentorAvatarText(selected));
document.querySelector("#mentorProfileAvatar").dataset.grade = String(selected?.evidence?.grade || "").toLowerCase();
document.querySelector("#mentorProfileBadges").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 populateMentorDialogs(selected) {
const noteInput = document.querySelector("#mentorNoteInput");
if (noteInput) {
noteInput.value = loadMentorNote(selected);
}
setText("mentorProfileDialogName", selected?.name || "--");
setText("mentorProfileDialogTagline", selected?.tagline || selected?.description || "--");
setText("mentorProfileDialogSource", selected?.evidence?.label || "公开资料整理");
setText("mentorProfileDialogEvidence", selected?.evidence?.note || selected?.description || "--");
setText("mentorProfileDialogDataDate", `行情数据 ${displayCompactDate(state.mentorSetup?.trade_date || elements.tradeDate.value)}`);
setText("mentorProfileDialogAvatar", mentorAvatarText(selected));
applyMentorAvatarTone(document.querySelector("#mentorProfileDialogAvatar"), mentorAvatarToneClass(selected));
document.querySelector("#mentorProfileDialogBadges").innerHTML = selected ? renderMentorBadges(selected) : "";
document.querySelector("#mentorProfileDialogFocus").innerHTML = (selected?.focus || []).slice(0, 4)
.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
}
function mentorNoteStorageKey(selected) {
const accountId = state.user?.id || state.user?.username || "anon";
return `xiaobai-mentor-note-${accountId}-${String(selected?.id || "")}`;
}
function loadMentorNote(selected) {
if (!selected) return "";
try {
return window.localStorage.getItem(mentorNoteStorageKey(selected)) || "";
} catch (_error) {
return "";
}
}
function saveMentorNote() {
const selected = selectedMentor();
if (!selected) return;
const input = document.querySelector("#mentorNoteInput");
if (!input) return;
try {
window.localStorage.setItem(mentorNoteStorageKey(selected), input.value);
} catch (_error) {
showToast("备注保存失败");
}
}
function renderMentorDirectory() {
const mentors = state.mentorSetup?.mentors || [];
const query = state.mentorQuery;
@@ -149,15 +74,9 @@ function renderMentorDirectory() {
sortToggle.querySelector("span").textContent = state.mentorSortMode ? "完成" : "整理";
document.querySelector("#mentorSortHint").hidden = !state.mentorSortMode;
document.querySelector("#mentorSearchInput").disabled = state.mentorSortMode;
document.querySelector("#mentorFilterToggle").disabled = state.mentorSortMode;
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
button.disabled = state.mentorSortMode;
});
const filterOptions = document.querySelector("#mentorFilterOptions");
if (state.mentorSortMode && filterOptions && !filterOptions.hidden) {
filterOptions.hidden = true;
document.querySelector("#mentorFilterToggle").setAttribute("aria-expanded", "false");
}
const container = document.querySelector("#mentorList");
container.classList.toggle("is-sorting", state.mentorSortMode);
container.innerHTML = filtered.map((mentor) => {
@@ -167,7 +86,7 @@ function renderMentorDirectory() {
<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-avatar ${mentorAvatarToneClass(mentor)}" data-grade="${escapeHtml(String(mentor.evidence?.grade || "").toLowerCase())}" aria-hidden="true">${escapeHtml(mentorAvatarText(mentor))}</span>
<span class="mentor-avatar" data-grade="${escapeHtml(String(mentor.evidence?.grade || "").toLowerCase())}" aria-hidden="true">${escapeHtml(mentorAvatarText(mentor))}</span>
<span class="mentor-option-copy">
<span class="mentor-option-heading">
<strong>${escapeHtml(mentor.name)}</strong>
@@ -177,12 +96,16 @@ function renderMentorDirectory() {
<span class="mentor-option-meta">${escapeHtml((mentor.focus || [])[0] || mentor.evidence?.label || "公开资料模型")}</span>
</span>
</button>
${state.mentorSortMode ? `
<span class="mentor-option-tools">
<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>
<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("");
@@ -190,6 +113,9 @@ function renderMentorDirectory() {
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));
});
@@ -215,36 +141,6 @@ function toggleMentorSortMode() {
renderMentorDirectory();
}
function toggleMentorFilterMenu() {
if (state.mentorSortMode || state.mentorLoading) return;
const options = document.querySelector("#mentorFilterOptions");
const toggle = document.querySelector("#mentorFilterToggle");
if (!options || !toggle) return;
const open = options.hidden;
options.hidden = !open;
toggle.setAttribute("aria-expanded", String(open));
if (open) {
const active = options.querySelector("[data-mentor-grade].active");
(active || options.querySelector("[data-mentor-grade]"))?.focus({ preventScroll: true });
}
}
function closeMentorFilterMenu() {
const options = document.querySelector("#mentorFilterOptions");
const toggle = document.querySelector("#mentorFilterToggle");
if (!options || options.hidden) return;
options.hidden = true;
toggle?.setAttribute("aria-expanded", "false");
}
function selectMentorGrade(grade) {
state.mentorGrade = grade || "all";
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
button.classList.toggle("active", button.dataset.mentorGrade === state.mentorGrade);
});
renderMentorDirectory();
}
async function toggleMentorPin(mentorId) {
if (state.mentorSavingPreferences) return;
const mentors = state.mentorSetup?.mentors || [];
@@ -348,17 +244,14 @@ async function persistMentorPreferences() {
}
}
function renderMentorBadges(mentor) {
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>');
}
if (mentor.pinned) {
badges.push('<span class="mentor-badge pinned" title="置顶"><i data-lucide="pin"></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>`);
badges.push(`<span class="mentor-badge evidence grade-${escapeHtml(grade.toLowerCase())}" title="${escapeHtml(mentor.evidence?.note || "素材等级")}">${escapeHtml(grade)}</span>`);
}
return badges.join("");
}
@@ -367,14 +260,6 @@ function mentorAvatarText(mentor) {
return Array.from(String(mentor?.name || "师").trim())[0] || "师";
}
function mentorMessageTime(message) {
const parsed = new Date(String(message?.created_at || ""));
if (Number.isNaN(parsed.getTime())) {
return new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
}
return parsed.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
}
async function selectMentor(mentorId) {
if (mentorId === state.selectedMentorId) return;
state.selectedMentorId = mentorId;
@@ -400,28 +285,29 @@ function renderMentorMessages() {
} else {
container.innerHTML = state.mentorMessages.map((message) => `
<article class="mentor-message ${message.role} ${message.error ? "is-error" : ""}">
<span class="mentor-message-avatar ${message.role === "user" ? "mentor-avatar-tone-blue" : escapeHtml(mentorAvatarToneClass(selected))}" aria-hidden="true">${message.role === "user" ? "我" : escapeHtml(mentorAvatarText(selected))}</span>
<span class="mentor-message-avatar" data-grade="${message.role === "assistant" ? escapeHtml(String(selected?.evidence?.grade || "").toLowerCase()) : ""}" aria-hidden="true">${message.role === "user" ? "我" : escapeHtml(mentorAvatarText(selected))}</span>
<div class="mentor-message-body">
<div class="mentor-message-label">${message.role === "user"
? escapeHtml(mentorMessageTime(message))
: `${escapeHtml(selected?.name || "问师")} · ${escapeHtml(mentorMessageTime(message))}`}</div>
<div class="mentor-message-content">${message.role === "assistant"
? (message.content ? formatMentorAnswer(message.content) : '<p class="mentor-loading-copy">正在读取复盘数据并推演...</p>')
: escapeHtml(message.content)}</div>
<div class="mentor-message-label">${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}</div>
${message.role === "assistant"
? `<div class="mentor-message-stack">${message.content
? formatMentorAnswer(message.content)
: '<div class="mentor-message-content"><p class="mentor-loading-copy">正在读取复盘数据并推演...</p></div>'}</div>`
: `<div class="mentor-message-content">${escapeHtml(message.content)}</div>`}
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
${renderMentorFollowUps(message)}
${message.meta && !message.streaming ? `<small>${escapeHtml(message.meta)}</small>` : ""}
</div>
</article>
`).join("");
}
document.querySelector("#mentorQuickPrompts").hidden = false;
document.querySelector("#mentorQuickPrompts").hidden = state.mentorMessages.length > 0 || state.mentorLoading;
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("#sendMentorQuestion").hidden = state.mentorLoading;
document.querySelector("#stopMentorQuestion").hidden = !state.mentorLoading;
document.querySelector("#mentorSortToggle").disabled = state.mentorLoading;
setText("activeMentorStatus", state.mentorLoading ? "正在生成回答..." : (selected?.tagline || selected?.description || "思维模型已就绪"));
setText("activeMentorStatus", state.mentorLoading ? "正在生成回答..." : "思维模型已就绪");
container.querySelectorAll("[data-mentor-follow-up]").forEach((button) => {
button.addEventListener("click", () => useMentorQuickPrompt(button.dataset.mentorFollowUp));
});
@@ -436,7 +322,7 @@ function renderMentorFollowUps(message) {
return `
<div class="mentor-follow-ups" aria-label="继续追问">
<span>继续追问</span>
${items.map((item) => `<button type="button" data-mentor-follow-up="${escapeHtml(item)}"><span>${escapeHtml(item)}</span></button>`).join("")}
${items.map((item) => `<button type="button" data-mentor-follow-up="${escapeHtml(item)}"><i data-lucide="corner-down-right"></i><span>${escapeHtml(item)}</span></button>`).join("")}
</div>
`;
}
@@ -456,7 +342,6 @@ async function sendMentorQuestion(event) {
const responseMessage = { role: "assistant", content: "", streaming: true, meta: "", followUps: [] };
state.mentorMessages.push(responseMessage);
input.value = "";
syncMentorComposerHeight();
state.mentorLoading = true;
state.mentorController = new AbortController();
hideMentorNotice();
@@ -477,6 +362,7 @@ async function sendMentorQuestion(event) {
scheduleMentorRender();
},
(meta) => {
responseMessage.meta = `${displayCompactDate(meta.data_trade_date || elements.tradeDate.value)} · 回答完成`;
responseMessage.followUps = Array.isArray(meta.follow_ups)
? meta.follow_ups.filter((item) => typeof item === "string" && item.trim()).slice(0, 3)
: [];
@@ -545,7 +431,6 @@ async function streamMentorRequest(body, signal, onDelta, onMeta) {
function useMentorQuickPrompt(prompt) {
const input = document.querySelector("#mentorQuestion");
input.value = prompt || "";
syncMentorComposerHeight();
input.focus();
}
@@ -593,12 +478,14 @@ function hideMentorNotice() {
}
function formatMentorAnswer(content) {
const blocks = [];
const sections = [[]];
let currentSection = sections[0];
let headingCount = 0;
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}>`);
currentSection.push(`<${listType} class="mentor-answer-list">${listItems.map((item) => `<li>${item}</li>`).join("")}</${listType}>`);
listItems = [];
listType = "";
};
@@ -608,18 +495,23 @@ function formatMentorAnswer(content) {
flushList();
return;
}
const heading = line.match(/^#{1,3}\s+(.+)$/);
const heading = mentorAnswerHeading(line);
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>`);
if (currentSection.length) {
currentSection = [];
sections.push(currentSection);
}
headingCount += 1;
currentSection.push(`<strong class="mentor-answer-heading">${formatMentorInline(escapeHtml(heading))}</strong>`);
} else if (/^-{3,}$/.test(line)) {
flushList();
blocks.push('<span class="mentor-answer-rule"></span>');
currentSection.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>`);
currentSection.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();
@@ -627,11 +519,27 @@ function formatMentorAnswer(content) {
listItems.push(formatMentorInline(escapeHtml((bullet || ordered)[1])));
} else {
flushList();
blocks.push(`<p class="mentor-answer-paragraph">${formatMentorInline(escapeHtml(line))}</p>`);
currentSection.push(`<p class="mentor-answer-paragraph">${formatMentorInline(escapeHtml(line))}</p>`);
}
});
flushList();
return blocks.join("");
const populatedSections = sections.filter((section) => section.length);
if (headingCount < 2) {
return `<div class="mentor-message-content">${populatedSections.flat().join("")}</div>`;
}
return populatedSections.map((section) => (
`<section class="mentor-message-content mentor-answer-bubble">${section.join("")}</section>`
)).join("");
}
function mentorAnswerHeading(line) {
const markdownHeading = line.match(/^#{1,3}\s+(.+)$/);
if (markdownHeading) return markdownHeading[1].trim();
const boldHeading = line.match(/^\*\*([^*]+)\*\*$/);
if (!boldHeading) return "";
const title = boldHeading[1].trim();
if (!title || title.length > 32 || /[。!?!?;:]$/.test(title)) return "";
return title;
}
function formatMentorInline(content) {
@@ -644,24 +552,19 @@ function bindMentorEvents() {
document.querySelector("#stopMentorQuestion").addEventListener("click", stopMentorGeneration);
document.querySelector("#clearMentorChatButton").addEventListener("click", clearMentorConversation);
document.querySelector("#mentorSortToggle").addEventListener("click", toggleMentorSortMode);
document.querySelector("#mentorFilterToggle").addEventListener("click", (event) => {
event.stopPropagation();
toggleMentorFilterMenu();
});
document.querySelector("#mentorSearchInput").addEventListener("input", (event) => {
state.mentorQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
renderMentorDirectory();
});
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
button.addEventListener("click", () => {
selectMentorGrade(button.dataset.mentorGrade);
closeMentorFilterMenu();
state.mentorGrade = button.dataset.mentorGrade || "all";
document.querySelectorAll("[data-mentor-grade]").forEach((item) => {
item.classList.toggle("active", item === button);
});
renderMentorDirectory();
});
});
document.addEventListener("click", (event) => {
if (event.target.closest(".mentor-filter-menu")) return;
closeMentorFilterMenu();
});
document.querySelectorAll("[data-mentor-prompt]").forEach((button) => {
button.addEventListener("click", () => useMentorQuickPrompt(button.dataset.mentorPrompt));
});
@@ -670,70 +573,4 @@ function bindMentorEvents() {
event.preventDefault();
document.querySelector("#mentorChatForm").requestSubmit();
});
document.querySelector("#mentorQuestion").addEventListener("input", syncMentorComposerHeight);
document.querySelector("#mentorNoteButton").addEventListener("click", openMentorNoteDialog);
document.querySelector("#mentorProfileButton").addEventListener("click", openMentorProfileDialog);
document.querySelector("#mentorPinButton").addEventListener("click", () => toggleMentorPin(state.selectedMentorId));
document.querySelector("#mentorNoteInput").addEventListener("input", saveMentorNote);
document.querySelectorAll("[data-mentor-dialog-close]").forEach((button) => {
button.addEventListener("click", () => closeMentorDialog(button.dataset.mentorDialogClose));
});
window.addEventListener("resize", centerOpenMentorDialogs);
}
function selectedMentor() {
return (state.mentorSetup?.mentors || []).find((item) => item.id === state.selectedMentorId) || null;
}
function syncMentorComposerHeight() {
const input = document.querySelector("#mentorQuestion");
if (!input) return;
input.style.height = "0";
const nextHeight = Math.min(input.scrollHeight, 168);
input.style.height = `${nextHeight}px`;
input.style.overflowY = nextHeight >= 168 ? "auto" : "hidden";
}
function openMentorDialog(dialogId) {
const dialog = document.querySelector(`#${dialogId}`);
if (!(dialog instanceof HTMLDialogElement)) return;
if (!dialog.open) dialog.showModal();
positionMentorDialog(dialog);
}
function closeMentorDialog(dialogId) {
const dialog = document.querySelector(`#${dialogId}`);
if (dialog instanceof HTMLDialogElement && dialog.open) dialog.close();
}
function openMentorNoteDialog() {
const input = document.querySelector("#mentorNoteInput");
if (input) input.value = loadMentorNote(selectedMentor());
openMentorDialog("mentorNoteDialog");
const noteInput = document.querySelector("#mentorNoteInput");
if (noteInput) requestAnimationFrame(() => noteInput.focus());
}
function openMentorProfileDialog() {
populateMentorDialogs(selectedMentor());
openMentorDialog("mentorProfileDialog");
}
function positionMentorDialog(dialog) {
if (!(dialog instanceof HTMLDialogElement) || !dialog.open) return;
const viewportPadding = 12;
const rect = dialog.getBoundingClientRect();
const width = Math.min(400, window.innerWidth - viewportPadding * 2);
const height = Math.min(rect.height, window.innerHeight - viewportPadding * 2);
dialog.style.top = `${Math.max(viewportPadding, Math.round((window.innerHeight - height) / 2))}px`;
dialog.style.left = `${Math.max(viewportPadding, Math.round((window.innerWidth - width) / 2))}px`;
dialog.style.right = "auto";
dialog.style.bottom = "auto";
dialog.style.margin = "0";
}
function centerOpenMentorDialogs() {
document.querySelectorAll("#mentorNoteDialog, #mentorProfileDialog").forEach((dialog) => {
if (dialog instanceof HTMLDialogElement && dialog.open) positionMentorDialog(dialog);
});
}
+26 -84
View File
@@ -78,7 +78,7 @@
color: var(--r2-faint);
text-align: left;
text-align: right;
}
.redesigned-pool-view .data-table .number {
@@ -207,7 +207,7 @@
color: var(--r2-faint);
text-align: left;
text-align: right;
}
.redesigned-broken-view .data-table .number {
@@ -335,7 +335,7 @@
color: var(--r2-faint);
text-align: left;
text-align: right;
}
.redesigned-down-view .data-table .number {
@@ -455,7 +455,7 @@
color: var(--r2-faint);
text-align: left;
text-align: right;
}
.redesigned-yesterday-view .data-table .number {
@@ -716,6 +716,7 @@
#brokenView .data-table thead th,
#downView .data-table thead th,
#limitPool .data-table thead th,
#performanceView .data-table thead th,
#yesterdayView .data-table thead th {
height: 32px;
@@ -725,16 +726,9 @@
font-size: 11.5px;
}
#limitPool .data-table thead th {
height: 36px;
padding: 0 8px;
font-size: var(--font-size-caption);
}
#brokenView .data-table tbody td,
#downView .data-table tbody td,
#limitPool .data-table tbody td,
#performanceView .data-table tbody td,
#yesterdayView .data-table tbody td {
height: 39px;
@@ -742,12 +736,6 @@
padding: 5px 9px;
}
#limitPool .data-table tbody td {
height: 40px;
padding: 0 8px;
}
#limitPool .main-grid {
grid-template-columns: minmax(0px, 1fr) 308px;
}
@@ -904,6 +892,24 @@
line-height: 1.6;
}
.pool-streak-tag {
display: inline-block;
padding: 1.5px 7px;
border: 1px solid transparent;
border-radius: 5px;
font-size: 11px;
line-height: 1.6;
background: var(--r2-up-soft);
color: var(--r2-up);
}
.pool-state-tag.one-word {
background: rgb(255, 243, 217);
@@ -2219,70 +2225,6 @@
table-layout: auto;
}
#limitPool #limitTable {
min-width: var(--table-wide);
width: 100%;
table-layout: auto;
}
#limitPool #limitTable.tbl thead th,
#limitPool .pool-table-card .data-table thead th {
height: 36px;
padding: 0 8px;
font-size: var(--font-size-caption);
}
#limitPool #limitTable.tbl tbody td,
#limitPool .pool-table-card .data-table tbody td {
height: 40px;
padding: 0 8px;
}
@media (min-width: 1440px) {
#limitPool #limitTable {
min-width: 0;
table-layout: fixed;
}
#limitPool #limitTable thead th {
width: auto;
min-width: 0;
}
#limitPool #limitTable thead th.row-number {
width: 36px;
}
#limitPool.redesigned-pool-view .pool-table-card.tbl-wrap {
overflow-x: hidden;
overflow-y: auto;
}
#limitPool #limitTable .reason-column {
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
#limitPool #limitTable .pool-reason-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
#brokenTable,
#downTable,
#limitTable,
@@ -2293,9 +2235,9 @@
:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) .row-number {
padding-right: 8px;
padding-left: 12px;
padding-left: 8px;
text-align: left;
text-align: center;
}
:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) .reason-column {
+4 -4
View File
@@ -26,7 +26,7 @@
<table class="data-table tbl" id="limitTable">
<thead>
<tr>
<th class="row-number" aria-label="序号">序号</th>
<th class="row-number num" aria-label="序号">序号</th>
<th data-sort="name">股票</th>
<th class="number num sortable" data-sort="streak">连板<span class="arr"></span></th>
<th class="number num sortable" data-sort="change">涨幅(%<span class="arr"></span></th>
@@ -83,7 +83,7 @@
<table id="brokenTable" class="data-table tbl">
<thead>
<tr>
<th class="row-number">序号</th>
<th class="row-number num">序号</th>
<th>股票</th>
<th class="number num sortable" data-broken-sort="change">现价涨幅(%<span class="arr"></span></th>
<th class="number num sortable" data-auto-sort="true">距涨停(%<span class="arr"></span></th>
@@ -122,7 +122,7 @@
<table id="downTable" class="data-table tbl">
<thead>
<tr>
<th class="row-number">序号</th>
<th class="row-number num">序号</th>
<th>股票</th>
<th class="number num sortable" data-down-sort="change">跌幅(%<span class="arr"></span></th>
<th class="number num sortable" data-auto-sort="true">价格(元)<span class="arr"></span></th>
@@ -176,7 +176,7 @@
<table id="yesterdayTable" class="data-table tbl">
<thead>
<tr>
<th class="row-number">序号</th>
<th class="row-number num">序号</th>
<th>股票</th>
<th class="number num sortable" data-yesterday-sort="prior_streak">昨日高度(板)<span class="arr"></span></th>
<th class="number num sortable" data-yesterday-sort="current_change">今日涨幅(%<span class="arr"></span></th>
+5 -5
View File
@@ -28,9 +28,9 @@ function renderLimitTable() {
const body = document.querySelector("#limitTableBody");
body.innerHTML = rows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}">
<td class="row-number muted">${index + 1}</td>
<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="streak-pill${number(row.streak) >= 4 ? " high" : ""}">${streakLabel(row.streak)}</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>
@@ -75,7 +75,7 @@ function renderBrokenTable(rows) {
const body = document.querySelector("#brokenTableBody");
body.innerHTML = visibleRows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}">
<td class="row-number muted">${index + 1}</td>
<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>
@@ -155,7 +155,7 @@ function renderDownTable(rows) {
const body = document.querySelector("#downTableBody");
body.innerHTML = visibleRows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}">
<td class="row-number muted">${index + 1}</td>
<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>
@@ -228,7 +228,7 @@ function renderYesterdayTable(rows) {
const body = document.querySelector("#yesterdayTableBody");
body.innerHTML = visibleRows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}">
<td class="row-number muted">${index + 1}</td>
<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>
+41 -46
View File
@@ -22,11 +22,11 @@
}
.popularity-source-tag.dual {
border-color: var(--warn);
border-color: rgb(230, 199, 115);
background: var(--amber-soft);
color: var(--warn);
color: rgb(118, 83, 20);
}
.popularity-concepts {
@@ -44,21 +44,21 @@
}
#popularityView .data-table {
font-size: var(--font-size-table);
font-size: 12px;
}
#popularityView .data-table thead th {
height: 36px;
height: 32px;
padding: 0 12px;
padding: 6px 9px;
font-size: var(--font-size-caption);
font-size: 11.5px;
}
#popularityView .data-table tbody td {
height: 40px;
height: 39px;
padding: 0 12px;
padding: 5px 9px;
}
.redesigned-popularity-view {
@@ -93,10 +93,6 @@
margin: 0px;
color: var(--r2-ink);
font-size: var(--font-size-page-title);
font-weight: var(--font-weight-semibold);
}
.popularity-title-v2 > span {
@@ -144,7 +140,7 @@
align-items: center;
min-height: 32px;
min-height: 34px;
padding: 3px;
@@ -156,19 +152,19 @@
}
.popularity-source-tabs-v2 button {
min-height: 26px;
min-height: 27px;
padding: 0px 13px;
border: 0px;
border-radius: 6px;
border-radius: 5px;
background: transparent;
color: var(--r2-sub);
font-size: var(--font-size-label);
font-size: 11px;
cursor: pointer;
}
@@ -194,15 +190,11 @@
}
.popularity-refresh-v2 {
min-height: 32px;
min-height: 34px;
height: 32px;
padding: 0px 11px;
padding: 0px 12px;
border-radius: 8px;
font-size: var(--font-size-label);
border-radius: 7px;
}
.popularity-refresh-v2 .lucide {
@@ -252,15 +244,15 @@
width: 3px;
background: var(--accent-soft);
background: rgb(199, 216, 251);
}
.popularity-glance-v2 article:nth-child(2)::before {
background: var(--down-soft);
background: rgb(183, 221, 207);
}
.popularity-glance-v2 article.consensus::before {
background: var(--warn);
background: rgb(230, 199, 115);
}
.popularity-glance-v2 article > span {
@@ -380,7 +372,7 @@
width: 230px;
height: 32px;
height: 33px;
flex: 0 0 auto;
@@ -388,9 +380,9 @@
padding: 0px 10px;
border: 1px solid var(--border-strong);
border: 1px solid rgb(216, 221, 229);
border-radius: 8px;
border-radius: 7px;
color: var(--r2-faint);
@@ -398,9 +390,9 @@
}
.popularity-search-v2:focus-within {
border-color: var(--accent);
border-color: rgb(150, 181, 242);
box-shadow: 0 0 0 2px var(--focus-ring);
box-shadow: rgba(37, 99, 235, 0.09) 0px 0px 0px 3px;
}
.popularity-search-v2 .lucide {
@@ -454,15 +446,15 @@
z-index: 2;
height: 36px;
height: 35px;
padding: 0 12px;
padding: 7px 10px;
border-bottom-color: var(--r2-line);
color: var(--r2-sub);
font-size: var(--font-size-caption);
font-size: 10.5px;
}
.popularity-table-v2 thead th:nth-child(1) {
@@ -485,11 +477,11 @@
}
.popularity-table-v2 tbody td {
height: 40px;
height: 43px;
padding: 0 12px;
padding: 7px 10px;
font-size: var(--font-size-table);
font-size: 11.5px;
}
.popularity-table-v2 tbody tr {
@@ -549,9 +541,9 @@
color: var(--r2-ink);
font-size: var(--font-size-table);
font-size: 12px;
font-weight: var(--font-weight-semibold);
font-weight: 700;
text-overflow: ellipsis;
@@ -567,7 +559,7 @@
}
.popularity-list-rank-v2 {
color: var(--text-2);
color: rgb(64, 85, 115);
}
.popularity-movement-v2 {
@@ -619,6 +611,10 @@
overflow: hidden;
}
body[data-active-view="popularityView"] .overview-strip {
flex: 0 0 auto;
}
body[data-active-view="popularityView"] #popularityView.active-view {
min-height: 0px;
@@ -662,9 +658,11 @@
}
.popularity-table-v2 tbody td {
height: 40px;
height: 39px;
padding: 0 12px;
padding-top: 5px;
padding-bottom: 5px;
}
}
@@ -859,11 +857,8 @@
width: auto;
}
#popularityView .popularity-table-v2 th:first-child,
#popularityView .popularity-table-v2 td:first-child {
#popularityView .popularity-table-v2 th:first-child {
width: var(--col-rank);
text-align: left;
}
#popularityView .popularity-table-v2 th:nth-child(2) {
+2 -2
View File
@@ -50,7 +50,7 @@ function renderPopularityTable() {
setText("popularityTableTitle", `${sourceName}`);
setText("popularityTableNote", combined ? "按双榜排名综合排序 · 已隐藏重复的榜单状态" : "按榜单名次排序 · 状态显示是否同时进入另一榜");
const headers = [
["排名", "row-number"], ["股票", ""], ["最新价(元)", "number num"], ["涨跌幅(%", "number num"],
["排名", "number num"], ["股票", ""], ["最新价(元)", "number num"], ["涨跌幅(%", "number num"],
...(source !== "dc" ? [["同花顺", "number num"]] : []),
...(source !== "ths" ? [["东方财富", "number num"]] : []),
["排名变化", "number num"], ["热门概念", ""], ...(!combined ? [["榜单状态", ""]] : []),
@@ -63,7 +63,7 @@ function renderPopularityTable() {
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="row-number popularity-rank-v2"><b>${index + 1}</b>${index < 3 ? '<span>热</span>' : ""}</td>
<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>
+91 -92
View File
@@ -217,6 +217,10 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
}
}
body[data-active-view="reviewWorkspaceView"] .overview-strip {
display: grid;
}
.trade-journal-section .trade-log-summary {
min-height: 58px;
}
@@ -238,7 +242,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
border-radius: 10px;
box-shadow: var(--shadow-card);
box-shadow: var(--shadow-xs);
min-height: 0px !important;
}
@@ -272,15 +276,15 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
}
.watchlist-section .data-table thead th {
height: 36px;
height: 32px;
padding: 0 12px;
padding: 6px 12px;
}
.watchlist-section .data-table tbody td {
height: 40px;
height: 44px;
padding: 0 12px;
padding: 7px 12px;
}
.trade-journal-section .trade-log-summary:empty {
@@ -380,7 +384,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
}
#reviewWorkspaceView .review-history-toggle {
min-height: 32px;
min-height: 30px;
display: inline-flex;
@@ -394,13 +398,13 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
border: 1px solid var(--review-line);
border-radius: 8px;
border-radius: 7px;
background: var(--surface);
color: var(--text-secondary);
color: rgb(55, 65, 81);
font-size: var(--font-size-label);
font-size: 11.5px;
white-space: nowrap;
}
@@ -465,7 +469,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
background: var(--surface);
box-shadow: var(--shadow-card);
box-shadow: rgba(16, 24, 40, 0.05) 0px 1px 2px;
}
#reviewWorkspaceView .review-card-heading > div {
@@ -511,9 +515,9 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
}
#reviewWorkspaceView .data-table thead th {
height: 36px;
height: 32px;
padding: 0 12px;
padding: 6px 12px;
border-bottom: 1px solid var(--review-line-soft);
@@ -521,21 +525,21 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
color: var(--review-faint);
font-size: var(--font-size-caption);
font-size: 10.5px;
font-weight: var(--font-weight-semibold);
font-weight: 600;
}
#reviewWorkspaceView .data-table tbody td {
height: 40px;
height: 48px;
padding: 0 12px;
padding: 7px 12px;
border-bottom: 1px solid var(--review-line-soft);
color: var(--text-primary);
color: rgb(55, 65, 81);
font-size: var(--font-size-table);
font-size: 12px;
}
#reviewWorkspaceView .data-table tbody tr:last-child td {
@@ -555,17 +559,17 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
#reviewWorkspaceView .stock-cell strong {
color: var(--review-ink);
font-size: var(--font-size-table);
font-size: 12px;
}
#reviewWorkspaceView .stock-cell small {
color: var(--review-faint);
font-size: var(--font-size-aux);
font-size: 10px;
}
#reviewWorkspaceView .review-watch-mark {
color: var(--text-tertiary);
color: rgb(209, 213, 219);
font-size: 15px;
@@ -573,7 +577,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
}
#reviewWorkspaceView .review-watch-mark.red {
color: var(--market-up);
color: rgb(224, 69, 54);
}
#reviewWorkspaceView .review-row-actions {
@@ -597,19 +601,19 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
}
#reviewWorkspaceView .table-action {
min-height: 32px;
min-height: 25px;
padding: 0px 6px;
border: 0px;
border-radius: 8px;
border-radius: 5px;
background: transparent;
color: var(--review-blue);
font-size: var(--font-size-label);
font-size: 10.5px;
}
#reviewWorkspaceView .table-action:hover {
@@ -617,13 +621,13 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
}
#reviewWorkspaceView .table-action.down {
color: var(--text-tertiary);
color: rgb(139, 146, 158);
}
#reviewWorkspaceView .table-action.down:hover {
background: var(--surface-hover);
color: var(--market-up);
color: rgb(209, 67, 67);
}
#reviewWorkspaceView .trade-log-table-frame .empty-state {
@@ -663,7 +667,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
}
#reviewWorkspaceView .trade-log-heading .button {
min-height: 32px;
min-height: 29px;
display: inline-flex;
@@ -675,13 +679,13 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
padding: 0px 10px;
border-radius: 8px;
border-radius: 7px;
background: var(--review-blue);
color: var(--text-inverse);
font-size: var(--font-size-label);
font-size: 11.5px;
}
#reviewWorkspaceView .trade-log-heading .button:hover {
@@ -827,35 +831,35 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
}
#reviewWorkspaceView .trade-action-add {
border-color: var(--market-up);
border-color: rgb(241, 196, 192);
background: var(--market-up-soft);
background: rgb(253, 236, 234);
color: var(--market-up);
color: rgb(214, 62, 50);
}
#reviewWorkspaceView .trade-action-buy {
border-color: var(--market-up);
border-color: rgb(241, 196, 192);
background: var(--market-up-soft);
background: rgb(253, 236, 234);
color: var(--market-up);
color: rgb(214, 62, 50);
}
#reviewWorkspaceView .trade-action-sell {
border-color: var(--market-down);
border-color: rgb(188, 225, 202);
background: var(--market-down-soft);
background: rgb(234, 247, 239);
color: var(--market-down);
color: rgb(22, 139, 67);
}
#reviewWorkspaceView .trade-action-trim {
border-color: var(--market-down);
border-color: rgb(188, 225, 202);
background: var(--market-down-soft);
background: rgb(234, 247, 239);
color: var(--market-down);
color: rgb(22, 139, 67);
}
#reviewWorkspaceView .trade-tags {
@@ -875,7 +879,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
background: var(--review-blue-soft);
color: var(--action);
color: rgb(82, 112, 167);
font-size: 8.5px;
}
@@ -895,7 +899,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
white-space: nowrap;
color: var(--text-secondary);
color: rgb(75, 85, 99);
font-size: 10.5px;
@@ -985,7 +989,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
}
#reviewWorkspaceView .journal-form .button {
min-height: 32px;
min-height: 34px;
display: inline-flex;
@@ -997,13 +1001,13 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
padding: 0px 14px;
border-radius: 8px;
border-radius: 7px;
background: var(--review-blue);
color: var(--text-inverse);
font-size: var(--font-size-label);
font-size: 12px;
}
#reviewWorkspaceView .journal-form .button:hover {
@@ -1243,9 +1247,9 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
color: var(--review-ink);
font-size: var(--font-size-page-title);
font-size: 19px;
font-weight: var(--font-weight-semibold);
font-weight: 750;
}
#reviewWorkspaceView .review-page-title .section-subtitle {
@@ -1257,7 +1261,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
white-space: nowrap;
font-size: var(--font-size-caption);
font-size: 12px;
}
#reviewWorkspaceView .review-card-heading {
@@ -1281,13 +1285,13 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
color: var(--review-ink);
font-size: var(--font-size-card-title);
font-size: 14.5px;
font-weight: var(--font-weight-semibold);
font-weight: 720;
}
#reviewWorkspaceView .review-add-watch {
min-height: 32px;
min-height: 29px;
display: inline-flex;
@@ -1299,23 +1303,23 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
padding: 0px 10px;
border: 1px solid var(--color-action-line);
border: 1px solid rgb(185, 205, 248);
border-radius: 8px;
border-radius: 7px;
background: var(--action-soft);
background: rgb(245, 248, 255);
color: var(--review-blue);
font-size: var(--font-size-label);
font-size: 11.5px;
font-weight: var(--font-weight-semibold);
font-weight: 650;
}
#reviewWorkspaceView .review-add-watch:hover {
border-color: var(--action);
border-color: rgb(142, 176, 244);
background: var(--selected);
background: rgb(234, 241, 255);
}
#reviewWorkspaceView .review-add-watch .lucide {
@@ -1370,17 +1374,17 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
}
#reviewWorkspaceView .review-watchlist-table .stock-cell strong {
font-size: var(--font-size-table);
font-size: 12.5px;
font-weight: var(--font-weight-semibold);
font-weight: 680;
}
#reviewWorkspaceView .review-watchlist-table .stock-cell small {
font-size: var(--font-size-aux);
font-size: 10.5px;
}
#reviewWorkspaceView .watch-attention-score {
color: var(--text-primary);
color: rgb(63, 75, 94);
font-size: 12px;
@@ -1448,11 +1452,11 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
#reviewWorkspaceView .journal-summary-field input:focus {
border-color: var(--review-blue-line);
box-shadow: 0 0 0 2px var(--focus-ring);
box-shadow: rgba(37, 99, 235, 0.08) 0px 0px 0px 2px;
}
#reviewWorkspaceView .journal-summary-field input::placeholder {
color: var(--text-tertiary);
color: rgb(166, 173, 183);
}
#reviewWorkspaceView .journal-form textarea {
@@ -1539,7 +1543,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
.watchlist-editor-form .form-field > label,
.watchlist-editor-form .form-field > span {
color: var(--text-secondary);
color: rgb(55, 65, 81);
font-size: 12px;
@@ -1557,7 +1561,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
padding: 0px 11px;
border: 1px solid var(--border-strong);
border: 1px solid rgb(223, 227, 232);
border-radius: 8px;
@@ -1565,9 +1569,9 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
}
.watchlist-search-control:focus-within {
border-color: var(--color-action-line);
border-color: rgb(184, 202, 244);
box-shadow: 0 0 0 3px var(--focus-ring);
box-shadow: rgba(37, 99, 235, 0.07) 0px 0px 0px 3px;
}
.watchlist-search-control .lucide {
@@ -1575,7 +1579,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
height: 15px;
color: var(--text-tertiary);
color: rgb(154, 163, 176);
}
.watchlist-search-control input {
@@ -1587,7 +1591,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
outline: 0px;
color: var(--text-primary);
color: rgb(31, 41, 55);
font-style: inherit;
@@ -1641,13 +1645,13 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
border-style: none none solid;
border-color: currentcolor currentcolor var(--border);
border-color: currentcolor currentcolor rgb(238, 240, 243);
border-image: none;
background: var(--surface);
color: var(--text-primary);
color: rgb(31, 41, 55);
text-align: left;
}
@@ -1669,7 +1673,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
}
.watchlist-search-results button small {
color: var(--text-tertiary);
color: rgb(139, 148, 161);
font-size: 10.5px;
}
@@ -1677,7 +1681,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
.watchlist-search-results button > b {
margin-left: auto;
color: var(--text-secondary);
color: rgb(105, 115, 134);
font-size: 11px;
@@ -1687,7 +1691,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
.watchlist-search-status {
padding: 14px 10px;
color: var(--text-tertiary);
color: rgb(139, 148, 161);
font-size: 11.5px;
@@ -1705,7 +1709,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
padding: 10px 12px;
border: 1px solid var(--action-soft);
border: 1px solid rgb(220, 229, 247);
border-radius: 8px;
@@ -1729,9 +1733,9 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
border-radius: 7px;
background: var(--action-soft);
background: rgb(232, 239, 255);
color: var(--action);
color: rgb(37, 99, 235);
}
.watchlist-selection-icon .lucide {
@@ -1749,7 +1753,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
}
.watchlist-selection strong {
color: var(--text-primary);
color: rgb(31, 41, 55);
font-size: 13.5px;
}
@@ -1759,13 +1763,13 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
gap: 8px;
color: var(--text-tertiary);
color: rgb(123, 132, 145);
font-size: 10.5px;
}
.watchlist-selection span b {
color: var(--text-secondary);
color: rgb(82, 96, 113);
font-weight: 600;
}
@@ -1787,7 +1791,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
resize: vertical;
border: 1px solid var(--border-strong);
border: 1px solid rgb(223, 227, 232);
border-radius: 8px;
@@ -1821,9 +1825,9 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
}
.watchlist-editor-form textarea:focus {
border-color: var(--color-action-line);
border-color: rgb(184, 202, 244);
box-shadow: 0 0 0 3px var(--focus-ring);
box-shadow: rgba(37, 99, 235, 0.07) 0px 0px 0px 3px;
}
#reviewWorkspaceView .trade-log-table th:nth-child(1) {
@@ -1869,7 +1873,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
font-size: 12px;
font-weight: var(--font-weight-semibold);
font-weight: 650;
min-height: 0px;
@@ -2197,8 +2201,3 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
height: auto;
}
}
#reviewWorkspaceView .data-table thead th:first-child,
#reviewWorkspaceView .data-table tbody td:first-child {
text-align: left;
}
+49 -60
View File
@@ -25,7 +25,7 @@
}
.rotation-day.selected-day {
background: var(--selected);
background: rgb(240, 246, 253);
}
.rotation-sector-chip.selected {
@@ -81,11 +81,11 @@
}
.rotation-day:nth-child(2n+1) {
background: var(--surface-subtle);
background: rgb(246, 248, 250);
}
.rotation-day-sector {
border-color: var(--border);
border-color: rgb(231, 235, 239);
border-radius: 4px;
@@ -107,7 +107,7 @@
}
:where(#rotationView) .rotation-swatch {
background: var(--accent-soft);
background: rgba(53, 106, 230, 0.12);
}
:where(#rotationView) .rotation-tracker-copy {
@@ -157,9 +157,9 @@
cursor: pointer;
border-bottom: 1px dashed var(--border);
border-bottom: 1px dashed rgba(135, 149, 173, 0.22);
background: color-mix(in srgb, var(--accent) calc(4% + var(--rotation-heat) * 46%), transparent);
background: rgba(53, 106, 230, calc(.04 + var(--rotation-heat) * .46));
}
.redesigned-rotation-view {
@@ -185,9 +185,9 @@
color: var(--r2-ink);
font-size: var(--font-size-page-title);
font-size: 17px;
font-weight: var(--font-weight-semibold);
font-weight: 800;
letter-spacing: 0px;
}
@@ -241,7 +241,7 @@
color: var(--r2-sub);
font-size: var(--font-size-label);
font-size: 12px;
line-height: 1;
}
@@ -260,15 +260,13 @@
.rotation-order-control button:focus-visible,
.rotation-sector-chip:focus-visible,
.rotation-track-cancel:focus-visible {
outline: 2px solid var(--accent);
outline: rgba(37, 99, 235, 0.28) solid 2px;
outline-offset: 2px;
}
.rotation-export-button {
min-height: 32px;
height: 32px;
min-height: 30px;
display: inline-flex;
@@ -276,17 +274,17 @@
justify-content: center;
padding: 0 12px;
padding: 5px 12px;
border: 1px solid var(--border-strong);
border: 1px solid var(--r2-line);
border-radius: 8px;
border-radius: 7px;
background: var(--surface);
color: var(--text-primary);
color: rgb(55, 65, 81);
font-size: var(--font-size-label);
font-size: 12px;
font-weight: 500;
}
@@ -435,17 +433,17 @@
}
#rotationView .rotation-swatch.strong {
background: var(--accent);
background: rgb(112, 155, 245);
}
#rotationView .rotation-swatch.warm {
background: var(--accent-soft);
background: rgb(203, 220, 255);
}
#rotationView .rotation-swatch.mild {
background: var(--surface-muted);
background: rgb(240, 244, 250);
border: 1px solid var(--border);
border: 1px solid rgb(223, 230, 240);
}
#rotationView .rotation-tracker {
@@ -489,7 +487,7 @@
}
#rotationView .rotation-tracker-copy span {
color: var(--accent-hover);
color: rgb(59, 98, 196);
font-size: 12px;
@@ -537,7 +535,7 @@
border-radius: 2px 2px 0px 0px;
background: var(--accent);
background: rgb(147, 180, 245);
}
#rotationView .rotation-tracker-spark small {
@@ -545,7 +543,7 @@
top: -1px;
color: var(--accent-hover);
color: rgb(59, 98, 196);
font-size: 9px;
}
@@ -557,7 +555,7 @@
border-style: dashed dashed none;
border-color: var(--action-line) var(--action-line) currentcolor;
border-color: rgb(185, 200, 232) rgb(185, 200, 232) currentcolor;
border-image: none;
@@ -569,9 +567,7 @@
}
.rotation-track-cancel {
min-height: 32px;
height: 32px;
min-height: 30px;
display: inline-flex;
@@ -579,21 +575,21 @@
justify-content: center;
border: 1px solid var(--border-strong);
border: 1px solid var(--r2-line);
border-radius: 8px;
border-radius: 7px;
background: var(--surface);
color: var(--text-primary);
color: rgb(55, 65, 81);
font-size: var(--font-size-label);
font-size: 12px;
font-weight: 500;
margin-left: auto;
padding: 0 12px;
padding: 4px 9px;
}
#rotationView .rotation-history {
@@ -701,7 +697,7 @@
padding: 5px;
background: var(--surface-subtle);
background: rgb(251, 252, 254);
}
#rotationView .rotation-sector-chip {
@@ -821,7 +817,7 @@
border-color: var(--r2-blue);
box-shadow: inset 3px 0 0 var(--r2-blue), 0 0 0 1px var(--accent-soft);
box-shadow: inset 3px 0 0 var(--r2-blue), 0 0 0 1px rgba(37, 99, 235, .12);
}
.rotation-cell-tooltip {
@@ -868,9 +864,9 @@
}
#rotationView .rotation-table thead th {
height: 36px;
height: 37px;
padding: 0 12px;
padding: 8px 12px;
border-bottom: 1px solid var(--r2-line);
@@ -878,7 +874,7 @@
color: var(--r2-sub);
font-size: var(--font-size-caption);
font-size: 12px;
font-weight: 600;
@@ -891,13 +887,8 @@
text-align: right;
}
#rotationView .rotation-table thead th:first-child,
#rotationView .rotation-table tbody td:first-child {
text-align: left;
}
#rotationView .rotation-table thead th[data-auto-sort] {
color: var(--text-2);
color: rgb(75, 85, 99);
cursor: pointer;
@@ -927,16 +918,14 @@
}
#rotationView .rotation-table tbody td {
height: 40px;
height: 42px;
padding: 0 12px;
padding: 8px 12px;
border-bottom: 1px solid var(--r2-line-soft);
color: var(--r2-ink);
font-size: var(--font-size-table);
white-space: nowrap;
}
@@ -945,7 +934,7 @@
}
#rotationView .rotation-table tbody tr:hover td {
background: var(--table-hover);
background: rgb(248, 250, 255);
}
#rotationView .rotation-table .stock-name {
@@ -965,9 +954,9 @@
}
#rotationView .trend-cool {
background: var(--accent-soft);
background: rgb(232, 244, 253);
color: var(--accent);
color: rgb(37, 99, 235);
}
#rotationView .trend-new {
@@ -1051,25 +1040,25 @@
}
#rotationView .rotation-sector-chip.heat-mild {
border-color: var(--border);
border-color: rgb(229, 234, 241);
background: var(--surface-muted);
background: rgb(246, 248, 251);
box-shadow: none;
}
#rotationView .rotation-sector-chip.heat-strong {
border-color: var(--action-line);
border-color: rgba(37, 99, 235, 0.24);
background: var(--accent-soft);
background: rgb(197, 215, 251);
box-shadow: none;
}
#rotationView .rotation-sector-chip.heat-warm {
border-color: var(--action-line);
border-color: rgba(37, 99, 235, 0.12);
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
background: rgb(231, 239, 255);
box-shadow: none;
}
@@ -1077,7 +1066,7 @@
#rotationView .rotation-sector-chip:hover {
z-index: 6;
border-color: var(--accent);
border-color: rgba(37, 99, 235, 0.34);
filter: saturate(1.06);
@@ -1091,7 +1080,7 @@
border-collapse: collapse;
font-size: var(--font-size-table);
font-size: 12.5px;
table-layout: auto;
+1 -1
View File
@@ -41,7 +41,7 @@
<div class="rotation-table-frame tbl-wrap">
<table id="rotationTable" class="data-table tbl rotation-table">
<thead><tr>
<th class="row-number">序号</th><th>代码</th><th>股票</th>
<th class="number num">序号</th><th>代码</th><th>股票</th>
<th class="number num" data-auto-sort="true" title="涨跌幅:点击排序">涨跌幅(%</th>
<th class="number num">开盘价(元)</th><th class="number num">收盘价(元)</th>
<th class="number num" data-auto-sort="true" title="成交额:点击排序">成交额(亿)</th><th>行情状态</th>
+1 -1
View File
@@ -161,7 +161,7 @@ function renderRotationMembers() {
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="row-number muted">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td><td class="stock-name">${escapeHtml(row.name)}</td>
<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>
+105 -116
View File
@@ -1,8 +1,8 @@
/* Canonical CSS owner: screener. Historical layers consolidated 2026-08-02. */
#screenerView .section-toolbar h2 {
font-size: var(--font-size-page-title);
font-size: 17px;
font-weight: var(--font-weight-semibold);
font-weight: 800;
}
@media (max-width: 767px) {
@@ -25,7 +25,7 @@
.curated-detail-header > div > span,
.quant-panel-heading span {
color: var(--text-secondary);
color: rgb(100, 116, 139);
font-size: 12px;
@@ -35,7 +35,7 @@
:where(#screenerView) .curated-detail-header h3 {
margin: 3px 0px 0px;
color: var(--text-primary);
color: rgb(17, 24, 39);
letter-spacing: 0px;
}
@@ -45,7 +45,7 @@
border-radius: 5px;
background: var(--action-soft);
background: rgb(232, 238, 252);
}
:where(#screenerView) .curated-search .lucide {
@@ -55,19 +55,19 @@
:where(#screenerView) .curated-search input {
background: transparent;
color: var(--text-primary);
color: rgb(17, 24, 39);
}
.curated-strategy-card.active .curated-strategy-rank {
background: var(--action-soft);
background: rgb(220, 232, 255);
color: var(--action);
color: rgb(29, 78, 216);
}
:where(#screenerView) .curated-detail-header p {
margin: 9px 0px 0px;
color: var(--text-secondary);
color: rgb(100, 116, 139);
}
.curated-strategy-badges {
@@ -87,9 +87,9 @@
border-radius: 5px;
background: var(--hover);
background: rgb(241, 245, 249);
color: var(--text-secondary);
color: rgb(71, 85, 105);
font-weight: 650;
@@ -97,9 +97,9 @@
}
.curated-strategy-badges span:first-child {
background: var(--action-soft);
background: rgb(234, 241, 255);
color: var(--action);
color: rgb(29, 78, 216);
}
:where(#screenerView) .mini-section-heading {
@@ -111,11 +111,11 @@
:where(#screenerView) .mini-section-heading h4 {
font-weight: 740;
color: var(--text-primary);
color: rgb(31, 41, 55);
}
.mini-section-heading > span {
color: var(--text-secondary);
color: rgb(100, 116, 139);
font-size: 12px;
}
@@ -141,15 +141,15 @@
gap: 12px;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid rgb(238, 240, 243);
}
:where(#screenerView) .curated-rule-row span {
color: var(--text-secondary);
color: rgb(71, 85, 105);
}
:where(#screenerView) .curated-rule-row strong {
color: var(--text-primary);
color: rgb(17, 24, 39);
font-variant-numeric: tabular-nums;
}
@@ -163,7 +163,7 @@
.curated-score-row > span:first-child {
overflow: hidden;
color: var(--text-secondary);
color: rgb(71, 85, 105);
text-overflow: ellipsis;
@@ -177,7 +177,7 @@
border-radius: 4px;
background: var(--surface-muted);
background: rgb(237, 240, 244);
}
.curated-score-track i {
@@ -187,11 +187,11 @@
border-radius: inherit;
background: var(--action);
background: rgb(79, 118, 199);
}
.curated-score-row strong {
color: var(--text-primary);
color: rgb(51, 65, 85);
text-align: right;
@@ -211,11 +211,11 @@
}
:where(#screenerView) .curated-data-status > .lucide {
color: var(--market-down);
color: rgb(22, 163, 74);
}
.curated-data-status.missing > .lucide {
color: var(--warning-color);
color: rgb(217, 119, 6);
}
.curated-data-status span {
@@ -225,13 +225,13 @@
:where(#screenerView) .curated-data-status strong {
display: block;
color: var(--text-primary);
color: rgb(51, 65, 85);
}
:where(#screenerView) .curated-data-status small {
display: block;
color: var(--text-tertiary);
color: rgb(124, 135, 152);
}
.quant-universe-grid .form-field {
@@ -239,16 +239,16 @@
}
.quant-universe-grid input[type="number"] {
height: 32px;
height: 40px;
}
.quant-rule-row input:focus,
.quant-rule-row select:focus {
border-color: var(--action);
border-color: rgb(37, 99, 235);
outline: 0px;
box-shadow: 0 0 0 3px var(--focus-ring);
box-shadow: rgba(37, 99, 235, 0.1) 0px 0px 0px 3px;
}
:where(#screenerView) .quant-remove-button {
@@ -268,7 +268,7 @@
float: right;
color: var(--action);
color: rgb(29, 78, 216);
}
:where(#screenerView) .quant-weight-status > div {
@@ -278,7 +278,7 @@
:where(#screenerView) .quant-weight-status i {
width: 100%;
background: var(--action);
background: rgb(37, 99, 235);
transition: width 180ms, background 180ms;
}
@@ -305,7 +305,7 @@
}
.quant-summary-pane {
border-top: 1px solid var(--border);
border-top: 1px solid rgb(229, 231, 235);
border-left: 0px;
}
@@ -549,7 +549,7 @@
padding: 12px;
background: var(--surface-subtle);
background: rgb(248, 249, 251);
}
#screenerView .strategy-list {
@@ -575,7 +575,7 @@
}
#screenerView .strategy-item.active {
border-color: var(--color-action-line);
border-color: rgb(201, 219, 241);
background: var(--action-soft);
}
@@ -724,7 +724,7 @@
#screenerView .backtest-panel {
padding: 10px 14px 12px;
background: var(--surface-subtle);
background: rgb(251, 252, 253);
}
#screenerView .backtest-panel > .workspace-heading {
@@ -1099,7 +1099,7 @@
}
.tracking-status.active {
border-color: var(--color-action-line);
border-color: rgb(197, 215, 237);
background: var(--action-soft);
@@ -1107,7 +1107,7 @@
}
.tracking-status.complete {
border-color: var(--market-down);
border-color: rgb(185, 223, 207);
background: var(--market-down-soft);
@@ -1149,9 +1149,9 @@
border-radius: 50%;
background: var(--border);
background: rgb(229, 231, 235);
color: var(--text-tertiary);
color: rgb(156, 163, 175);
font-size: 11px;
@@ -1189,11 +1189,11 @@
.screener-strategy-title b.neutral {
background: var(--surface-muted);
color: var(--text-secondary);
color: rgb(107, 114, 128);
}
:where(#screenerView) .screener-strategy-summary p {
color: var(--text-secondary);
color: rgb(107, 114, 128);
font-size: 12px;
}
@@ -1225,7 +1225,7 @@
border-right: 0px;
border-bottom-color: var(--border);
border-bottom-color: rgb(238, 240, 243);
}
#screenerView .screener-result-frame th {
@@ -1235,13 +1235,13 @@
border-right: 0px;
border-bottom-color: var(--border);
border-bottom-color: rgb(238, 240, 243);
height: 38px;
background: var(--table-header);
color: var(--text-secondary);
color: rgb(107, 114, 128);
font-size: 12px;
}
@@ -1281,7 +1281,7 @@
background: var(--surface);
box-shadow: var(--shadow-float);
box-shadow: rgba(15, 23, 42, 0.16) -12px 0px 32px;
}
.strategy-drawer[open] {
@@ -1323,7 +1323,7 @@
}
.strategy-drawer-header span {
color: var(--text-tertiary);
color: rgb(156, 163, 175);
font-size: 10.5px;
}
@@ -1365,7 +1365,7 @@
border-bottom: 1px solid var(--border);
background: var(--surface-subtle);
background: rgb(248, 249, 251);
}
#screenerView .strategy-drawer .strategy-list {
@@ -1602,7 +1602,7 @@
padding-top: 5px;
border-top: 1px solid var(--border);
border-top: 1px solid rgb(238, 240, 243);
}
#screenerView .screener-backtest-strip {
@@ -1684,7 +1684,7 @@
background: var(--surface);
color: var(--text-primary);
color: rgb(31, 41, 55);
font-style: inherit;
@@ -1722,7 +1722,7 @@
padding: 0px 9px;
color: var(--text-primary);
color: rgb(31, 41, 55);
font: inherit;
@@ -1749,7 +1749,7 @@
.curated-library-pane {
border-right: 0px;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid rgb(229, 231, 235);
display: block;
@@ -1788,9 +1788,9 @@
}
.curated-search:focus-within {
border-color: var(--action);
border-color: rgb(142, 172, 239);
box-shadow: 0 0 0 3px var(--focus-ring);
box-shadow: rgba(53, 106, 230, 0.1) 0px 0px 0px 3px;
}
:where(#screenerView) .screener-page-heading {
@@ -1824,7 +1824,7 @@
}
:where(#screenerView) .curated-library-heading h3 {
color: var(--text-primary);
color: rgb(17, 24, 39);
letter-spacing: 0px;
}
@@ -1870,7 +1870,7 @@
justify-content: space-between;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid rgb(232, 235, 239);
}
:where(#screenerView) .curated-execution-bar {
@@ -1890,7 +1890,7 @@
}
#screenerView .regime-evidence strong {
color: var(--warning-color);
color: rgb(146, 88, 6);
font-size: 12px;
@@ -1902,7 +1902,7 @@
overflow: visible;
color: var(--warning-color);
color: rgb(162, 106, 24);
font-size: 10.5px;
@@ -2119,6 +2119,10 @@ body[data-active-view="screenerView"] .workspace-view {
}
}
body[data-active-view="screenerView"] .overview-strip {
display: grid;
}
@media (min-width: 901px) {
.screener-page-bar {
min-height: 34px;
@@ -2346,7 +2350,7 @@ body[data-active-view="screenerView"] .workspace-view {
}
:where(#screenerView) .curated-strategy-card:hover {
box-shadow: var(--shadow-raised);
box-shadow: rgba(37, 99, 235, 0.08) 0px 4px 14px;
}
:where(#screenerView) .quant-screener-panel {
@@ -2410,7 +2414,7 @@ body[data-active-view="screenerView"] .workspace-view {
border-radius: 999px;
background: var(--border-strong);
background: rgb(223, 229, 238);
accent-color: var(--primary);
@@ -2528,19 +2532,19 @@ body[data-active-view="screenerView"] .workspace-view {
color: var(--r2-ink);
font-size: var(--font-size-page-title);
font-size: 17px;
font-weight: var(--font-weight-semibold);
font-weight: 800;
}
#screenerView .screener-page-heading .section-subtitle {
color: var(--r2-faint);
font-size: var(--font-size-caption);
font-size: 12px;
}
#screenerView .screener-mode-tabs {
min-height: 32px;
min-height: 34px;
display: inline-flex;
@@ -2658,7 +2662,7 @@ body[data-active-view="screenerView"] .workspace-view {
}
#screenerView .step-line.complete {
background: var(--market-down);
background: rgb(134, 213, 160);
}
#screenerView .screener-overview-card {
@@ -2708,7 +2712,7 @@ body[data-active-view="screenerView"] .workspace-view {
border-radius: 5px;
background: var(--surface-muted);
background: rgb(244, 246, 248);
color: var(--r2-sub);
@@ -2762,7 +2766,7 @@ body[data-active-view="screenerView"] .workspace-view {
}
#screenerView .regime-option:hover {
border-color: var(--border-strong);
border-color: rgb(187, 200, 219);
color: var(--r2-ink);
}
@@ -2975,7 +2979,7 @@ body[data-active-view="screenerView"] .workspace-view {
#screenerView .result-toolbar .count-badge {
border-radius: 5px;
background: var(--surface-muted);
background: rgb(241, 243, 246);
color: var(--r2-sub);
@@ -3035,29 +3039,25 @@ body[data-active-view="screenerView"] .workspace-view {
}
#screenerView .data-table {
font-size: var(--font-size-table);
font-size: 11.5px;
}
#screenerView .data-table thead th {
height: 36px;
height: 39px;
padding: 0 12px;
padding: 8px 11px;
background: var(--table-header);
color: var(--r2-sub);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-semibold);
font-size: 10.5px;
}
#screenerView .data-table tbody td {
height: 40px;
height: 45px;
padding: 0 12px;
font-size: var(--font-size-table);
padding: 8px 11px;
}
#screenerView .data-table .stock-name {
@@ -3069,7 +3069,7 @@ body[data-active-view="screenerView"] .workspace-view {
overflow: hidden;
color: var(--text-secondary);
color: rgb(95, 107, 124);
text-overflow: ellipsis;
}
@@ -3091,13 +3091,13 @@ body[data-active-view="screenerView"] .workspace-view {
#screenerView .probability-value small {
margin-top: 3px;
font-weight: var(--font-weight-medium);
font-weight: 500;
display: block;
color: var(--r2-faint);
font-size: var(--font-size-aux);
font-size: 9px;
}
#screenerView .tracking-summary {
@@ -3341,7 +3341,7 @@ body[data-active-view="screenerView"] .workspace-view {
}
#screenerView .curated-school-filters button.active {
border-color: var(--color-action-line);
border-color: rgb(197, 212, 241);
background: var(--scr-blue-soft);
@@ -3562,7 +3562,7 @@ body[data-active-view="screenerView"] .workspace-view {
border-radius: 5px;
background: var(--surface-muted);
background: rgb(238, 241, 245);
color: var(--r2-sub);
@@ -3598,7 +3598,7 @@ body[data-active-view="screenerView"] .workspace-view {
border-radius: 4px;
background: var(--surface-muted);
background: rgb(238, 241, 245);
color: var(--r2-faint);
@@ -3953,7 +3953,7 @@ body[data-active-view="screenerView"] .workspace-view {
border-radius: 6px;
background: var(--surface-subtle);
background: rgb(247, 248, 250);
}
#screenerView .quant-rule-rows {
@@ -3989,7 +3989,7 @@ body[data-active-view="screenerView"] .workspace-view {
min-height: 30px;
border: 1px solid var(--border-strong);
border: 1px solid rgb(220, 225, 232);
border-radius: 6px;
@@ -4003,7 +4003,7 @@ body[data-active-view="screenerView"] .workspace-view {
min-height: 30px;
border: 1px solid var(--border-strong);
border: 1px solid rgb(220, 225, 232);
border-radius: 6px;
@@ -4075,7 +4075,7 @@ body[data-active-view="screenerView"] .workspace-view {
border-radius: 3px;
background: var(--surface-muted);
background: rgb(232, 235, 240);
}
#screenerView .quant-weight-status i {
@@ -4109,7 +4109,7 @@ body[data-active-view="screenerView"] .workspace-view {
}
#screenerView .strategy-drawer::backdrop {
background: var(--backdrop);
background: rgba(20, 29, 44, 0.38);
backdrop-filter: blur(3px);
}
@@ -4587,7 +4587,7 @@ body[data-active-view="screenerView"] .workspace-view {
}
#screenerTrackingView .tracking-back-button:hover {
border-color: var(--color-action-line);
border-color: rgb(185, 200, 228);
color: var(--scr-blue);
}
@@ -4743,27 +4743,23 @@ body[data-active-view="screenerView"] .workspace-view {
}
#screenerTrackingView .data-table {
font-size: var(--font-size-table);
font-size: 11.5px;
}
#screenerTrackingView .data-table thead th {
height: 36px;
height: 39px;
padding: 0 12px;
padding: 8px 11px;
background: var(--table-header);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-semibold);
font-size: 10.5px;
}
#screenerTrackingView .data-table tbody td {
height: 40px;
height: 46px;
padding: 0 12px;
font-size: var(--font-size-table);
padding: 8px 11px;
}
#screenerTrackingView .stock-cell {
@@ -4779,7 +4775,7 @@ body[data-active-view="screenerView"] .workspace-view {
#screenerTrackingView .stock-cell small {
color: var(--r2-faint);
font-size: var(--font-size-aux);
font-size: 9px;
}
#screenerTrackingView .table-action.danger {
@@ -5499,7 +5495,7 @@ body[data-active-view="screenerView"] .workspace-view {
flex-direction: column;
border: 1px solid var(--market-up);
border: 1px solid rgb(243, 201, 195);
border-radius: 9px;
@@ -5681,7 +5677,7 @@ body[data-active-view="screenerView"] .workspace-view {
}
#screenerView .screener-tracking-entry:focus-visible {
outline: 2px solid var(--action);
outline: rgba(37, 99, 235, 0.45) solid 2px;
outline-offset: 2px;
}
@@ -5733,11 +5729,11 @@ body[data-active-view="screenerView"] .workspace-view {
padding: 3px 8px;
border: 1px solid var(--border-strong);
border: 1px solid rgb(217, 226, 239);
border-radius: 5px;
background: var(--surface-subtle);
background: rgb(247, 249, 252);
color: var(--r2-sub);
@@ -5870,7 +5866,7 @@ body[data-active-view="screenerView"] .workspace-view {
width: 100%;
min-width: 1376px;
min-width: 1180px;
table-layout: fixed;
}
@@ -5891,12 +5887,12 @@ body[data-active-view="screenerView"] .workspace-view {
width: 78px;
}
#screenerView .screener-result-columns col:nth-child(5),
#screenerView .screener-result-columns col:nth-child(6),
#screenerView .screener-result-columns col:nth-child(7) {
width: 120px;
#screenerView .screener-result-columns col:nth-child(5) {
width: 104px;
}
#screenerView .screener-result-columns col:nth-child(6),
#screenerView .screener-result-columns col:nth-child(7),
#screenerView .screener-result-columns col:nth-child(8),
#screenerView .screener-result-columns col:nth-child(9) {
width: 82px;
@@ -6567,10 +6563,3 @@ body[data-active-view="screenerView"] .workspace-view {
gap: 3px;
}
}
#screenerView .data-table thead th:first-child,
#screenerView .data-table tbody td:first-child,
#screenerTrackingView .data-table thead th:first-child,
#screenerTrackingView .data-table tbody td:first-child {
text-align: left;
}
+81 -85
View File
@@ -218,11 +218,7 @@
.sentiment-history-table thead th {
border-bottom-color: rgb(216, 224, 230);
height: 36px;
font-size: var(--fs-caption);
font-weight: 600;
font-size: 11.5px;
}
.sentiment-history-table .sentiment-history-groups th {
@@ -317,10 +313,24 @@
background: var(--table-header);
}
.sentiment-history-table tbody tr:nth-child(2n) td {
background: var(--surface-subtle);
}
.sentiment-history-table tbody tr:hover td {
background: var(--table-hover);
}
.sentiment-history-table tbody tr.latest-row td {
background: var(--table-selected);
font-weight: 650;
}
.sentiment-history-table tbody tr.latest-row td:first-child {
box-shadow: inset 3px 0 0 var(--action);
}
.sentiment-score-cell {
font-weight: 750;
}
@@ -367,15 +377,15 @@
}
.sentiment-phase-badge.phase-repair {
background: var(--accent-soft);
background: rgb(231, 246, 246);
color: var(--accent);
color: rgb(11, 109, 116);
}
.sentiment-phase-badge.phase-fermentation {
background: var(--up-soft);
background: rgb(237, 246, 234);
color: var(--up);
color: rgb(61, 113, 51);
}
.sentiment-phase-badge.phase-climax {
@@ -874,21 +884,19 @@
min-width: 0px;
min-height: 0px;
min-height: 33px;
display: flex;
flex: 0 0 auto;
flex-direction: column;
flex-direction: row;
justify-content: center;
align-items: center;
align-items: flex-start;
gap: 6px;
gap: 2px;
padding: 0 16px;
padding: 0px 10px 0px 0px;
border: 0px;
}
@@ -916,11 +924,11 @@
}
.overview-strip .sentiment-text {
color: var(--accent);
color: var(--r2-ink);
font-size: var(--fs-caption);
font-size: 12px;
font-weight: 500;
font-weight: 600;
white-space: nowrap;
}
@@ -1029,31 +1037,25 @@
align-items: flex-start;
gap: 14px;
gap: 16px;
padding: 14px 16px 10px;
padding: 14px 16px;
}
.sentiment-current-phase-badge {
min-width: 92px;
min-width: 94px;
flex: 0 0 auto;
display: flex;
display: block;
flex-direction: column;
padding: 10px 18px;
align-items: center;
border: 1px solid rgb(245, 207, 201);
gap: 2px;
border-radius: 10px;
padding: 10px 16px;
border: 1px solid var(--accent);
border-radius: var(--card-radius);
background: var(--accent-soft);
background: var(--r2-up-soft);
text-align: center;
}
@@ -1061,11 +1063,11 @@
.sentiment-current-phase-badge strong {
display: block;
color: var(--accent);
color: var(--r2-up);
font-size: 19px;
font-weight: 700;
font-weight: 800;
line-height: 1.35;
}
@@ -1073,11 +1075,11 @@
.sentiment-current-phase-badge span {
display: block;
margin-top: 0;
margin-top: 2px;
color: var(--text-3);
color: var(--r2-sub);
font-size: var(--fs-aux);
font-size: 11px;
white-space: nowrap;
}
@@ -1089,17 +1091,15 @@
}
.sentiment-phase-info p {
color: var(--text-2);
color: var(--r2-sub);
font-size: var(--fs-label);
font-size: 12.5px;
line-height: 1.6;
line-height: 1.7;
}
.sentiment-phase-info p b {
color: var(--text-1);
font-weight: 600;
font-weight: 700;
}
.sentiment-phase-info p .down {
@@ -1113,17 +1113,17 @@
.sentiment-phase-advice {
margin-top: 8px;
padding: 8px 10px;
padding: 5px 9px;
border-radius: var(--radius-md);
border-radius: 6px;
background: var(--surface-muted);
background: var(--r2-amber-soft);
color: var(--text-2);
color: var(--r2-amber);
font-size: var(--fs-caption);
font-size: 12px;
line-height: 1.55;
line-height: 1.6;
}
.sentiment-feedback-strip {
@@ -1131,47 +1131,45 @@
grid-template-columns: 1fr 1fr;
border-top: 1px solid var(--border);
border-top: 1px solid var(--r2-line-soft);
}
.sentiment-feedback-strip > span {
min-width: 0px;
display: flex;
display: grid;
flex-wrap: wrap;
grid-template-columns: auto 1fr;
align-items: baseline;
gap: 2px 8px;
gap: 2px 6px;
padding: 8px 12px;
padding: 9px 14px;
color: var(--r2-faint);
color: var(--text-3);
font-size: var(--fs-caption);
font-size: 10.5px;
}
.sentiment-feedback-strip > span + span {
border-left: 1px solid var(--border);
border-left: 1px solid var(--r2-line-soft);
}
.sentiment-feedback-strip strong {
color: var(--text-1);
color: var(--r2-ink);
font-size: inherit;
font-size: 11.5px;
font-weight: 600;
text-align: left;
text-align: right;
}
.sentiment-feedback-strip small {
grid-column: 1 / -1;
overflow: hidden;
color: var(--text-3);
color: var(--r2-sub);
font-size: var(--fs-aux);
font-size: 10px;
text-overflow: ellipsis;
@@ -1311,21 +1309,19 @@
.redesigned-sentiment-view .sentiment-history-table td,
.redesigned-sentiment-view .sentiment-history-table th {
height: 40px;
height: auto;
padding: 0 10px;
padding: 8px 10px;
border-bottom: 1px solid var(--r2-line-soft);
}
.redesigned-sentiment-view .sentiment-history-table thead th {
height: 36px;
background: var(--table-header);
color: var(--text-2);
color: var(--r2-sub);
font-size: var(--fs-caption);
font-size: 12px;
font-weight: 600;
}
@@ -1403,17 +1399,17 @@
}
.overview-strip .sentiment-block .sentiment-text {
display: inline-flex;
display: block;
margin: 0px;
padding: 0 6px;
padding: 0px;
font-size: var(--fs-caption);
font-size: 11px;
font-weight: 500;
line-height: 18px;
line-height: 1;
letter-spacing: 0px;
@@ -1482,19 +1478,19 @@
}
.overview-strip[data-overview-expanded="true"] .sentiment-block {
min-height: 0;
min-height: 76px;
justify-content: center;
gap: 2px;
gap: 4px;
padding: 0 16px;
padding: 10px 16px;
flex-direction: column;
flex-direction: row;
align-items: flex-start;
align-items: center;
border-right: 0;
border-right: 1px solid var(--line-soft);
background: transparent;
}
@@ -1632,9 +1628,9 @@
}
:root[data-theme="dark"] #sentimentCycleView .sentiment-phase-badge.phase-fermentation {
background: var(--market-up-soft);
background: var(--market-down-soft);
color: var(--market-up);
color: var(--market-down);
}
:root[data-theme="dark"] #sentimentCycleView .sentiment-phase-badge.phase-climax {
+1 -1
View File
@@ -42,7 +42,7 @@ function renderSentimentHistory() {
empty.hidden = rows.length > 0;
body.innerHTML = [...rows].reverse().map((row) => {
return `
<tr>
<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>
+30 -37
View File
@@ -184,15 +184,15 @@
padding: 9px 13px;
border-bottom-color: var(--border);
border-bottom-color: rgb(231, 235, 239);
}
.theme-directory-item:hover {
background: var(--hover);
background: rgb(241, 246, 251);
}
.theme-directory-item.active {
background: var(--selected);
background: rgb(234, 242, 251);
box-shadow: inset 3px 0 var(--action);
}
@@ -294,10 +294,6 @@
margin: 0px;
color: var(--r2-ink);
font-size: var(--font-size-page-title);
font-weight: var(--font-weight-semibold);
}
.theme-title-v2 > div > span {
@@ -335,7 +331,7 @@
.theme-search-v2 {
width: 250px;
height: 32px;
height: 34px;
display: flex;
@@ -345,9 +341,9 @@
padding: 0px 10px;
border: 1px solid var(--border-strong);
border: 1px solid var(--control-border);
border-radius: 8px;
border-radius: 7px;
background: var(--surface);
@@ -393,15 +389,11 @@
}
.theme-refresh-v2 {
min-height: 32px;
height: 32px;
min-height: 34px;
padding: 0px 12px;
border-radius: 8px;
font-size: var(--font-size-label);
border-radius: 7px;
}
.theme-refresh-v2 .lucide {
@@ -449,17 +441,17 @@
}
.theme-summary-v2 span {
color: var(--text-3);
color: var(--r2-sub);
font-size: var(--font-size-caption);
font-size: 11px;
}
.theme-summary-v2 strong {
color: var(--r2-ink);
font-size: var(--font-size-metric);
font-size: 19px;
font-weight: var(--font-weight-semibold);
font-weight: var(--font-weight-bold);
font-variant-numeric: tabular-nums;
}
@@ -535,9 +527,9 @@
color: var(--r2-ink);
font-size: var(--font-size-card-title);
font-size: 14px;
font-weight: var(--font-weight-semibold);
font-weight: 700;
}
.theme-card-head-v2 span {
@@ -683,11 +675,11 @@
.theme-rank-v2 {
color: var(--r2-faint);
font-size: var(--font-size-aux);
font-size: 10.5px;
font-variant-numeric: tabular-nums;
text-align: left;
text-align: center;
}
.theme-directory-item-v2:nth-child(-n+3) .theme-rank-v2 {
@@ -1036,9 +1028,9 @@
z-index: 2;
height: 36px;
height: 34px;
padding: 0 12px;
padding: 6px 11px;
border-bottom-color: var(--r2-line);
@@ -1046,20 +1038,15 @@
color: var(--r2-sub);
font-size: var(--font-size-caption);
font-size: 10.5px;
}
.theme-members-table-v2 tbody td {
height: 40px;
height: 39px;
padding: 0 12px;
padding: 7px 11px;
font-size: var(--font-size-table);
}
.theme-members-table-v2 thead th:first-child,
.theme-members-table-v2 tbody td:first-child {
text-align: left;
font-size: 11.5px;
}
.theme-members-table-v2 tbody tr {
@@ -1089,6 +1076,10 @@
overflow: hidden;
}
body[data-active-view="themeLibraryView"] .overview-strip {
flex: 0 0 auto;
}
body[data-active-view="themeLibraryView"] #themeLibraryView.active-view {
min-height: 0px;
@@ -1193,9 +1184,11 @@
}
.theme-members-table-v2 tbody td {
height: 40px;
height: 35px;
padding: 0 12px;
padding-top: 5px;
padding-bottom: 5px;
}
}
+1 -1
View File
@@ -46,7 +46,7 @@
<strong id="themeMemberCount">0 只</strong>
</header>
<div class="theme-members-frame-v2 tbl-wrap">
<table class="data-table tbl theme-members-table-v2"><thead><tr><th class="row-number">序号</th><th>股票</th><th class="number num sortable">涨跌幅(%<span class="arr"></span></th><th class="number num sortable">收盘价(元)<span class="arr"></span></th><th class="number num sortable">成交额(亿)<span class="arr"></span></th></tr></thead><tbody id="themeMemberTableBody"></tbody></table>
<table class="data-table tbl theme-members-table-v2"><thead><tr><th class="num">序号</th><th>股票</th><th class="number num sortable">涨跌幅(%<span class="arr"></span></th><th class="number num sortable">收盘价(元)<span class="arr"></span></th><th class="number num sortable">成交额(亿)<span class="arr"></span></th></tr></thead><tbody id="themeMemberTableBody"></tbody></table>
</div>
</section>
</div>
+1 -1
View File
@@ -97,7 +97,7 @@ function renderThemeDetail() {
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 muted">${index + 1}</td>
<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("");
-5
View File
@@ -8,11 +8,6 @@ async function backfillData() {
end_date: document.querySelector("#backfillEnd").value,
});
showToast(`历史回补完成,共处理 ${payload.results.length} 个工作日`);
state.sentimentHistory = null;
state.sentimentHistoryKey = "";
if (state.activeView === "sentimentCycleView") {
await loadSentimentHistory(true);
}
await openAdminSettings(true);
} catch (error) {
showToast(error.message);
+89 -103
View File
@@ -12,7 +12,7 @@
padding: 20px;
background: var(--canvas);
background: rgb(237, 241, 244);
}
.auth-gate[hidden] {
@@ -26,13 +26,13 @@
padding: 26px;
border: 1px solid var(--border-strong);
border: 1px solid var(--line-strong);
border-radius: 12px;
border-radius: 6px;
background: var(--surface);
box-shadow: var(--shadow-raised);
box-shadow: var(--shadow);
}
.auth-brand {
@@ -46,9 +46,7 @@
.auth-brand h1 {
margin: 0px;
font-size: var(--font-size-page-title);
font-weight: var(--font-weight-semibold);
font-size: 22px;
}
.auth-brand span {
@@ -56,9 +54,9 @@
margin-top: 5px;
color: var(--text-3);
color: var(--text-muted);
font-size: var(--font-size-caption);
font-size: 12px;
}
.auth-tabs {
@@ -90,11 +88,11 @@
}
.auth-tab.active {
border-bottom-color: var(--action);
border-bottom-color: var(--coral);
color: var(--text-1);
color: var(--text);
font-weight: var(--font-weight-semibold);
font-weight: 750;
}
.auth-form {
@@ -108,15 +106,15 @@
.auth-form .button {
width: 100%;
min-height: 32px;
min-height: 40px;
}
.auth-error {
margin: 0px;
color: var(--market-up);
color: rgb(185, 54, 39);
font-size: var(--font-size-caption);
font-size: 12px;
line-height: 1.5;
}
@@ -148,23 +146,23 @@
.admin-section-picker select {
width: 100%;
min-height: 32px;
min-height: 40px;
padding: 0px 11px;
border: 1px solid var(--border-strong);
border: 1px solid var(--line-strong);
border-radius: 8px;
border-radius: 5px;
background: var(--surface);
color: var(--text-primary);
font-size: var(--font-size-label);
font-size: 13px;
}
.admin-section-picker select:focus-visible {
outline: 2px solid var(--action);
outline: 2px solid var(--blue);
outline-offset: 2px;
}
@@ -309,13 +307,13 @@
min-width: 0px;
height: 32px;
height: 34px;
padding: 0px 8px;
border: 1px solid var(--border-strong);
border: 1px solid var(--line-strong);
border-radius: 8px;
border-radius: 4px;
background: var(--surface);
}
@@ -388,11 +386,11 @@
}
.connection-status.connected {
border-color: var(--market-down);
border-color: rgb(167, 222, 201);
background: var(--green-soft);
color: var(--market-down);
color: rgb(8, 106, 75);
}
.settings-dialog form {
@@ -472,11 +470,11 @@
}
.account-button > span {
flex: 0 0 auto;
min-width: 0px;
min-width: max-content;
overflow: hidden;
overflow: visible;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -596,7 +594,7 @@
.account-dropdown button {
width: 100%;
min-height: 36px;
min-height: 40px;
display: grid;
@@ -638,7 +636,7 @@
outline: none;
box-shadow: inset 0 0 0 2px var(--focus-ring);
box-shadow: inset 0 0 0 2px var(--focus-ring, #1268c4);
}
.account-dropdown button svg {
@@ -664,16 +662,16 @@
}
.account-dropdown .account-menu-danger {
color: var(--market-up);
color: rgb(181, 58, 66);
grid-template-columns: 18px minmax(0px, 1fr);
}
.account-dropdown .account-menu-danger:focus-visible,
.account-dropdown .account-menu-danger:hover {
color: var(--market-up);
color: rgb(163, 47, 55);
background: var(--market-up-soft);
background: rgb(255, 242, 243);
}
button.account-role-badge {
@@ -687,11 +685,11 @@ button.account-role-badge {
button.account-role-badge:hover {
filter: brightness(0.98);
box-shadow: var(--shadow-card);
box-shadow: rgba(67, 76, 86, 0.14) 0px 3px 10px;
}
button.account-role-badge:focus-visible {
outline: 2px solid var(--action);
outline: 2px solid var(--blue);
outline-offset: 2px;
}
@@ -703,9 +701,9 @@ button.account-role-badge:focus-visible {
}
.admin-role-badge {
color: var(--text-secondary);
color: rgb(83, 103, 124);
background: var(--surface-muted);
background: rgb(244, 247, 250);
}
.vip-role-badge {
@@ -721,7 +719,7 @@ button.account-role-badge:focus-visible {
.vip-role-badge.is-nonmember {
color: rgb(105, 117, 128);
background: var(--surface-muted);
background: rgb(244, 246, 248);
border-color: rgb(203, 211, 218);
@@ -827,7 +825,7 @@ button.account-role-badge:focus-visible {
}
.member-gate strong {
color: var(--text-primary);
color: rgb(60, 70, 80);
font-size: 14px;
}
@@ -879,7 +877,7 @@ button.account-role-badge:focus-visible {
margin-bottom: 12px;
color: var(--text-secondary);
color: rgb(83, 103, 124);
}
.privacy-note svg {
@@ -891,7 +889,7 @@ button.account-role-badge:focus-visible {
margin-top: 3px;
color: var(--market-down);
color: rgb(52, 116, 95);
}
.membership-status-grid {
@@ -907,17 +905,17 @@ button.account-role-badge:focus-visible {
.membership-status-grid > div {
padding: 12px 13px;
border: 1px solid var(--border);
border: 1px solid var(--line, #e2e8ee);
border-radius: 10px;
background: var(--surface-subtle);
background: rgb(251, 252, 253);
}
.membership-status-grid span {
display: block;
color: var(--text-tertiary);
color: rgb(116, 128, 140);
font-size: 11px;
}
@@ -927,7 +925,7 @@ button.account-role-badge:focus-visible {
margin-top: 5px;
color: var(--text-primary);
color: rgb(38, 50, 61);
font-size: 15px;
@@ -937,7 +935,7 @@ button.account-role-badge:focus-visible {
.membership-comparison {
overflow: hidden;
border: 1px solid var(--border);
border: 1px solid var(--line, #e2e8ee);
border-radius: 10px;
@@ -955,7 +953,7 @@ button.account-role-badge:focus-visible {
padding: 9px 11px;
border-top: 1px solid var(--border);
border-top: 1px solid var(--line, #e2e8ee);
}
.membership-comparison > div:first-child {
@@ -963,21 +961,21 @@ button.account-role-badge:focus-visible {
}
.membership-comparison-head {
color: var(--text-secondary);
color: rgb(105, 118, 131);
background: var(--surface-subtle);
background: rgb(247, 249, 251);
font-weight: 700;
}
.membership-comparison b {
color: var(--market-down);
color: rgb(39, 108, 88);
font-weight: 600;
}
.membership-comparison b.muted {
color: var(--text-tertiary);
color: rgb(154, 164, 173);
}
.membership-comparison b.available {
@@ -995,7 +993,7 @@ button.account-role-badge:focus-visible {
margin-top: 14px;
color: var(--text-secondary);
color: rgb(93, 105, 116);
font-size: 12px;
}
@@ -1028,15 +1026,11 @@ button.account-role-badge:focus-visible {
display: grid;
grid-template-columns: minmax(0px, 1fr);
grid-template-columns: auto minmax(0px, 1fr);
}
.account-menu-shell .account-button {
width: 100%;
min-width: 0px;
max-width: none;
}
.account-dropdown {
@@ -1091,7 +1085,7 @@ button.account-role-badge:focus-visible {
}
.account-settings-dialog {
border-radius: 12px;
border-radius: 9px;
}
.settings-dialog {
@@ -1101,7 +1095,7 @@ button.account-role-badge:focus-visible {
overflow-x: hidden;
border-radius: 12px;
border-radius: 9px;
}
.account-dropdown {
@@ -1121,17 +1115,17 @@ button.account-role-badge:focus-visible {
padding: 7px;
background: var(--surface);
background: rgba(255, 255, 255, 0.98);
transform-origin: right top;
animation: account-menu-in 180ms var(--ease-out) both;
border: 1px solid var(--border);
border-color: var(--border);
border-radius: 8px;
border-radius: 7px;
box-shadow: var(--shadow-float);
box-shadow: rgba(22, 34, 46, 0.15) 0px 14px 35px;
}
@media (max-width: 767px) {
@@ -1183,37 +1177,29 @@ button.account-role-badge:focus-visible {
gap: 3px;
}
@media (min-width: 1280px) and (max-width: 1439px) {
.app-header .header-command-group .account-role-badge span {
@media (min-width: 1280px) and (max-width: 1510px) {
.account-role-badge span {
display: none;
}
.app-header .header-command-group .account-role-badge {
min-width: 28px;
.account-role-badge {
min-width: 27px;
justify-content: center;
padding-inline: 6px;
}
}
@media (min-width: 1280px) {
@media (min-width: 1380px) and (max-width: 1510px) {
#settingsButton,
.header-command-group .account-button {
flex: 0 0 auto;
width: auto;
max-width: none;
overflow: visible;
padding: 0px 8px;
padding: 0px 9px;
}
#settingsButton > span,
.header-command-group .account-button > span {
display: inline;
overflow: visible;
}
}
@@ -1228,7 +1214,7 @@ button.account-role-badge:focus-visible {
border-radius: 7px;
background: var(--action);
background: rgb(37, 99, 235);
color: var(--text-inverse);
@@ -1276,11 +1262,11 @@ button.account-role-badge:focus-visible {
}
.account-button {
flex: 0 0 auto;
overflow: hidden;
overflow: visible;
text-overflow: ellipsis;
max-width: none;
max-width: 108px;
min-height: 28px;
@@ -1322,17 +1308,17 @@ button.account-role-badge:focus-visible {
.settings-dialog:not(.heaven-reading-dialog) .settings-section-heading h3 {
margin: 0px;
color: var(--text-primary);
color: rgb(39, 52, 67);
font-size: var(--font-size-card-title);
font-size: 14px;
font-weight: var(--font-weight-semibold);
font-weight: 720;
}
.settings-dialog:not(.heaven-reading-dialog) .settings-section-heading > span {
color: var(--text-tertiary);
color: rgb(124, 135, 149);
font-size: var(--font-size-aux);
font-size: 10.5px;
}
.settings-dialog:not(.heaven-reading-dialog) .form-field {
@@ -1341,7 +1327,7 @@ button.account-role-badge:focus-visible {
.settings-dialog:not(.heaven-reading-dialog) .form-field > label,
.settings-dialog:not(.heaven-reading-dialog) .form-field > span {
color: var(--text-secondary);
color: rgb(78, 91, 106);
font-size: 11px;
@@ -1349,7 +1335,7 @@ button.account-role-badge:focus-visible {
}
.settings-dialog:not(.heaven-reading-dialog) :is(input, select, textarea) {
border-color: var(--border-strong);
border-color: rgb(213, 220, 229);
border-radius: 7px;
}
@@ -1389,7 +1375,7 @@ button.account-role-badge:focus-visible {
}
.account-settings-dialog .settings-lead {
color: var(--text-secondary);
color: rgb(96, 109, 124);
font-size: 12px;
@@ -1411,7 +1397,7 @@ button.account-role-badge:focus-visible {
padding: 11px 12px;
border-color: var(--border-strong);
border-color: rgb(223, 228, 234);
border-radius: 7px;
@@ -1439,7 +1425,7 @@ button.account-role-badge:focus-visible {
}
.account-settings-dialog .membership-comparison {
border-color: var(--border-strong);
border-color: rgb(223, 228, 234);
border-radius: 7px;
}
@@ -1449,7 +1435,7 @@ button.account-role-badge:focus-visible {
padding: 8px 11px;
border-color: var(--border);
border-color: rgb(230, 234, 239);
}
.account-settings-dialog .membership-comparison-head {
@@ -1463,11 +1449,11 @@ button.account-role-badge:focus-visible {
.account-settings-dialog .privacy-note {
padding: 10px 11px;
border: 1px solid var(--market-down);
border: 1px solid rgb(220, 232, 226);
border-radius: 7px;
background: var(--market-down-soft);
background: rgb(245, 250, 247);
}
.account-settings-dialog .account-birth-form {
@@ -1489,7 +1475,7 @@ button.account-role-badge:focus-visible {
border-radius: 7px;
color: var(--text-secondary);
color: rgb(102, 115, 132);
font-size: 11px;
}
@@ -1503,7 +1489,7 @@ button.account-role-badge:focus-visible {
border-bottom: 1px solid var(--dialog-line);
background: var(--surface-subtle);
background: rgb(251, 252, 253);
}
.admin-dialog .admin-section-picker label {
@@ -1511,13 +1497,13 @@ button.account-role-badge:focus-visible {
}
.admin-dialog .admin-section-picker select {
min-height: 32px;
min-height: 36px;
border-color: var(--border-strong);
border-color: rgb(212, 220, 229);
border-radius: 8px;
border-radius: 7px;
font-size: var(--font-size-label);
font-size: 12px;
}
.admin-dialog .admin-panel {
@@ -1649,7 +1635,7 @@ button.account-role-badge:focus-visible {
}
:root[data-theme="dark"] :is(.loading-overlay, .auth-gate) {
background: var(--backdrop);
background: color-mix(in srgb, var(--canvas) 92%, transparent);
}
:root[data-theme="dark"] :is(.auth-shell) {
+17 -41
View File
@@ -1,15 +1,11 @@
/* Canonical CSS owner: cards. Historical layers consolidated 2026-08-02. */
.stock-name {
font-size: var(--fs-table);
font-weight: 600;
font-weight: 700;
}
.stock-code {
color: var(--text-3);
font-size: var(--fs-aux);
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
@@ -53,37 +49,25 @@
}
.streak-pill {
background: var(--coral-soft);
color: var(--coral);
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
min-width: 30px;
padding: 2px 8px;
height: 20px;
padding: 0 7px;
border-radius: 999px;
background: var(--up-soft);
color: var(--up);
font-size: var(--fs-caption);
font-weight: 600;
border-radius: 4px;
white-space: nowrap;
}
.streak-pill.high {
background: var(--up);
color: #fff;
}
.outcome-tag {
display: inline-flex;
@@ -243,7 +227,7 @@
font-size: var(--font-size-card-title);
font-weight: var(--font-weight-semibold);
font-weight: var(--font-weight-bold);
}
.card-h .sub {
@@ -269,29 +253,21 @@
}
.sname {
font-weight: 600;
font-weight: 700;
font-size: var(--fs-table);
font-size: 13px;
}
.scode {
font-size: var(--fs-aux);
font-size: 11px;
color: var(--text-3);
color: var(--faint);
margin-left: 0;
margin-left: 6px;
font-weight: 400;
}
.stock-cell {
display: flex;
flex-direction: column;
line-height: 1.3;
}
.muted {
color: var(--faint);
}
+4 -6
View File
@@ -635,16 +635,14 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
height: 14px;
}
@media (min-width: 1280px) {
.header-command-group .command-button:not(.account-button) {
width: 32px;
min-width: 32px;
@media (min-width: 1280px) and (max-width: 1510px) {
.header-command-group .command-button {
width: 31px;
padding: 0px;
}
.header-command-group .command-button:not(.account-button) > span {
.header-command-group .command-button > span {
display: none;
}
}
+26 -28
View File
@@ -10,9 +10,7 @@
.dialog-header h2 {
margin: 2px 0px 0px;
font-size: 16px;
font-weight: var(--font-weight-semibold);
font-size: 19px;
letter-spacing: 0px;
}
@@ -38,9 +36,9 @@
}
.admin-dialog .model-row {
border-color: var(--border);
border-color: rgb(223, 228, 234);
border-radius: 8px;
border-radius: 7px;
background: var(--surface-subtle);
}
@@ -242,11 +240,11 @@
}
.alert-button.has-alerts {
border-color: var(--warning-color);
border-color: rgb(214, 180, 93);
background: var(--warning-soft);
background: rgb(255, 249, 233);
color: var(--warning-color);
color: rgb(138, 97, 0);
}
.alert-badge {
@@ -528,17 +526,17 @@
}
.assistant-message.user .assistant-message-content {
border-color: var(--color-action-line);
border-color: rgb(190, 210, 235);
background: var(--action-soft);
}
.assistant-message.is-error .assistant-message-content {
border-color: var(--market-up);
border-color: rgb(239, 197, 200);
background: var(--market-up-soft);
color: var(--market-up);
color: rgb(139, 47, 52);
}
.assistant-message-content p {
@@ -780,7 +778,7 @@
gap: 16px;
padding: 20px;
padding: 14px 18px;
min-height: 62px;
@@ -788,7 +786,7 @@
}
.assistant-dialog {
border-radius: 12px;
border-radius: 9px;
width: min(820px, -32px + 100vw);
@@ -798,9 +796,9 @@
}
.dialog-eyebrow {
color: var(--text-3);
color: var(--text-muted);
font-size: var(--font-size-caption);
font-size: 12px;
}
.global-search-dialog {
@@ -808,7 +806,7 @@
border-radius: 8px;
box-shadow: var(--shadow-float);
box-shadow: rgba(26, 37, 47, 0.22) 0px 22px 70px, rgba(26, 37, 47, 0.1) 0px 3px 14px;
max-width: 700px;
@@ -894,13 +892,13 @@
font: 11px / 1.2 ui-monospace, SFMono-Regular, Consolas, monospace;
border-color: var(--border-strong);
border-color: rgb(214, 221, 229);
border-radius: 5px;
background: var(--surface-subtle);
background: rgb(247, 248, 250);
color: var(--text-tertiary);
color: rgb(125, 136, 150);
}
.global-search-results {
@@ -922,7 +920,7 @@
padding: 9px 10px 5px;
color: var(--text-tertiary);
color: rgb(135, 146, 160);
font-size: 10px;
}
@@ -959,9 +957,9 @@
.global-search-result.is-active,
.global-search-result:hover {
background: var(--action-soft);
background: rgb(238, 244, 255);
color: var(--action);
color: rgb(31, 85, 165);
}
.global-search-result-icon {
@@ -975,7 +973,7 @@
color: var(--text-muted);
border-color: var(--border);
border-color: rgb(224, 229, 235);
border-radius: 7px;
@@ -1005,7 +1003,7 @@
border-color: var(--dialog-line);
background: var(--surface-subtle);
background: rgb(251, 252, 253);
}
.alerts-dialog .alert-form {
@@ -1079,7 +1077,7 @@
padding: 16px 18px;
background: var(--surface-subtle);
background: rgb(247, 249, 251);
}
.assistant-dialog .assistant-message {
@@ -1089,9 +1087,9 @@
}
.assistant-dialog .assistant-message-content {
border-color: var(--border);
border-color: rgb(220, 227, 235);
border-radius: 8px;
border-radius: 7px;
font-size: 12.5px;
@@ -1199,7 +1197,7 @@
z-index: 95;
box-shadow: var(--shadow-float);
box-shadow: rgba(0, 0, 0, 0.12) -8px 0px 24px;
transition: right 0.25s;
@@ -52,21 +52,21 @@
}
.segmented {
height: var(--control-height);
height: 34px;
display: flex;
overflow: hidden;
min-height: var(--control-height);
min-height: 36px;
padding: 2px;
border: 0px;
border-radius: var(--radius-md);
border-radius: 7px;
background: var(--surface-muted);
background: rgb(236, 239, 244);
}
.segment {
@@ -82,11 +82,11 @@
border: 0px;
border-radius: 6px;
border-radius: 5px;
color: var(--text-secondary);
font-size: var(--font-size-label);
font-size: 11.5px;
}
.segment.active {
+20 -55
View File
@@ -14,13 +14,13 @@
.data-table th.sort-asc::after {
content: " ↑";
color: var(--accent);
color: var(--blue);
}
.data-table th.sort-desc::after {
content: " ↓";
color: var(--accent);
color: var(--blue);
}
.data-table tbody tr td {
@@ -48,7 +48,7 @@
color: var(--text-muted);
text-align: left;
text-align: center;
}
.table-action {
@@ -114,9 +114,9 @@
text-align: left;
height: 40px;
height: 42px;
padding: 0 12px;
padding: 8px 11px;
border-right: 0px;
@@ -214,9 +214,7 @@
.data-table .stock-name {
color: var(--text-primary);
font-size: var(--fs-table);
font-weight: 600;
font-weight: 700;
}
.main-grid > .table-frame {
@@ -244,7 +242,7 @@
}
.data-table thead th {
padding: 0 12px;
padding: 0px 11px;
border-bottom: 1px solid var(--border);
@@ -258,31 +256,23 @@
height: 36px;
font-size: var(--font-size-caption);
font-size: var(--font-size-label);
}
.data-table tbody td {
padding: 0 12px;
border-bottom: 1px solid var(--border);
padding: 6px 11px;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-primary);
height: 40px;
}
.data-table thead tr:last-child th:first-child,
.data-table tbody td:first-child,
.tbl thead tr:last-child th:first-child,
.tbl tbody td:first-child {
text-align: left;
}
.tbl .row-number {
text-align: left;
height: 41px;
}
.main-grid .data-table thead th {
height: 36px;
height: 34px;
padding: 0 12px;
padding: 6px 10px;
}
.main-grid .data-table tbody td {
@@ -310,11 +300,11 @@
font-weight: 600;
font-size: var(--font-size-caption);
font-size: 12px;
text-align: left;
padding: 0 12px;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
@@ -341,30 +331,18 @@
margin-left: 3px;
}
.tbl thead th.sorted,
.data-table thead th.sorted,
.data-table thead th.sort-asc,
.data-table thead th.sort-desc {
color: var(--accent);
}
.tbl thead th.sorted .arr,
.data-table thead th.sorted .arr {
color: var(--accent);
.tbl thead th.sorted .arr {
color: var(--blue);
}
.tbl tbody td {
padding: 0 12px;
padding: 9px 12px;
border-bottom: 1px solid var(--line-soft);
white-space: nowrap;
vertical-align: middle;
height: 40px;
font-size: var(--font-size-table);
}
.tbl tbody tr:hover {
@@ -437,19 +415,6 @@
color: var(--text-secondary);
}
:root[data-theme="dark"] :is(
.tbl thead th.sorted,
.data-table thead th.sorted,
.data-table thead th.sort-asc,
.data-table thead th.sort-desc
) {
color: var(--accent);
}
:root[data-theme="dark"] :is(.tbl thead th.sorted .arr, .data-table thead th.sorted .arr) {
color: var(--accent);
}
:root[data-theme="dark"] :is(table tbody td, .data-table tbody td, .tbl tbody td) {
border-color: var(--line-soft);
+2 -28
View File
@@ -89,10 +89,7 @@ function renderDashboard() {
const { meta, overview, ladders, sectors } = state.dashboard;
animateMetric("tapeUp", overview.up_count, (value) => Math.round(value));
animateMetric("tapeDown", overview.down_count, (value) => Math.round(value));
animateMetric("tapeLimit", overview.limit_up_count, (value) => `${Math.round(value)}`);
animateMetric("tapeLimitDown", overview.limit_down_count, (value) => `${Math.round(value)}`);
animateMetric("detailBroken", overview.broken_count, (value) => `${Math.round(value)}`);
animateMetric("detailSealRate", overview.seal_rate, (value) => `${formatNumber(value, 1)}%`);
setText("tapeLimit", `${overview.limit_up_count} / 跌停 ${overview.limit_down_count}`);
animateMetric("tapeAmount", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`);
animateMetric("limitUpMetric", overview.limit_up_count, (value) => `${Math.round(value)}`);
animateMetric("limitDownMetric", overview.limit_down_count, (value) => `${Math.round(value)}`);
@@ -101,30 +98,7 @@ function renderDashboard() {
animateMetric("amountMetric", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`);
setText("dataDateMetric", dashboardDataTimestamp(meta));
animateMetric("sentimentScore", overview.sentiment_score, (value) => Math.round(value));
animateMetric("detailSentimentScore", overview.sentiment_score, (value) => Math.round(value));
const moodLabel = sentimentLabel(overview.sentiment_score);
setText("sentimentText", moodLabel);
setText("detailSentimentText", moodLabel);
document.querySelectorAll("#sentimentText, #detailSentimentText").forEach((sentimentChip) => {
sentimentChip.classList.toggle("is-hot", moodLabel === "情绪高涨");
sentimentChip.classList.toggle("is-strong", moodLabel === "情绪偏强");
sentimentChip.classList.toggle("is-cold", moodLabel === "情绪冰点" || moodLabel === "情绪偏弱");
});
const pageSubtitle = document.querySelector("#currentPageSubtitle");
const activePage = window.XiaobaiPages?.get(state.activeView);
if (pageSubtitle) {
const dateText = displayCompactDate(meta.trade_date);
if (activePage?.id === "mentorView") {
pageSubtitle.textContent = dateText === "--"
? "与不同交易思维模型持续对话 · 数据日期 --"
: `与不同交易思维模型持续对话 · 数据日期 ${dateText}`;
} else {
const groupLabel = activePage?.group === "market"
? "市场复盘"
: activePage?.group === "personal" ? "个人" : "智能工具";
pageSubtitle.textContent = dateText === "--" ? groupLabel : `${groupLabel} · ${dateText}`;
}
}
setText("sentimentText", sentimentLabel(overview.sentiment_score));
updateSentimentGauge(overview.sentiment_score);
setText("updatedAt", `${dashboardSourceLabel(meta)} · 更新 ${formatTimestamp(meta.updated_at)}`);
+79 -612
View File
@@ -187,6 +187,12 @@ body.sidebar-collapsed .nav-group + .nav-group {
.metric {
border-top: 1px solid var(--line);
}
.metric-wide {
display: flex;
grid-column: span 2;
}
}
@media (max-width: 860px) {
@@ -222,6 +228,10 @@ body.sidebar-collapsed .nav-group + .nav-group {
grid-template-columns: repeat(3, 1fr);
}
.metric-wide {
grid-column: auto;
}
.metric {
padding: 10px;
}
@@ -252,6 +262,10 @@ body.sidebar-collapsed .nav-group + .nav-group {
grid-template-columns: repeat(2, 1fr);
}
.metric-wide {
grid-column: 1 / -1;
}
.section-title-group {
align-items: flex-start;
@@ -297,6 +311,10 @@ body.sidebar-collapsed .nav-group + .nav-group {
.overview-strip {
grid-template-columns: minmax(210px, 1.3fr) repeat(5, minmax(90px, 0.7fr));
}
.overview-strip .metric-wide {
display: none;
}
}
@media (max-width: 767px) {
@@ -626,6 +644,10 @@ body.sidebar-collapsed .app-main {
padding: 0px 8px;
}
.overview-strip .metric-wide {
display: none;
}
.overview-strip .metric:nth-of-type(n+4) {
display: none;
}
@@ -640,6 +662,14 @@ body.sidebar-collapsed .app-main {
padding: 8px;
}
.overview-strip[data-overview-expanded="true"] .metric-wide {
min-height: 54px;
display: flex;
border-bottom: 1px solid var(--border);
}
.overview-strip[data-overview-expanded="true"] .metric:nth-of-type(n) {
min-height: 54px;
@@ -740,6 +770,10 @@ body.sidebar-collapsed .app-main {
display: flex;
}
.overview-strip .metric-wide {
display: none;
}
.overview-strip .metric:nth-of-type(n+5) {
display: none;
}
@@ -858,7 +892,7 @@ body.sidebar-collapsed .app-main {
padding: 0px 10px;
border-radius: var(--radius-md);
border-radius: 7px;
font-size: 12.5px;
@@ -990,8 +1024,8 @@ body.sidebar-collapsed .app-main {
grid-template-columns: 176px minmax(0px, 1fr) auto;
}
.app-header .market-tape {
display: flex;
.market-tape {
display: none;
}
.module-nav {
@@ -1390,19 +1424,19 @@ body.sidebar-collapsed .module-nav .nav-group + .nav-group {
padding: 12px 10px 4px;
color: var(--text-3);
color: var(--r2-faint);
font-size: var(--fs-aux);
font-size: 11px;
font-weight: 600;
font-weight: 400;
letter-spacing: .04em;
letter-spacing: 0px;
}
.module-nav .module-tab {
width: 100%;
min-height: 36px;
min-height: 34px;
display: flex;
@@ -1418,13 +1452,13 @@ body.sidebar-collapsed .module-nav .nav-group + .nav-group {
border: 0px;
border-radius: var(--radius-md);
border-radius: 7px;
background: transparent;
color: var(--text-2);
color: var(--text-secondary);
font-size: var(--fs-table);
font-size: 13px;
font-weight: 400;
}
@@ -1442,23 +1476,23 @@ body.sidebar-collapsed .module-nav .nav-group + .nav-group {
}
.module-nav .module-tab:hover {
background: var(--hover);
background: var(--surface-hover);
color: var(--text-1);
color: var(--r2-ink);
}
.module-nav .module-tab.active {
background: var(--selected);
background: var(--r2-blue-soft);
color: var(--accent);
color: var(--r2-blue);
font-weight: 600;
}
.module-nav .module-tab.mobile-active {
background: var(--selected);
background: var(--r2-blue-soft);
color: var(--accent);
color: var(--r2-blue);
font-weight: 600;
}
@@ -1722,11 +1756,7 @@ body.sidebar-collapsed .module-nav .module-tab {
padding: 0px 16px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
overflow: hidden;
border-top: 0px;
@@ -1745,10 +1775,6 @@ body.sidebar-collapsed .module-nav .module-tab {
box-shadow: none;
}
.overview-strip::-webkit-scrollbar {
display: none;
}
.overview-strip .row {
display: flex;
@@ -1758,8 +1784,6 @@ body.sidebar-collapsed .module-nav .module-tab {
padding: 7px var(--page-pad-x);
flex-shrink: 0;
font-size: 12px;
color: var(--sub);
@@ -1974,7 +1998,7 @@ body.sidebar-collapsed .module-nav .module-tab {
width: auto;
min-height: var(--statusbar-height);
min-height: 30px;
height: var(--statusbar-height);
@@ -1988,11 +2012,11 @@ body.sidebar-collapsed .module-nav .module-tab {
border-top: 1px solid var(--line);
background: var(--header-bg);
background: var(--surface);
color: var(--text-3);
color: var(--faint);
font-size: var(--fs-aux);
font-size: 11.5px;
}
body.sidebar-collapsed .status-bar {
@@ -2134,7 +2158,7 @@ body.sidebar-collapsed .status-bar {
width: var(--sidebar-width);
background: var(--header-bg);
background: var(--surface);
border-right: 1px solid var(--line);
@@ -2242,15 +2266,7 @@ body.sidebar-collapsed .status-bar {
.main {
margin-left: var(--sidebar-width);
min-width: 0;
overflow-x: hidden;
}
@media (min-width: 1280px) {
.main {
min-width: 1080px;
}
min-width: 1080px;
}
.app-header {
@@ -2260,7 +2276,7 @@ body.sidebar-collapsed .status-bar {
width: auto;
min-height: var(--topbar-height);
min-height: 46px;
box-shadow: none;
@@ -2272,17 +2288,17 @@ body.sidebar-collapsed .status-bar {
z-index: 50;
background: var(--header-bg);
background: var(--surface);
border-bottom: 1px solid var(--border);
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
gap: var(--header-cluster-gap);
gap: 14px;
padding: 0 var(--header-pad-x);
padding: 0 var(--page-pad-x);
height: var(--topbar-height);
}
@@ -2396,7 +2412,7 @@ body.sidebar-collapsed .status-bar {
overflow: hidden;
}
body:is([data-active-view="sentimentCycleView"], [data-active-view="yesterdayView"]) .app-main .overview-strip {
body:is([data-active-view="sentimentCycleView"], [data-active-view="yesterdayView"]) .overview-strip {
flex: 0 0 auto;
}
@@ -2412,7 +2428,7 @@ body.sidebar-collapsed .status-bar {
overflow: hidden;
}
body:is([data-active-view="auctionView"], [data-active-view="themeLibraryView"], [data-active-view="popularityView"], [data-active-view="mentorView"], [data-active-view="rotationView"]) .app-main .overview-strip {
body:is([data-active-view="auctionView"], [data-active-view="themeLibraryView"], [data-active-view="popularityView"], [data-active-view="mentorView"], [data-active-view="rotationView"]) .overview-strip {
flex: 0 0 auto;
}
@@ -2462,8 +2478,8 @@ body.sidebar-collapsed .status-bar {
padding: var(--mobile-shell-pad);
}
.app-header .market-tape {
display: flex;
.market-tape {
display: none;
}
.header-actions {
@@ -2685,6 +2701,10 @@ body.sidebar-collapsed .status-bar {
padding: 0px;
}
.overview-strip .metric-wide {
display: none;
}
.overview-strip .metric:nth-of-type(n+4) {
display: none;
}
@@ -2710,20 +2730,10 @@ body.sidebar-collapsed .status-bar {
color: var(--text-primary);
}
:root[data-theme="dark"] :is(.module-nav, .app-header, .status-bar) {
:root[data-theme="dark"] :is(.module-nav, .app-header, .overview-strip, .status-bar) {
border-color: var(--border);
background: var(--header-bg);
color: var(--text-primary);
box-shadow: none;
}
:root[data-theme="dark"] .overview-strip {
border-color: var(--border);
background: transparent;
background: var(--surface);
color: var(--text-primary);
@@ -2731,7 +2741,7 @@ body.sidebar-collapsed .status-bar {
}
:root[data-theme="dark"] .app-header {
background: var(--header-bg);
background: color-mix(in srgb, var(--surface) 96%, transparent);
}
:root[data-theme="dark"] :is(.sidebar-brand, .module-nav .sidebar-brand, .nav-group, .sidebar-collapse-button, .header-date-group) {
@@ -3009,16 +3019,10 @@ body.sidebar-collapsed .status-bar {
body.mobile-shell .header-command-group > .command-button,
body.mobile-shell .header-command-group .account-button {
width: 100%;
max-width: none;
min-height: var(--mobile-touch-size);
justify-content: flex-start;
}
body.mobile-shell .header-command-group .account-button > span,
body.mobile-shell .header-command-group .account-role-badge span {
display: inline;
}
body.mobile-shell .header-command-group .account-dropdown {
position: static;
grid-column: 1 / -1;
@@ -3184,6 +3188,11 @@ body.sidebar-collapsed .status-bar {
display: flex;
}
body.mobile-shell .overview-strip .metric-wide,
body.mobile-shell .overview-toggle {
display: none;
}
body.mobile-shell .workspace-view,
body.mobile-shell .workspace-view.page:not(#heavenView),
body.mobile-shell:is([data-active-view]) .workspace-view.active-view {
@@ -3213,545 +3222,3 @@ body.sidebar-collapsed .status-bar {
display: none;
}
}
/* Generic page title slot in the shell header. Kept hidden so each page owns
its own in-view title header (mentor renders one inside #mentorView). */
.app-page-context {
min-width: var(--header-page-context-min);
max-width: var(--header-page-context-max);
flex: 0 1 auto;
flex-direction: column;
justify-content: center;
gap: 2px;
overflow: hidden;
display: flex;
}
.app-header .app-page-context,
.main .app-header .app-page-context {
min-width: var(--header-page-context-min);
max-width: var(--header-page-context-max);
overflow: hidden;
}
.main .app-header .app-page-context span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
#themeModeText {
display: none;
}
.app-page-context strong {
overflow: hidden;
color: var(--text-1);
font-size: var(--fs-page-title);
font-weight: 600;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-page-context span {
overflow: hidden;
color: var(--text-3);
font-size: var(--fs-caption);
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Canonical B-147 merged header. */
.app-header .overview-strip,
.app-header .overview-strip[data-overview-expanded="true"] {
position: static;
display: flex;
flex: 1 0 auto;
align-items: stretch;
width: auto;
height: auto;
min-width: max-content;
min-height: 0;
margin: 0;
padding: 0;
overflow: visible;
border: 0;
background: transparent;
box-shadow: none;
}
.app-header .market-tape {
display: flex;
flex: 1 0 auto;
align-items: center;
min-width: max-content;
gap: 0;
overflow: visible;
white-space: nowrap;
}
.tape-metric {
position: relative;
display: flex;
flex: 0 0 auto;
flex-direction: column;
justify-content: center;
gap: 2px;
min-width: max-content;
padding: 0 var(--header-tape-metric-pad-x);
}
.app-header .tape-metric,
.app-header .tape-sentiment.sentiment-block {
flex: 0 0 auto;
min-width: max-content;
}
.tape-metric + .tape-metric::before,
.tape-metric + .overview-toggle::before {
content: "";
position: absolute;
top: 50%;
left: 0;
width: 1px;
height: 26px;
background: var(--border);
transform: translateY(-50%);
}
.tape-metric .metric-label {
color: var(--text-3);
font-size: var(--fs-aux);
}
.tape-metric-value,
.tape-metric .metric-value {
display: flex;
align-items: center;
gap: 5px;
overflow: visible;
color: var(--text-1);
font-size: 15px;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1.2;
white-space: nowrap;
}
.tape-metric .metric-value.up,
.tape-detail-item .up {
color: var(--up);
}
.tape-metric .metric-value.down,
.tape-detail-item .down {
color: var(--down);
}
.tape-metric .metric-value.warning,
.tape-detail-item .warning {
color: var(--warn);
}
@media (min-width: 1440px) and (max-width: 1599px) {
.app-header .overview-strip .tape-sentiment.sentiment-block {
flex-direction: row;
align-items: center;
gap: 8px;
}
}
.app-header .overview-strip .tape-sentiment .metric-label,
.app-header .overview-strip .tape-sentiment .sentiment-text {
font-size: var(--fs-caption);
font-weight: 500;
line-height: 18px;
}
.app-header .overview-strip .tape-sentiment > .sentiment-gauge {
position: absolute;
display: none;
width: 0;
height: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.tape-sentiment .sentiment-text,
.tape-detail-item .sentiment-text {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: var(--radius-sm);
background: var(--accent-soft);
color: var(--accent);
font-size: var(--fs-caption);
font-style: normal;
font-weight: 500;
line-height: 18px;
}
.tape-sentiment .sentiment-text.is-hot,
.tape-detail-item .sentiment-text.is-hot {
background: var(--up-soft);
color: var(--up);
}
.tape-sentiment .sentiment-text.is-strong,
.tape-detail-item .sentiment-text.is-strong {
background: var(--accent-soft);
color: var(--accent);
}
.tape-sentiment .sentiment-text.is-cold,
.tape-detail-item .sentiment-text.is-cold {
background: var(--down-soft);
color: var(--down);
}
.app-header .overview-toggle {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
min-height: 28px;
margin-left: var(--header-tape-toggle-gap);
padding: 0 10px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-2);
font-size: var(--fs-label);
}
.app-header .overview-toggle:hover {
background: var(--hover);
color: var(--text-1);
}
.app-header .overview-toggle .lucide {
width: 14px;
height: 14px;
}
.tape-detail {
position: absolute;
top: calc(var(--topbar-height) + 1px);
right: 0;
left: 0;
z-index: 45;
display: none;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0 16px;
padding: 14px 20px 16px;
border-bottom: 1px solid var(--border);
background: var(--header-bg);
box-shadow: var(--shadow-raised);
}
.overview-strip[data-overview-expanded="true"] .tape-detail {
display: grid;
}
.tape-detail-item {
display: flex;
flex-direction: column;
gap: 3px;
padding: 8px 14px;
border-radius: var(--radius-md);
}
.tape-detail-item:hover {
background: var(--hover);
}
.tape-detail-date,
.overview-strip[data-overview-expanded="true"] .tape-detail-date {
display: flex;
flex-direction: column;
}
.tape-detail-item span {
color: var(--text-3);
font-size: var(--fs-aux);
}
.tape-detail-item strong {
color: var(--text-1);
font-size: 16px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.app-header .header-actions {
position: relative;
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--header-action-gap);
margin-left: 0;
overflow: visible;
}
.app-header .header-date-group {
height: 32px;
min-height: 32px;
gap: 2px;
margin-right: 6px;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.app-header .header-date-group .icon-button,
.app-header .header-actions > .icon-button {
width: 32px;
min-width: 32px;
height: 32px;
min-height: 32px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-2);
box-shadow: none;
}
.app-header .header-actions > .icon-button:hover,
.app-header .header-date-group .icon-button:hover {
background: var(--hover);
color: var(--text-1);
}
.app-header .header-date-group .date-input {
width: auto;
height: 32px;
padding: 0 10px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text-1);
font-size: var(--fs-label);
font-variant-numeric: tabular-nums;
}
.app-header .header-actions > .icon-button .lucide,
.app-header .header-date-group .lucide {
width: 16px;
height: 16px;
}
.app-header .alert-button {
position: relative;
}
.app-header .alert-badge {
position: absolute;
top: 5px;
right: 5px;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 7px;
background: var(--up);
color: #fff;
font-size: 10px;
font-weight: 600;
line-height: 14px;
text-align: center;
}
#heavenView.workspace-view,
#heavenReadingDialog {
--color-action-base: #2563eb;
--color-action: #2563eb;
--color-action-base-hover: #1d4ed8;
--color-action-hover: #1d4ed8;
--color-action-base-soft: #eff4ff;
--color-action-soft: #eff4ff;
--action: #2563eb;
--action-hover: #1d4ed8;
--action-soft: #eff4ff;
--accent: #2563eb;
--primary: #2563eb;
--blue: #2563eb;
--r2-blue: #2563eb;
--focus-ring: rgba(37, 99, 235, .16);
--text-primary: #1f2937;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--border: #e5e7eb;
--border-strong: #d1d5db;
--surface-hover: #f8faff;
--surface-selected: #eff4ff;
--size-radius-sm: 5px;
--size-radius-md: 7px;
}
:root[data-theme="dark"] #heavenView.workspace-view,
:root[data-theme="dark"] #heavenReadingDialog {
--action: #6ca8e8;
--action-hover: #8bbcf0;
--action-soft: #23364a;
--accent: #6ca8e8;
--primary: #6ca8e8;
--blue: #6ca8e8;
--r2-blue: #6ca8e8;
--canvas: #121416;
--surface: #1b1e21;
--surface-muted: #202428;
--border: #343a40;
--border-strong: #474f57;
--text-primary: #e8eaed;
--text-secondary: #adb5bd;
--text-tertiary: #7f8993;
--market-up: #f06d73;
--market-up-soft: #40262a;
--focus-ring: rgba(108, 168, 232, .22);
}
@media (min-width: 1024px) and (max-width: 1599px) {
.tape-optional {
display: none;
}
}
@media (max-width: 1440px) {
.app-page-context {
display: none;
}
}
@media (max-width: 1023px) {
.app-header {
gap: 8px;
height: var(--mobile-header-height);
min-height: var(--mobile-header-height);
padding: 0 10px;
}
.app-header .overview-strip,
.app-header .overview-strip[data-overview-expanded="true"] {
flex: 0 0 auto;
width: auto;
}
.app-header .market-tape .tape-metric {
display: none;
}
.app-header .overview-toggle {
width: var(--mobile-touch-size);
min-width: var(--mobile-touch-size);
height: var(--mobile-touch-size);
min-height: var(--mobile-touch-size);
margin-left: 0;
justify-content: center;
}
.app-header .overview-toggle span {
display: none;
}
.tape-detail,
.overview-strip[data-overview-expanded="true"] .tape-detail {
top: calc(var(--mobile-header-height) + 1px);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tape-detail-date,
.overview-strip[data-overview-expanded="true"] .tape-detail-date {
display: flex;
flex-direction: column;
}
}
@media (max-width: 767px) {
body.mobile-shell .app-header .overview-strip,
body.mobile-shell .app-header .overview-strip[data-overview-expanded="true"] {
width: auto;
height: auto;
min-height: 0;
display: flex;
margin: 0;
padding: 0;
overflow: visible;
border: 0;
background: transparent;
}
body.mobile-shell .app-header .market-tape {
display: flex;
overflow: visible;
}
body.mobile-shell .app-header .overview-toggle {
display: inline-flex;
}
}
@media (min-width: 768px) {
.app-header .header-menu-button {
display: none;
}
.app-header .header-command-group {
position: static;
display: flex;
flex: 0 0 auto;
flex-direction: row;
align-items: center;
gap: var(--header-command-gap);
width: auto;
max-width: none;
padding: 0;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.app-header .header-command-group .command-button,
.app-header .header-command-group .account-button {
width: auto;
justify-content: center;
}
.app-header .header-command-group .account-menu-shell {
display: inline-flex;
flex-direction: row;
align-items: center;
width: auto;
}
.app-header .header-command-group .account-role-badges {
display: inline-flex;
flex-wrap: nowrap;
min-height: 32px;
}
.app-header .header-command-group .account-dropdown {
left: auto;
right: 0;
}
}
body[data-active-view="heavenView"] .app-header .overview-strip,
body[data-active-view="heavenView"] .app-header .overview-strip[data-overview-expanded="true"],
body.mobile-shell[data-active-view="heavenView"] .app-header .overview-strip,
body.mobile-shell[data-active-view="heavenView"] .app-header .overview-strip[data-overview-expanded="true"] {
display: none;
flex: 0 0 0;
width: 0;
min-width: 0;
height: 0;
min-height: 0;
overflow: hidden;
}
+15 -56
View File
@@ -22,19 +22,12 @@
const button = document.querySelector("#headerMenuButton");
const backdrop = document.querySelector("#mobileCommandBackdrop");
if (!menu || !button) return;
if (!isMobileViewport()) {
menu.classList.remove("is-open");
button.setAttribute("aria-expanded", "false");
document.body.classList.remove("mobile-command-open");
if (backdrop) backdrop.hidden = true;
return;
}
const open = typeof force === "boolean" ? force : !menu.classList.contains("is-open");
menu.classList.toggle("is-open", open);
button.setAttribute("aria-expanded", String(open));
document.body.classList.toggle("mobile-command-open", open);
if (backdrop) backdrop.hidden = !open;
if (open) {
document.body.classList.toggle("mobile-command-open", open && isMobileViewport());
if (backdrop) backdrop.hidden = !(open && isMobileViewport());
if (open && isMobileViewport()) {
global.requestAnimationFrame(() => menu.querySelector("button:not([hidden])")?.focus({ preventScroll: true }));
} else if (force === false && document.activeElement && menu.contains(document.activeElement)) {
button.focus({ preventScroll: true });
@@ -63,28 +56,6 @@
updateSidebarControl();
}
function pageGroupLabel(page) {
return page?.group === "market"
? "市场复盘"
: page?.group === "personal" ? "个人" : "智能工具";
}
function writePageSubtitle(viewId) {
const pageSubtitle = document.querySelector("#currentPageSubtitle");
if (!pageSubtitle) return;
const page = registry.get(viewId);
const dateText = options.tradeDate?.() || "";
const hasDate = Boolean(dateText) && dateText !== "--";
if (viewId === "mentorView") {
pageSubtitle.textContent = hasDate
? `与不同交易思维模型持续对话 · 数据日期 ${dateText}`
: "与不同交易思维模型持续对话 · 数据日期 --";
return;
}
const groupLabel = pageGroupLabel(page);
pageSubtitle.textContent = hasDate ? `${groupLabel} · ${dateText}` : groupLabel;
}
function syncNavigation(viewId) {
const page = registry.get(viewId);
const navigationId = page?.navigation_alias || viewId;
@@ -98,9 +69,6 @@
: page?.group === "personal" ? "复盘" : "工具";
}
if (mobileTitle) mobileTitle.textContent = page?.title || "小白复盘";
const pageTitle = document.querySelector("#currentPageTitle");
if (pageTitle) pageTitle.textContent = page?.title || "小白复盘";
writePageSubtitle(viewId);
document.querySelectorAll(".module-tab").forEach((button) => {
button.classList.toggle("active", button.dataset.view === navigationId);
button.classList.toggle(
@@ -216,35 +184,26 @@
toggleHeaderCommandMenu(false);
}
});
function setOverviewExpanded(expanded) {
const overview = document.querySelector(".overview-strip");
const button = document.querySelector("#overviewToggle");
if (!overview || !button) return;
overview.dataset.overviewExpanded = String(expanded);
button.setAttribute("aria-expanded", String(expanded));
button.title = expanded ? "收起市场详情" : "展开市场详情";
const label = button.querySelector("span");
if (label) label.textContent = "详情";
button.querySelector("i")?.setAttribute("data-lucide", expanded ? "chevron-up" : "chevron-down");
options.refreshIcons?.();
}
document.querySelector("#overviewToggle")?.addEventListener("click", (event) => {
event.stopPropagation();
const overview = document.querySelector(".overview-strip");
if (!overview) return;
const expanded = overview.dataset.overviewExpanded !== "true";
setOverviewExpanded(expanded);
if (expanded) toggleHeaderCommandMenu(false);
overview.dataset.overviewExpanded = String(expanded);
event.currentTarget.setAttribute("aria-expanded", String(expanded));
event.currentTarget.title = expanded ? "收起市场详情" : "展开市场详情";
const label = event.currentTarget.querySelector("span");
if (label) label.textContent = expanded ? "收起详情" : "展开详情";
event.currentTarget.querySelector("i")?.setAttribute(
"data-lucide",
expanded ? "chevron-up" : "chevron-down",
);
options.refreshIcons?.();
});
document.addEventListener("click", (event) => {
if (!event.target.closest("#headerCommandGroup, #headerMenuButton")) toggleHeaderCommandMenu(false);
if (!event.target.closest(".overview-strip")) setOverviewExpanded(false);
if (!event.target.closest(".header-actions")) toggleHeaderCommandMenu(false);
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
toggleHeaderCommandMenu(false);
setOverviewExpanded(false);
if (event.key === "Escape") toggleHeaderCommandMenu(false);
});
global.addEventListener("resize", () => {
toggleHeaderCommandMenu(false);
-2
View File
@@ -13,8 +13,6 @@ function syncThemeControl() {
button.setAttribute("aria-label", label);
button.setAttribute("aria-pressed", String(dark));
button.querySelector("i")?.setAttribute("data-lucide", dark ? "sun" : "moon");
const modeText = document.querySelector("#themeModeText");
if (modeText) modeText.textContent = dark ? "夜间模式" : "日间模式";
}
function clearThemeTransitionEffects() {
+68 -139
View File
@@ -22,20 +22,19 @@
--color-gray-900: #1f2937;
--color-shell-canvas: #f4f5f7;
--color-page-canvas: #f4f5f7;
--color-surface-muted: #f2f3f5;
--color-surface-subtle: #f8f9fb;
--color-border: #eceded;
--color-border-strong: #dee0e3;
--color-text-primary: #1f2329;
--color-text-secondary: #646a73;
--color-text-tertiary: #8f959e;
--color-action-base: #3370ff;
--color-action-base-hover: #2b5fd9;
--color-action-base-soft: #eaf1fe;
--color-action: #3370ff;
--color-action-hover: #2b5fd9;
--color-action-soft: #eaf1fe;
--color-action-press: #2456b8;
--color-surface-muted: #f3f4f6;
--color-surface-subtle: #f8fafc;
--color-border: #e5e7eb;
--color-border-strong: #d1d5db;
--color-text-primary: #1f2937;
--color-text-secondary: #6b7280;
--color-text-tertiary: #9ca3af;
--color-action-base: #2563eb;
--color-action-base-hover: #1d4ed8;
--color-action-base-soft: #eff4ff;
--color-action: #2563eb;
--color-action-hover: #1d4ed8;
--color-action-soft: #eff4ff;
--color-action-line: #c7d8fb;
--color-market-up-base: #e04536;
--color-market-up-base-soft: #fdecea;
@@ -50,25 +49,24 @@
--color-warning: #b45309;
--color-warning-soft: #fdf3e3;
--size-radius-sm: 4px;
--size-radius-md: 8px;
--size-radius-sm: 5px;
--size-radius-md: 7px;
--size-radius-lg: 10px;
--size-radius-dialog: 12px;
--size-control: 32px;
--size-sidebar: 200px;
--size-topbar: 64px;
--size-summary: 0px;
--size-statusbar: 28px;
--size-topbar: 46px;
--size-summary: 36px;
--size-statusbar: 30px;
--size-page-pad-y: 14px;
--size-page-pad-x: 16px;
--size-card-gap: 12px;
--elevation-card: 0 1px 2px rgba(31, 35, 41, .04);
--elevation-soft: 0 1px 2px rgba(31, 35, 41, .04);
--elevation-raised: 0 2px 6px rgba(31, 35, 41, .04), 0 8px 24px rgba(31, 35, 41, .06);
--elevation-float: 0 12px 32px rgba(0, 0, 0, .14);
--elevation-card: 0 1px 2px rgba(16, 24, 40, .05);
--elevation-soft: 0 1px 2px rgba(16, 24, 40, .05);
--elevation-raised: 0 4px 14px rgba(16, 24, 40, .06);
--elevation-float: 0 12px 32px rgba(0, 0, 0, .18);
--motion-instant: 100ms;
--motion-fast: 120ms;
--motion-fast: 140ms;
--motion-medium: 200ms;
--motion-deliberate: 260ms;
--motion-slow: 560ms;
@@ -82,11 +80,8 @@
--surface-canvas: var(--color-page-canvas);
--surface-raised: var(--color-white);
--surface-sunken: #eef0f3;
--header-bg: var(--color-white);
--surface-hover: #f2f3f5;
--surface-selected: #eaf1fe;
--hover: var(--surface-hover);
--selected: var(--surface-selected);
--surface-hover: #f8faff;
--surface-selected: #eff4ff;
--surface-overlay: var(--color-white);
--border: var(--color-border);
--border-strong: var(--color-border-strong);
@@ -98,12 +93,7 @@
--on-action: #ffffff;
--action: var(--color-action-base);
--action-hover: var(--color-action-base-hover);
--action-press: var(--color-action-press);
--action-soft: var(--color-action-base-soft);
--accent: var(--action);
--accent-hover: var(--action-hover);
--accent-press: var(--action-press);
--accent-soft: var(--action-soft);
--market-up: var(--color-market-up-base);
--market-up-soft: var(--color-market-up-base-soft);
--market-down: var(--color-market-down-base);
@@ -116,12 +106,7 @@
--table-header: var(--surface-subtle);
--table-hover: #f8faff;
--table-selected: var(--surface-selected);
--focus-ring: rgba(51, 112, 255, .15);
--text-1: var(--text-primary);
--text-2: var(--text-secondary);
--text-3: var(--text-tertiary);
--warn: var(--warning-color);
--warn-soft: var(--warning-soft);
--focus-ring: rgba(37, 99, 235, .16);
--backdrop: rgba(17, 24, 39, .48);
--primary: var(--color-action);
--primary-hover: var(--color-action-hover);
@@ -141,32 +126,18 @@
--radius-lg: var(--size-radius-lg);
--shadow-xs: var(--elevation-card);
--shadow-sm: var(--elevation-raised);
--shadow-card: var(--elevation-card);
--shadow-raised: var(--elevation-raised);
--shadow-float: var(--elevation-float);
--duration-fast: 150ms;
--duration-normal: 220ms;
--font-size-aux: 11.5px;
--font-size-caption: 12.5px;
--font-size-label: 13px;
--font-size-table: 13.5px;
--font-size-body: 14px;
--font-size-chat: 14px;
--font-size-card-title: 15px;
--font-size-page-title: 18px;
--font-size-metric: 24px;
--font-size-hero: 30px;
--fs-aux: var(--font-size-aux);
--fs-caption: var(--font-size-caption);
--fs-label: var(--font-size-label);
--fs-table: var(--font-size-table);
--fs-body: var(--font-size-body);
--fs-chat: var(--font-size-chat);
--fs-card-title: var(--font-size-card-title);
--fs-page-title: var(--font-size-page-title);
--fs-metric: var(--font-size-metric);
--fs-hero: var(--font-size-hero);
--font-size-aux: 10.5px;
--font-size-caption: 11px;
--font-size-label: 12px;
--font-size-table: 12.5px;
--font-size-body: 13px;
--font-size-card-title: 14px;
--font-size-page-title: 17px;
--font-size-metric: 22px;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
@@ -176,20 +147,11 @@
--space-8: 8px;
--space-12: 12px;
--space-16: 16px;
--space-20: 20px;
--space-24: 24px;
--sidebar-width: var(--size-sidebar);
--sidebar-compact-width: 64px;
--topbar-height: var(--size-topbar);
--header-pad-x: 20px;
--header-cluster-gap: 24px;
--header-action-gap: 6px;
--header-command-gap: 6px;
--header-tape-metric-pad-x: 16px;
--header-tape-toggle-gap: 14px;
--header-page-context-min: 148px;
--header-page-context-max: 280px;
--summary-height: var(--size-summary);
--statusbar-height: var(--size-statusbar);
--page-pad-y: var(--size-page-pad-y);
@@ -257,14 +219,13 @@
--dragon-profile-weight-strong: 750;
--dragon-profile-weight-semibold: 600;
--mentor-directory-width: 300px;
--mentor-pane-header-height: 56px;
--mentor-avatar-size: 40px;
--mentor-message-avatar-size: 36px;
--mentor-chat-avatar-size: 38px;
--mentor-profile-avatar-size: 68px;
--mentor-composer-min-height: 85px;
--mentor-message-max-width: 60%;
--mentor-directory-width: 280px;
--mentor-profile-width: 272px;
--mentor-pane-header-height: 58px;
--mentor-avatar-size: 46px;
--mentor-profile-avatar-size: 80px;
--mentor-composer-min-height: 94px;
--mentor-message-max-width: 82%;
--chart-background: #fbfcfd;
--chart-grid: #e2e8ec;
@@ -352,64 +313,45 @@
--r2-radius: var(--size-radius-lg);
--r2-shadow: var(--elevation-card);
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", "PingFang SC", "Microsoft YaHei UI", sans-serif;
font-size: 14px;
}
:root[data-theme="dark"] {
color-scheme: dark;
--canvas: #141519;
--header-bg: #1c1e23;
--surface: #232529;
--surface-muted: #2a2d33;
--surface-subtle: #202329;
--canvas: #121416;
--surface: #1b1e21;
--surface-muted: #202428;
--surface-subtle: #24282d;
--surface-canvas: var(--canvas);
--surface-raised: var(--surface);
--surface-sunken: #191b1f;
--surface-hover: #2a2d33;
--surface-selected: #2b3b58;
--surface-overlay: #232529;
--hover: var(--surface-hover);
--selected: var(--surface-selected);
--border: #2b2e34;
--border-strong: #3a3e47;
--border-subtle: #2b2e34;
--surface-sunken: #16191c;
--surface-hover: #24282d;
--surface-selected: #23364a;
--surface-overlay: #24282d;
--border: #343a40;
--border-strong: #474f57;
--border-subtle: #2a2f34;
--text-primary: #e8eaed;
--text-secondary: #a9adb3;
--text-tertiary: #7c828a;
--text-1: var(--text-primary);
--text-2: var(--text-secondary);
--text-3: var(--text-tertiary);
--text-secondary: #adb5bd;
--text-tertiary: #7f8993;
--text-inverse: #ffffff;
--action: #5b8def;
--action-hover: #7ba5f5;
--action-press: #3465c4;
--action-soft: #2b3b58;
--accent: var(--action);
--accent-hover: var(--action-hover);
--accent-press: var(--action-press);
--accent-soft: var(--action-soft);
--market-up: #f26762;
--market-up-soft: #3d2829;
--action: #6ca8e8;
--action-hover: #8bbcf0;
--action-soft: #23364a;
--market-up: #f06d73;
--market-up-soft: #40262a;
--market-down: #43bc8a;
--market-down-soft: #22362c;
--market-down-soft: #1d382f;
--warning-color: #e2ad58;
--warning-soft: #3d3220;
--warn: var(--warning-color);
--warn-soft: var(--warning-soft);
--control-surface: #232529;
--control-hover: #2a2d33;
--control-border: #3a3e47;
--table-header: #202329;
--table-hover: #2a2d33;
--table-selected: #2b3b58;
--focus-ring: rgba(91, 141, 239, .25);
--elevation-card: 0 1px 2px rgba(0, 0, 0, .28);
--elevation-soft: 0 1px 2px rgba(0, 0, 0, .28);
--elevation-raised: 0 1px 2px rgba(0, 0, 0, .30), 0 8px 24px rgba(0, 0, 0, .35);
--elevation-float: 0 12px 32px rgba(0, 0, 0, .46);
--shadow-card: var(--elevation-card);
--shadow-raised: var(--elevation-raised);
--control-surface: #202428;
--control-hover: #2a2f34;
--control-border: #474f57;
--table-header: #202428;
--table-hover: #242f39;
--table-selected: #23364a;
--focus-ring: rgba(108, 168, 232, .22);
--backdrop: rgba(0, 0, 0, .66);
--primary: var(--action);
--primary-hover: var(--action-hover);
@@ -525,16 +467,3 @@
--shadow-soft: 0 1px 2px rgba(0, 0, 0, .28), 0 8px 24px rgba(0, 0, 0, .16);
--shadow: 0 18px 50px rgba(0, 0, 0, .46);
}
@media (min-width: 1280px) and (max-width: 1919px) {
:root {
--header-pad-x: 12px;
--header-cluster-gap: 8px;
--header-action-gap: 4px;
--header-command-gap: 4px;
--header-tape-metric-pad-x: 6px;
--header-tape-toggle-gap: 4px;
--header-page-context-min: 96px;
--header-page-context-max: 148px;
}
}
File diff suppressed because it is too large Load Diff
-346
View File
@@ -1,346 +0,0 @@
const { test, expect } = require("@playwright/test");
// 手机端(/m/)全页面回归:P5 收官打磨。
// 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手,
// 以及日夜两套渲染、空态、错误态、横屏健壮性、深底深字对比度抽查。
const EMPTY_DASHBOARD = {
meta: {
trade_date: "2026-07-22",
requested_date: "2026-07-22",
source: "tushare",
realtime: false,
cached: true,
market_status: "closed",
previous_trade_date: "2026-07-21",
},
overview: {
up_count: 2100,
down_count: 2800,
limit_up_count: 42,
limit_down_count: 8,
broken_count: 17,
seal_rate: 71.2,
amount_billion: 12600,
sentiment_score: 48,
},
limits: [],
broken: [],
down_limits: [],
yesterday_limits: [],
limit_performance: [],
ladders: [],
sectors: [],
sector_rotation: [],
};
const DASHBOARD = {
...EMPTY_DASHBOARD,
limits: [
{ code: "002141", name: "贤丰控股", streak: 4, change: 9.98, price: 12.48, sector: "电子元件", first_time: "09:31", last_time: "10:18", open_times: 1, turnover_rate: 18.42, amount_billion: 12.6, seal_amount_million: 8200, reason: "板块龙头连板打开空间" },
],
broken: [
{ code: "002156", name: "通富微电", change: 9.77, price: 76.64, sector: "半导体", first_time: "10:08:03", open_times: 4, turnover_rate: 17.36, amount_billion: 198.18, reason: "芯片方向冲高回落" },
],
down_limits: [
{ code: "000037", name: "深南电A", change: -10.03, price: 8.97, sector: "电力", turnover_rate: 11.78, amount_billion: 3.68, streak: 2, reason: "连续弱势跌停" },
],
yesterday_limits: [
{ code: "000011", name: "深物业A", prior_streak: 1, current_change: 9.99, outcome: "晋级", current_streak: 2, sector: "房地产开发", reason: "地产政策预期" },
],
limit_performance: [
{ level: 3, label: "昨日3板", count: 4, advanced: 2, advance_rate: 50, positive_rate: 75, average_change: 3.4 },
{ level: 2, label: "昨日2板", count: 9, advanced: 2, advance_rate: 22.2, positive_rate: 44.4, average_change: 0.8 },
],
ladders: [
{ level: 4, label: "4板", count: 1, stocks: [{ code: "002141", name: "贤丰控股", sector: "电子元件", first_time: "09:31", open_times: 0, seal_amount_million: 8200 }] },
{ level: 3, label: "3板", count: 2, stocks: [{ code: "000011", name: "深物业A", sector: "房地产开发", first_time: "09:40", open_times: 1, amount_billion: 3.2 }] },
],
};
function authSession(role = "admin", subscribed = true) {
return {
authenticated: true,
csrf_token: "mobile-test-csrf",
user: {
id: role === "admin" ? 1 : 2,
username: role === "admin" ? "admin_user" : "normal_user",
role,
membership: { active: role === "admin" || subscribed, subscribed, is_admin: role === "admin" },
},
};
}
async function mockMobileApi(page, options = {}) {
const auth = options.auth || authSession();
const dashboard = options.emptyDashboard ? EMPTY_DASHBOARD : DASHBOARD;
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
let payload = { ok: true };
if (options.failDashboard && path === "/api/dashboard") {
await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "行情服务暂时不可用" }) });
return;
}
if (path === "/api/auth/me") payload = auth;
else if (path === "/api/dashboard") payload = dashboard;
else if (path === "/api/popularity") {
const hot = { rank: 1, code: "002141", ts_code: "002141.SZ", name: "贤丰控股", change: 2.4, price: 12.48, ths_rank: 1, dc_rank: 2, rank_change: 3, concepts: ["电子元件"], dual_source: true };
payload = { meta: { trade_date: "2026-07-22" }, combined: [hot], ths: [{ ...hot }], dc: [{ ...hot, rank: 2 }] };
} else if (path === "/api/sentiment/history") {
payload = { rows: [{ trade_date: "2026-07-22", score: 48, phase: "修复", direction: "升温", label: "情绪修复", day_change: 4, seal_rate: 71, limit_up_count: 42, broken_count: 17, components: {} }] };
} else if (path === "/api/rotation/history") {
payload = { rows: [{ trade_date: "2026-07-22", sectors: [{ name: "人工智能", rank: 1, count: 8, strength: 88 }] }] };
} else if (path === "/api/rotation/members") {
payload = { meta: { trade_date: "20260722", sector_name: "人工智能", member_count: 1, quoted_count: 1 }, rows: [{ code: "002141", name: "贤丰控股", change: 4.8, open: 18.21, close: 19.06, amount_billion: 19.6 }] };
} else if (path === "/api/auction") {
payload = {
meta: { trade_date: "2026-07-22", phase: "finalized", available: true },
summary: { stock_count: 3, focus_count: 1, one_price_count: 1, amount_billion: 2.5 },
amount_history: [{ trade_date: "2026-07-22", amount_billion: 2.5, stock_count: 2 }],
themes: { carry: [{ name: "电子元件", status: "强承接", prior_limit_count: 2, leader: "贤丰控股", matched_count: 1, median_change: 4.2, amount_million: 15 }] },
focus_rows: [{ code: "002141", name: "贤丰控股", sector: "电子元件", change: 4.2, price: 12.48, amount_million: 15, volume_ratio: 1.8, expectation: "超预期", attention_score: 88.5 }],
rows: [],
one_price_rows: [],
watchlist_rows: [],
};
} else if (path === "/api/themes") {
payload = { meta: { trade_date: "2026-07-22" }, summary: { theme_count: 1, up_count: 1, down_count: 0, hot_count: 1 }, items: [{ code: "885728.TI", name: "人工智能", member_count: 8, change: 2.2, turnover_rate: 3.1, hot_rank: 1, has_quote: true }] };
} else if (path === "/api/themes/detail") {
payload = { meta: { trade_date: "2026-07-22" }, theme: { code: "885728.TI", name: "人工智能", member_count: 8, change: 2.2, turnover_rate: 3.1 }, summary: { member_count: 8, quoted_count: 8, up_count: 6, down_count: 2 }, members: [{ code: "002141", name: "贤丰控股", change: 2.4, price: 12.48, amount_billion: 3.2 }] };
} else if (path === "/api/dragon-tiger") {
payload = { meta: { trade_date: "2026-07-22", status: "ok" }, summary: { trader_count: 1, operation_count: 2, active_stock_count: 1 }, traders: [{ id: "t1", name: "赵老哥", identity_type: "trader", recognized: true, stock_count: 1, operation_count: 2, net_buy_million: 150, operations: [] }], unclassified_seats: [] };
} else if (path === "/api/dragon-tiger/profiles") {
payload = { meta: { status: "success" }, summary: { profile_count: 1, described_count: 1, organization_count: 1 }, profiles: [{ id: "p1", name: "赵老哥", description: "聚焦核心。", organizations: ["华泰证券浙江分公司"], organization_count: 1 }] };
} else if (path === "/api/screener/setup") {
payload = {
trade_date: "20260722",
regime: { id: "repair", label: "修复", confidence: 70, reason: "测试" },
regimes: [{ id: "repair", label: "修复" }],
factor_data: { ready: true, date_count: 45 },
strategies: [{ id: 1, name: "修复确认", description: "保留原有流程", regimes: ["repair"], builtin: true, data_ready: true, missing_data: [], formula: { meta: { library: "smart" }, universe: { exclude_st: true }, filters: [], score: [], limit: 15 } }],
};
} else if (path === "/api/screener/tracking") {
payload = { batches: [], summary: { total: 0, observed: 0, t1_win_rate: null, t5_win_rate: null, average_t5: null } };
} else if (path === "/api/watchlist") {
payload = { items: [{ code: "000002", name: "万科A", sector: "房地产开发", color: "red", change: 1.86, return_5d: 8.92, attention_score: 72.4, remark: "" }] };
} else if (path === "/api/trades") {
payload = { items: [{ id: 7, trade_date: "20260722", code: "002141", name: "贤丰控股", action: "buy", action_label: "买入", price: 10.2, quantity: 1000, position_pct: 20, pnl_amount: null, pnl_pct: null, emotion: "calm", emotion_label: "平静", tags: [], thesis: "", execution: "" }], summary: { total: 1, realized: 0, win_rate: null, pnl_amount: null, average_position: 20 } };
} else if (path === "/api/notes") {
payload = { items: [{ id: 12, code: "002141", stock_name: "贤丰控股", trade_date: "20260722", summary: "缩量修复", content: "等待确认。", plan: "" }] };
} else if (path === "/api/alerts") {
payload = { items: [{ id: 11, kind: "manual", available_date: "20260722", title: "复盘开盘强度", content: "", code: "002141", is_read: false, due: true }], unread_count: 1 };
} else if (path === "/api/assistant/messages") {
payload = { items: [] };
} else if (path === "/api/mentors/setup") {
payload = { trade_date: "20260722", mentors: [{ id: "source-a", name: "原帖老师", tagline: "先看周期。", focus: ["情绪周期"], evidence: { grade: "A" }, private: false }] };
} else if (path === "/api/mentors/messages") {
payload = { items: [] };
} else if (path === "/api/search") {
payload = { groups: { stocks: [{ id: "002141", code: "002141", name: "贤丰控股", type: "stock", industry: "电子元件" }], sectors: [], themes: [], indices: [] } };
} else if (/^\/api\/stock\/\d+\/preview$/.test(path)) {
payload = {
meta: { trade_date: "2026-07-22", intraday_status: "available" },
stock: { code: "002141", name: "贤丰控股", industry: "电子元件", price: 12.48, change: 2.4 },
prices: [{ trade_date: "2026-07-21", open: 10, high: 10.5, low: 9.9, close: 10.2, volume: 1000 }, { trade_date: "2026-07-22", open: 10.3, high: 10.9, low: 10.2, close: 12.48, volume: 1200 }],
intraday: [{ date: "2026-07-22", time: "09:30", open: 10.2, high: 10.24, low: 10.18, close: 10.22, volume: 100, average: 10.22 }],
};
}
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(payload) });
});
}
async function openMobile(page) {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/m/");
await expect(page.locator("#m-boot-splash")).toBeHidden();
await expect(page.locator("#m-tabbar")).toBeVisible();
}
async function setMobileTheme(page, theme) {
const wanted = theme === "night" ? "dark" : "light";
await page.evaluate((value) => window.MobileTheme.applyTheme(value), wanted);
await expect(page.locator("#m-app")).toHaveAttribute("data-theme", wanted);
}
async function navigateToFeature(page, key) {
await page.evaluate((k) => { window.MobileRouter.navigate("#/feature/" + k); }, key);
await expect(page.locator(`#m-view .m-page[data-page="${key}"]`)).toBeVisible();
}
function measureOverflow(page) {
return page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
}
// 深底深字 / 隐形文字抽查:文本元素的前景色若与其实际背景色完全一致即为不可见文字。
function findInvisibleText(page) {
return page.evaluate(() => {
const issues = [];
const seen = new Set();
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
const text = node.textContent.trim();
if (!text || text.length < 1) continue;
const el = node.parentElement;
if (!el || seen.has(el)) continue;
seen.add(el);
const style = getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") continue;
let bg = "transparent";
let cur = el;
while (cur && (bg === "transparent" || bg === "rgba(0, 0, 0, 0)")) {
bg = getComputedStyle(cur).backgroundColor;
cur = cur.parentElement;
}
if (style.color === bg) {
issues.push({ tag: el.tagName, cls: String(el.className).slice(0, 40), text: text.slice(0, 24), color: style.color });
}
}
return issues;
});
}
const MARKET_PAGES = [
["market/sentiment", "情绪周期"],
["market/limit-up", "涨停池"],
["market/broken", "炸板池"],
["market/limit-down", "跌停池"],
["market/yesterday", "昨日涨停"],
["market/performance", "涨停表现"],
["market/ladder", "市场天梯"],
["market/rotation", "主题轮动"],
["market/auction", "竞价"],
["market/themes", "题材库"],
["market/popularity", "人气榜"],
["market/dragon", "龙虎榜"],
];
const TOOL_PAGES = [
["tools/screener", "智能选股"],
["tools/tracking", "策略跟踪"],
["tools/mentor", "问师"],
];
const REVIEW_PAGES = [
["review/watchlist", "自选股"],
["review/trades", "交易日志"],
["review/daily", "每日复盘"],
["review/notes", "个股笔记"],
["review/alerts", "提醒中心"],
];
test("mobile login renders before authentication", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname === "/api/auth/me") {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ authenticated: false }) });
return;
}
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }) });
});
await page.goto("/m/");
await expect(page.locator("#m-auth-form")).toBeVisible();
await expect(page.locator("#m-auth-username")).toBeVisible();
await expect(page.locator("#m-tabbar")).toBeHidden();
});
test("four hub pages render their icon grids", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
for (const hub of ["market", "tools", "review", "system"]) {
await page.evaluate((h) => { window.MobileRouter.navigate("#/hub/" + h); }, hub);
await expect(page.locator(".m-hub-grid")).toBeVisible();
await expect(page.locator(".m-hub-grid .m-grid-item").first()).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
}
});
for (const theme of ["day", "night"]) {
test(`market 12 pages render without overflow or invisible text (${theme})`, async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
await setMobileTheme(page, theme);
for (const [key, label] of MARKET_PAGES) {
await navigateToFeature(page, key);
await expect(page.locator("#m-title")).toHaveText(label);
await expect(page.locator("#m-view .m-page")).toBeVisible();
await page.waitForTimeout(120);
expect(await measureOverflow(page), `${key} overflows in ${theme}`).toBeLessThanOrEqual(1);
const invisible = await findInvisibleText(page);
expect(invisible, `${key} invisible text in ${theme}: ${JSON.stringify(invisible)}`).toEqual([]);
}
});
}
test("tools pages render screener, tracking and mentor", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
for (const [key, label] of TOOL_PAGES) {
await navigateToFeature(page, key);
await expect(page.locator("#m-title")).toHaveText(label);
await page.waitForTimeout(120);
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
}
});
test("review five pages render watchlist, trades, daily, notes and alerts", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
for (const [key, label] of REVIEW_PAGES) {
await navigateToFeature(page, key);
await expect(page.locator("#m-title")).toHaveText(label);
await page.waitForTimeout(120);
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
}
});
test("assistant chat renders with presets and input", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
await page.evaluate(() => { window.MobileRouter.navigate("#/assistant/chat"); });
await expect(page.locator("#m-title")).toHaveText("复盘助手");
await expect(page.locator("#m-chat-input")).toBeVisible();
await expect(page.locator(".m-chat-presets")).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
});
test("empty dashboard shows empty state instead of a bare table", async ({ page }) => {
await mockMobileApi(page, { emptyDashboard: true });
await openMobile(page);
await navigateToFeature(page, "market/limit-up");
await expect(page.locator(".m-state")).toBeVisible();
await expect(page.locator(".m-state p")).toContainText("暂无相关数据");
});
test("dashboard failure shows error state with retry", async ({ page }) => {
await mockMobileApi(page, { failDashboard: true });
await openMobile(page);
await navigateToFeature(page, "market/limit-up");
await expect(page.locator(".m-state--error")).toBeVisible();
await expect(page.locator(".m-btn-retry")).toBeVisible();
});
test("landscape keeps pages usable and returns to portrait", async ({ page }) => {
await mockMobileApi(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/m/");
await expect(page.locator("#m-tabbar")).toBeVisible();
await page.setViewportSize({ width: 844, height: 390 });
await page.evaluate(() => { window.MobileRouter.navigate("#/feature/market/sentiment"); });
await page.waitForTimeout(150);
await expect(page.locator("#m-title")).toHaveText("情绪周期");
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
await expect(page.locator("#m-tabbar")).toBeVisible();
await page.evaluate(() => { window.MobileRouter.navigate("#/feature/market/limit-up"); });
await page.waitForTimeout(150);
await expect(page.locator(".m-table")).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator("#m-tabbar")).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
});
+9 -81
View File
@@ -18,92 +18,20 @@ class DatabaseMigrationTests(unittest.TestCase):
rows = connection.execute(
"SELECT version, name FROM schema_migrations"
).fetchall()
self.assertEqual(
[(row["version"], row["name"]) for row in rows],
[
("0001", "adopt_legacy_schema"),
("0002", "create_job_runs"),
("0003", "extend_llm_audit"),
("0004", "add_mentor_note"),
],
)
columns = {
str(row["name"])
for row in connection.execute(
"PRAGMA table_info(mentor_preferences)"
)
}
self.assertIn("note", columns)
self.assertEqual(
[(row["version"], row["name"]) for row in rows],
[
("0001", "adopt_legacy_schema"),
("0002", "create_job_runs"),
("0003", "extend_llm_audit"),
],
)
ReviewDatabase(path)
with database.connect() as connection:
count = connection.execute(
"SELECT COUNT(*) AS count FROM schema_migrations"
).fetchone()["count"]
self.assertEqual(count, 4)
def test_database_with_recorded_0004_and_note_column_starts_without_reapply(
self,
) -> None:
with tempfile.TemporaryDirectory() as root:
path = Path(root) / "review.db"
database = ReviewDatabase(path)
with database.connect() as connection:
note_rows = [
str(row["name"])
for row in connection.execute(
"PRAGMA table_info(mentor_preferences)"
)
]
self.assertIn("note", note_rows)
ReviewDatabase(path)
with database.connect() as connection:
count = connection.execute(
"SELECT COUNT(*) AS count FROM schema_migrations"
).fetchone()["count"]
self.assertEqual(count, 4)
def test_old_database_without_0004_upgrades_and_adds_note_column(self) -> None:
with tempfile.TemporaryDirectory() as root:
path = Path(root) / "review.db"
database = ReviewDatabase(path)
with database.connect() as connection:
connection.execute(
"DELETE FROM schema_migrations WHERE version = '0004'"
)
connection.execute(
"ALTER TABLE mentor_preferences DROP COLUMN note"
)
ReviewDatabase(path)
with database.connect() as connection:
versions = {
str(row["version"])
for row in connection.execute(
"SELECT version FROM schema_migrations"
)
}
note_rows = [
str(row["name"])
for row in connection.execute(
"PRAGMA table_info(mentor_preferences)"
)
]
self.assertEqual(versions, {"0001", "0002", "0003", "0004"})
self.assertIn("note", note_rows)
def test_database_with_unknown_migration_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as root:
path = Path(root) / "review.db"
database = ReviewDatabase(path)
with database.connect() as connection:
connection.execute(
"""
INSERT INTO schema_migrations
(version, name, checksum, applied_at)
VALUES ('9999', 'unknown_legacy', 'x', '2026-08-01T00:00:00+00:00')
"""
)
with self.assertRaises(MigrationError):
ReviewDatabase(path)
self.assertEqual(count, 3)
def test_connection_factory_enables_required_pragmas(self) -> None:
with tempfile.TemporaryDirectory() as root:
+18 -95
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import re
import subprocess
import unittest
from html.parser import HTMLParser
from pathlib import Path
@@ -9,35 +8,12 @@ from pathlib import Path
from tests.frontend_test_helpers import (
assembled_frontend_runtime,
assembled_frontend_document,
registered_frontend_runtime_scripts,
)
STATIC_DIR = Path(__file__).resolve().parents[1] / "frontend"
def uncommitted_runtime_files() -> set[str]:
"""Relative frontend paths with pending working-tree edits.
The id-reference contract is validated against committed sources only.
Parallel agents may temporarily reference ids that their own (uncommitted)
HTML change reintroduces; those are resolved by the owning commit.
"""
result = subprocess.run(
["git", "-C", str(STATIC_DIR.parent), "diff", "--name-only", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return set()
return {
line.strip().removeprefix("app/frontend/")
for line in result.stdout.splitlines()
if line.strip().startswith("app/frontend/")
}
class IdCollector(HTMLParser):
def __init__(self) -> None:
super().__init__()
@@ -74,23 +50,11 @@ class FrontendContractTests(unittest.TestCase):
self.assertEqual(duplicates, [])
def test_literal_id_selectors_exist_in_html(self):
pending = uncommitted_runtime_files()
dangling: list[str] = []
for url in registered_frontend_runtime_scripts():
relative = url.split("?", 1)[0].lstrip("/")
if relative.startswith("vendor/"):
continue
if relative in pending:
continue
source = (STATIC_DIR / relative).read_text(encoding="utf-8")
selectors = set(re.findall(r'querySelector\("#([A-Za-z][A-Za-z0-9_-]*)"\)', source))
selectors.update(re.findall(r'getElementById\("([A-Za-z][A-Za-z0-9_-]*)"\)', source))
selectors.update(re.findall(r'setText\("([A-Za-z][A-Za-z0-9_-]*)"', source))
dangling.extend(
f"{relative}:{selector}"
for selector in sorted(selectors - set(self.ids))
)
self.assertEqual(dangling, [])
selectors = set(re.findall(r'querySelector\("#([A-Za-z][A-Za-z0-9_-]*)"\)', self.script))
selectors.update(re.findall(r'getElementById\("([A-Za-z][A-Za-z0-9_-]*)"\)', self.script))
selectors.update(re.findall(r'setText\("([A-Za-z][A-Za-z0-9_-]*)"', self.script))
missing = sorted(selectors - set(self.ids))
self.assertEqual(missing, [])
def test_all_primary_views_have_navigation_entries(self):
views = set(re.findall(r'id="([A-Za-z][A-Za-z0-9_-]*View|limitPool)" class="workspace-view', self.html))
@@ -166,15 +130,6 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn('id="accountDropdown"', self.html)
self.assertIn('id="settingsButton"', self.html)
def test_backfill_clears_sentiment_cache_and_reloads_sentiment_view(self):
start = self.script.index("async function backfillData")
end = self.script.index("async function openAdminSettings", start)
backfill = self.script[start:end]
self.assertIn("state.sentimentHistory = null;", backfill)
self.assertIn('state.sentimentHistoryKey = "";', backfill)
self.assertIn('if (state.activeView === "sentimentCycleView")', backfill)
self.assertIn("await loadSentimentHistory(true);", backfill)
def test_screener_uses_progressive_strategy_editor(self):
for step in ("regime", "strategy", "run", "result"):
self.assertIn(f'data-screener-step="{step}"', self.html)
@@ -324,19 +279,19 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn("context.fillStyle = palette.axis;", chart)
self.assertNotIn('context.fillStyle = "#6c7983";', chart)
def test_dark_mentor_tokens_and_sentiment_bottom_clearance_are_defined(self):
self.assertIn(":root[data-theme=\"dark\"] #mentorView.workspace-view {", self.mentor_styles)
self.assertIn("--qp-bg-chat: #232529;", self.mentor_styles)
self.assertIn("--qp-bg-bubble-self: #35598C;", self.mentor_styles)
self.assertIn("--qp-bg-selected: #2B3B58;", self.mentor_styles)
self.assertIn("--qp-link: #316FEF;", self.mentor_styles)
self.assertIn("--qp-accent-soft: #5B8DEF;", self.mentor_styles)
self.assertIn("--mentor-directory-width: 300px;", self.tokens)
self.assertIn("--mentor-composer-min-height: 85px;", self.tokens)
self.assertIn("#mentorView .mentor-message.user .mentor-message-content {", self.mentor_styles)
self.assertIn("background: var(--qp-bg-bubble-self);", self.mentor_styles)
self.assertIn("#mentorView .mentor-quick-prompts button {", self.mentor_styles)
self.assertIn("#mentorView .mentor-composer-hint {", self.mentor_styles)
def test_dark_mentor_answer_bubble_and_sentiment_bottom_clearance_are_defined(self):
self.assertIn(":root[data-theme=\"dark\"] #mentorView {", self.mentor_styles)
self.assertIn("--mentor-ink: var(--text-primary);", self.mentor_styles)
self.assertIn("--mentor-sub: var(--text-secondary);", self.mentor_styles)
self.assertIn(":root[data-theme=\"dark\"] #mentorView .mentor-message {", self.mentor_styles)
self.assertIn("background: transparent;", self.mentor_styles)
self.assertIn("box-shadow: none;", self.mentor_styles)
self.assertIn(
':root[data-theme="dark"] #mentorView .mentor-message.assistant .mentor-message-content {',
self.mentor_styles,
)
self.assertIn("border-color: var(--border);", self.mentor_styles)
self.assertIn("background: var(--surface-muted);", self.mentor_styles)
self.assertIn("#sentimentCycleView .sentiment-history-frame {", self.sentiment_styles)
self.assertIn("margin-bottom: var(--card-gap);", self.sentiment_styles)
self.assertIn("padding-bottom: var(--card-gap);", self.sentiment_styles)
@@ -344,38 +299,6 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn("max-height: var(--sentiment-history-max-height);", self.sentiment_styles)
self.assertIn("overflow: auto;", self.sentiment_styles)
def test_mentor_final_visual_fix_contract(self):
shell_styles = (STATIC_DIR / "shared" / "shell.css").read_text(encoding="utf-8")
mentor_html = (STATIC_DIR / "pages" / "mentor" / "page.html").read_text(encoding="utf-8")
self.assertNotIn('body[data-active-view="mentorView"]', shell_styles)
self.assertNotIn("body[data-active-view=mentorView]", shell_styles)
self.assertIn('id="mentorPageSubtitle"', mentor_html)
self.assertIn('class="mentor-page-header"', mentor_html)
self.assertIn("#mentorView .mentor-page-header", self.mentor_styles)
for tone in ("violet", "blue", "green", "orange", "red", "teal", "purple", "yellow"):
self.assertIn(f".mentor-avatar-tone-{tone} {{", self.mentor_styles, tone)
self.assertIn(
f':root[data-theme="dark"] #mentorView .mentor-avatar-tone-{tone} {{',
self.mentor_styles,
tone,
)
self.assertIn("kobe92-perspective", self.script)
self.assertIn('"beijingchaojia-perspective": "green"', self.script)
self.assertIn("`mentor-avatar-tone-${mentorAvatarTone(mentor)}`", self.script)
self.assertIn('${escapeHtml(grade)}级', self.script)
self.assertIn("置顶", self.script)
self.assertNotIn("activeMentorBadges", self.script)
self.assertNotIn("activeMentorBadges", mentor_html)
self.assertIn("#mentorView .mentor-message.assistant .mentor-message-body {", self.mentor_styles)
self.assertIn("max-width: 900px;", self.mentor_styles)
self.assertIn("#mentorView .mentor-follow-ups {", self.mentor_styles)
self.assertIn("width: 382px;", self.mentor_styles)
self.assertIn("data-lucide=\"filter\"", mentor_html)
self.assertNotIn("chevron-down", mentor_html)
self.assertNotIn("· 回答完成", self.script)
self.assertIn('state.mentorLoading ? "正在生成回答..." : (selected?.tagline', self.script)
def test_theme_switch_is_atomic_and_theme_library_loading_surface_is_dark_safe(self):
self.assertIn('typeof document.startViewTransition === "function"', self.script)
self.assertIn('root.classList.add("theme-switching")', self.script)
+22
View File
@@ -59,6 +59,28 @@ class JobRunnerTests(unittest.TestCase):
release.set()
self.assertTrue(self.runner.wait_for_idle())
def test_database_claim_rejects_the_same_job_from_another_runner(self) -> None:
other_runner = InProcessJobRunner(JobRegistry.load(), self.repository)
entered = threading.Event()
release = threading.Event()
calls = []
def wait() -> None:
calls.append("first")
entered.set()
release.wait(2)
self.assertTrue(self.runner.submit("screener.automatic", "shared-key", wait))
self.assertTrue(entered.wait(1))
self.assertFalse(
other_runner.run_inline(
"screener.automatic", "shared-key", lambda: calls.append("second")
)
)
release.set()
self.assertTrue(self.runner.wait_for_idle())
self.assertEqual(calls, ["first"])
def test_failed_status_payload_is_recorded_as_failure(self) -> None:
self.assertTrue(
self.runner.run_inline(
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
import json
import unittest
from types import SimpleNamespace
from backend.features.system.health import HealthServiceMixin
from backend.features.system.routes import SystemRoutesMixin
class _Connection:
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def execute(self, _query: str):
return self
def fetchone(self):
return (1,)
class _Database:
def connect(self):
return _Connection()
def status(self):
return {
"database": "secret-review.db",
"snapshot_dates": 31,
"updated_at": "2026-08-07T11:34:55+08:00",
"last_sync": {
"id": 991,
"trade_date": "20260807",
"source": "tushare",
"status": "success",
"finished_at": "2026-08-07T11:34:55+08:00",
"message": "https://provider.invalid?token=secret",
},
}
class _Service(HealthServiceMixin):
def __init__(self):
self.database = _Database()
self.jobs = SimpleNamespace(
repository=SimpleNamespace(
recent=lambda _limit: [
{
"id": 77,
"status": "success",
"finished_at": "2026-08-07T11:34:55+08:00",
"message": "secret upstream response",
}
]
)
)
self._system_credentials = {
"tushare_token": "secret-token",
"primary_model_id": "primary-id",
"fallback_model_id": "fallback-id",
}
@property
def configured(self):
return bool(self._system_credentials.get("tushare_token"))
def _platform_llm_profile(self):
return {
"primary": {
"api_key": "secret-primary-key",
"base_url": "https://models.invalid/v1",
"model": "secret-primary-model",
},
"fallback": {
"api_key": "secret-fallback-key",
"base_url": "https://models.invalid/v1",
"model": "secret-fallback-model",
},
}
@staticmethod
def _profile_configured(profile):
return all(profile.get(key) for key in ("api_key", "base_url", "model"))
class SystemHealthTests(unittest.TestCase):
def test_public_route_uses_the_service_health_contract(self):
handler = SystemRoutesMixin()
expected = {"ok": True, "components": {"process": {"status": "ready"}}}
handler.application_service = SimpleNamespace(health_status=lambda: expected)
responses = []
handler.send_json = responses.append
handled = handler._handle_system_public_get(SimpleNamespace(path="/api/health"))
self.assertTrue(handled)
self.assertEqual(responses, [expected])
def test_health_distinguishes_runtime_components(self):
result = _Service().health_status()
self.assertTrue(result["ok"])
self.assertEqual(result["status"], "ready")
self.assertEqual(
set(result["components"]),
{"process", "database", "data_sources", "jobs", "models"},
)
self.assertEqual(result["components"]["data_sources"]["latest_trade_date"], "20260807")
self.assertEqual(result["components"]["models"]["primary"], "ready")
self.assertEqual(result["components"]["models"]["fallback"], "ready")
def test_public_health_never_exposes_engineering_or_secret_fields(self):
payload = json.dumps(_Service().health_status(), ensure_ascii=False).lower()
for forbidden in (
"tushare",
"ifind",
"secret",
"token",
"https://",
".db",
"primary-id",
"fallback-id",
"model",
):
if forbidden == "model":
self.assertNotIn("secret-primary-model", payload)
else:
self.assertNotIn(forbidden, payload)
if __name__ == "__main__":
unittest.main()