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
51 changed files with 1813 additions and 3540 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
+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-2",
"/shared/tokens.css?v=20260806-2",
"/shared/base.css?v=20260806-1",
"/shared/shell.css?v=20260820-2",
"/shared/auth.css?v=20260820-3",
"/shared/components/controls.css?v=20260820-1",
"/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": 62384,
"lines": 3718
"bytes": 51978,
"lines": 3224
},
{
"path": "backend/features/heaven/engine.py",
@@ -461,8 +461,8 @@
},
{
"path": "frontend/index.html",
"bytes": 47056,
"lines": 646
"bytes": 45846,
"lines": 643
},
{
"path": "backend/features/screener/catalog.py",
@@ -471,8 +471,8 @@
},
{
"path": "frontend/pages/auction/foundation.css",
"bytes": 35329,
"lines": 2420
"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,
+67 -70
View File
@@ -17,29 +17,29 @@
document.documentElement.style.colorScheme = theme;
})();
</script>
<link rel="stylesheet" href="/shared/tokens.css?v=20260820-2">
<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-2">
<link rel="stylesheet" href="/shared/auth.css?v=20260820-3">
<link rel="stylesheet" href="/shared/components/controls.css?v=20260820-1">
<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="账号登录">
@@ -68,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">
@@ -126,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>
@@ -213,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>
+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-1",
"/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",
+36 -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 {
@@ -2151,9 +2138,11 @@
}
.auction-table-v2 tbody td {
height: 40px;
height: 43px;
padding: 0 12px;
padding-top: 6px;
padding-bottom: 6px;
}
}
@@ -2176,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 {
+1 -1
View File
@@ -51,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>
+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>
+37 -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 {
@@ -666,9 +658,11 @@
}
.popularity-table-v2 tbody td {
height: 40px;
height: 39px;
padding: 0 12px;
padding-top: 5px;
padding-bottom: 5px;
}
}
@@ -863,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>
+26 -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 {
@@ -1197,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);
+79 -81
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 {
@@ -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;
}
@@ -1087,7 +1085,7 @@ button.account-role-badge:focus-visible {
}
.account-settings-dialog {
border-radius: 12px;
border-radius: 9px;
}
.settings-dialog {
@@ -1097,7 +1095,7 @@ button.account-role-badge:focus-visible {
overflow-x: hidden;
border-radius: 12px;
border-radius: 9px;
}
.account-dropdown {
@@ -1117,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) {
@@ -1180,14 +1178,14 @@ button.account-role-badge:focus-visible {
}
@media (min-width: 1280px) and (max-width: 1510px) {
.app-header .header-command-group .account-role-badge span {
display: inline;
.account-role-badge span {
display: none;
}
.app-header .header-command-group .account-role-badge {
min-width: 0;
.account-role-badge {
min-width: 27px;
justify-content: flex-start;
justify-content: center;
}
}
@@ -1216,7 +1214,7 @@ button.account-role-badge:focus-visible {
border-radius: 7px;
background: var(--action);
background: rgb(37, 99, 235);
color: var(--text-inverse);
@@ -1310,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 {
@@ -1329,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;
@@ -1337,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;
}
@@ -1377,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;
@@ -1399,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;
@@ -1427,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;
}
@@ -1437,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 {
@@ -1451,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 {
@@ -1477,7 +1475,7 @@ button.account-role-badge:focus-visible {
border-radius: 7px;
color: var(--text-secondary);
color: rgb(102, 115, 132);
font-size: 11px;
}
@@ -1491,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 {
@@ -1499,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 {
@@ -1637,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);
}
+1 -1
View File
@@ -1010,7 +1010,7 @@ textarea {
@media (min-width: 768px) {
.header-menu-button {
display: inline-flex;
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)}`);
+77 -571
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: 24px;
gap: 14px;
padding: 0 20px;
padding: 0 var(--page-pad-x);
height: var(--topbar-height);
}
@@ -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) {
@@ -3178,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 {
@@ -3207,512 +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: 148px;
max-width: 280px;
flex: 0 1 auto;
flex-direction: column;
justify-content: center;
gap: 2px;
overflow: hidden;
display: flex;
}
#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 1 auto;
align-items: stretch;
width: auto;
height: auto;
min-width: 0;
min-height: 0;
margin: 0;
padding: 0;
overflow: visible;
border: 0;
background: transparent;
box-shadow: none;
}
.app-header .market-tape {
display: flex;
flex: 1 1 auto;
align-items: center;
min-width: 0;
gap: 0;
overflow: hidden;
white-space: nowrap;
}
.tape-metric {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
min-width: 0;
padding: 0 16px;
}
.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: hidden;
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);
}
.tape-sentiment .sentiment-gauge {
position: absolute;
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: 14px;
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: 6px;
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: 1439px) {
.tape-optional {
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-page-context {
display: none;
}
.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: inline-flex;
}
.app-header .header-command-group {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 60;
display: none;
flex-direction: column;
align-items: stretch;
gap: 6px;
width: 240px;
padding: 8px;
overflow: visible;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
box-shadow: var(--shadow-float);
}
.app-header .header-command-group.is-open {
display: flex;
}
.app-header .header-command-group .command-button,
.app-header .header-command-group .account-button {
width: 100%;
justify-content: flex-start;
}
.app-header .header-command-group .account-menu-shell {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
}
.app-header .header-command-group .account-role-badges {
display: flex;
flex-wrap: wrap;
min-height: 0;
}
.app-header .header-command-group .account-dropdown {
left: 0;
right: auto;
}
}
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;
}
+12 -46
View File
@@ -56,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;
@@ -91,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(
@@ -209,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 -118
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,7 +147,6 @@
--space-8: 8px;
--space-12: 12px;
--space-16: 16px;
--space-20: 20px;
--space-24: 24px;
--sidebar-width: var(--size-sidebar);
@@ -249,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;
@@ -344,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);
+57 -433
View File
@@ -483,14 +483,14 @@ async function mockApplication(page, authSession = session(), options = {}) {
}],
};
} else if (url.pathname === "/api/mentors/setup") {
payload = { trade_date: "20260722", mentors: options.mentors || mentorDirectory(authSession.user.role) };
payload = { trade_date: "20260722", mentors: mentorDirectory(authSession.user.role) };
} else if (url.pathname === "/api/mentors/chat") {
await route.fulfill({
status: 200,
contentType: "application/x-ndjson; charset=utf-8",
body: [
JSON.stringify({ type: "delta", content: "## 判断\n先看市场结构。\n\n" }),
JSON.stringify({ type: "delta", content: "- 等待确认\n- 控制仓位" }),
JSON.stringify({ type: "delta", content: "**判断**\n先看市场结构。\n\n" }),
JSON.stringify({ type: "delta", content: "**操作**\n- 等待确认\n- 控制仓位" }),
JSON.stringify({
type: "meta",
data_trade_date: "20260722",
@@ -510,21 +510,12 @@ async function mockApplication(page, authSession = session(), options = {}) {
});
}
async function openHeaderCommandMenu(page) {
const menu = page.locator("#headerCommandGroup");
if (await menu.isVisible()) return;
await page.locator("#headerMenuButton").click();
await expect(menu).toBeVisible();
}
test("admin shell opens every primary workspace and global search", async ({ page }) => {
await mockApplication(page);
await page.goto("/index.html");
await expect(page.locator("#authGate")).toBeHidden();
await openHeaderCommandMenu(page);
await expect(page.locator("#settingsButton")).toBeVisible();
await expect(page.locator("#syncButton")).toBeVisible();
await page.keyboard.press("Escape");
await page.locator("#alertButton").click();
await expect(page.locator("#alertsDialog")).toBeVisible();
await page.locator("#closeAlertsDialog").click();
@@ -535,7 +526,7 @@ test("admin shell opens every primary workspace and global search", async ({ pag
const views = [
"auctionView", "sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView",
"performanceView", "ladderView", "rotationView", "themeLibraryView", "popularityView", "dragonView", "screenerView",
"heavenView", "reviewWorkspaceView", "mentorView",
"mentorView", "heavenView", "reviewWorkspaceView",
];
for (const view of views) {
await page.locator(`[data-view="${view}"]`).first().click();
@@ -576,7 +567,7 @@ test("every primary workspace shares the canonical desktop shell geometry", asyn
const views = [
"auctionView", "sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView",
"performanceView", "ladderView", "rotationView", "themeLibraryView", "popularityView", "dragonView",
"screenerView", "heavenView", "reviewWorkspaceView",
"screenerView", "mentorView", "heavenView", "reviewWorkspaceView",
];
let reference = null;
for (const view of views) {
@@ -598,7 +589,6 @@ test("manual refresh stays in place without reopening the full-page loader", asy
await page.goto("/index.html");
await expect(page.locator("#loadingOverlay")).toBeHidden();
await openHeaderCommandMenu(page);
await page.locator("#refreshButton").click();
await expect(page.locator("#refreshButton")).toBeDisabled();
@@ -2815,122 +2805,69 @@ test("mentor directory exposes evidence filters and private owner metadata", asy
await page.goto("/index.html");
await page.locator('[data-view="mentorView"]').first().click();
await expect(page.locator("#mentorView .mentor-sidebar .mentor-directory-tools")).toBeVisible();
await expect(page.locator("#mentorFilterToggle")).toBeVisible();
await expect(page.locator("#mentorView .mentor-sidebar .mentor-evidence-filters")).toBeVisible();
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(22);
await expect(page.locator('#mentorList [data-mentor-id="private-owner"] .mentor-badge.private')).toContainText("仅自己");
await expect(page.locator('#mentorList [data-mentor-id="source-a"] .mentor-badge.grade-a')).toHaveText("A");
await expect(page.locator('#mentorList [data-mentor-id="source-a"] .mentor-badge.grade-a')).toHaveText("A");
await expect(page.locator('#mentorList [data-mentor-id="source-c"] .mentor-badge.quality')).toHaveCount(0);
await page.locator("#mentorSearchInput").fill("行为推演");
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(1);
await expect(page.locator("#mentorCount")).toHaveText("1 / 22 位");
await page.locator("#mentorSearchInput").fill("");
await page.locator("#mentorFilterToggle").click();
await page.locator('[data-mentor-grade="B"]').click();
await expect(page.locator("#mentorFilterOptions [data-mentor-grade].active")).toHaveText("B级");
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(7);
await page.locator('#mentorList [data-mentor-id="source-b"]').click();
await expect(page.locator("#activeMentorName")).toHaveText("多源老师");
await expect(page.locator("#activeMentorStatus")).toContainText("确认之后再行动");
await expect(page.locator("#mentorProfileName")).toHaveText("多源老师");
await expect(page.locator("#mentorProfileBadges")).toHaveText("B");
await expect(page.locator("#activeMentorEvidence")).toHaveText("公开访谈与多源材料");
await expect(page.locator("#mentorView .mentor-chat-header")).toHaveCount(0);
await expect(page.locator("#mentorChatForm #clearMentorChatButton")).toBeVisible();
const avatarGeometry = await page.evaluate(() => {
const directoryAvatar = document.querySelector("#mentorList .mentor-option.active .mentor-avatar");
const profileAvatar = document.querySelector("#mentorProfileAvatar");
return {
directoryWidth: directoryAvatar.getBoundingClientRect().width,
directoryRadius: getComputedStyle(directoryAvatar).borderRadius,
profileWidth: profileAvatar.getBoundingClientRect().width,
profileRadius: getComputedStyle(profileAvatar).borderRadius,
};
});
expect(avatarGeometry.directoryWidth).toBeGreaterThanOrEqual(46);
expect(avatarGeometry.profileWidth).toBeGreaterThan(avatarGeometry.directoryWidth);
expect(avatarGeometry.profileRadius).toBe(avatarGeometry.directoryRadius);
const mentorLibrary = await page.locator("#mentorView .mentor-sidebar").boundingBox();
const mentorChat = await page.locator("#mentorView .mentor-chat-panel").boundingBox();
const mentorProfile = await page.locator("#mentorView .mentor-profile-panel").boundingBox();
const mentorInput = await page.locator("#mentorQuestion").boundingBox();
const mentorLayout = await page.locator("#mentorView .mentor-layout").boundingBox();
expect(Math.abs(mentorLibrary.width - 300)).toBeLessThanOrEqual(1);
expect(Math.abs(mentorLibrary.width - 280)).toBeLessThanOrEqual(1);
expect(Math.abs(mentorProfile.width - 272)).toBeLessThanOrEqual(1);
expect(Math.abs(mentorChat.x - (mentorLibrary.x + mentorLibrary.width))).toBeLessThanOrEqual(1);
expect(Math.abs(mentorChat.x + mentorChat.width - (mentorLayout.x + mentorLayout.width))).toBeLessThanOrEqual(1);
const composerForm = await page.locator("#mentorView .mentor-chat-form").boundingBox();
expect(composerForm.height).toBeGreaterThanOrEqual(81);
expect(composerForm.height).toBeLessThanOrEqual(91);
expect(mentorInput.height).toBeGreaterThanOrEqual(24);
expect(mentorInput.height).toBeLessThanOrEqual(34);
const searchField = await page.locator("#mentorView .mentor-search-field").boundingBox();
expect(searchField.width).toBeGreaterThan(160);
await expect(page.locator("#mentorSearchInput")).toHaveAttribute("placeholder", "搜索联系人或标签");
await expect(page.locator(".module-nav")).toBeVisible();
await expect(page.locator(".module-tab.active")).toHaveAttribute("data-view", "mentorView");
await expect(page.locator(".market-tape")).toBeVisible();
await expect(page.locator("#tradeDate")).toBeVisible();
await expect(page.locator(".overview-strip")).toBeVisible();
await expect(page.locator(".status-bar")).toBeVisible();
await expect(page.locator("#themeToggle")).toBeVisible();
await expect(page.locator("#mentorView .mentor-page-title h2")).toHaveText("问师");
await expect(page.locator("#mentorPageSubtitle")).toContainText("数据日期");
expect(Math.abs(mentorProfile.x - (mentorChat.x + mentorChat.width))).toBeLessThanOrEqual(1);
expect(mentorInput.height).toBeGreaterThanOrEqual(48);
expect(await page.evaluate(() => document.documentElement.scrollHeight - window.innerHeight)).toBeLessThanOrEqual(1);
await page.setViewportSize({ width: 1920, height: 947 });
const expandedMentorLayout = await page.locator("#mentorView .mentor-layout").boundingBox();
const footerBar = await page.locator("#mentorView .mentor-workspace-footer").boundingBox();
const shellStatusBar = await page.locator(".status-bar").boundingBox();
expect(Math.abs(shellStatusBar.y + shellStatusBar.height - 947)).toBeLessThanOrEqual(1);
expect(footerBar.y + footerBar.height).toBeLessThanOrEqual(shellStatusBar.y);
expect(Math.abs(expandedMentorLayout.y + expandedMentorLayout.height - footerBar.y)).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
const statusBar = await page.locator(".status-bar").boundingBox();
const lowerGap = statusBar.y - (expandedMentorLayout.y + expandedMentorLayout.height);
expect(lowerGap).toBeGreaterThanOrEqual(0);
expect(lowerGap).toBeLessThanOrEqual(16);
await page.locator("#overviewToggle").click();
const openOverviewLayout = await page.locator("#mentorView .mentor-layout").boundingBox();
const openOverviewGap = statusBar.y - (openOverviewLayout.y + openOverviewLayout.height);
expect(openOverviewGap).toBeGreaterThanOrEqual(0);
expect(openOverviewGap).toBeLessThanOrEqual(16);
expect(await page.evaluate(() => document.documentElement.scrollHeight - window.innerHeight)).toBeLessThanOrEqual(1);
});
test("mentor stays inside the project shell and switching pages leaves no residue", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
await page.locator('[data-view="mentorView"]').first().click();
await expect(page.locator("#mentorView")).toHaveClass(/active-view/);
await expect(page.locator(".module-nav")).toBeVisible();
await expect(page.locator(".module-tab.active")).toHaveAttribute("data-view", "mentorView");
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(22);
await expect(page.locator("#mentorView .mentor-page-title h2")).toHaveText("问师");
await page.locator('[data-view="limitPool"]').first().click();
await expect(page.locator("#limitPool")).toHaveClass(/active-view/);
await expect(page.locator("#mentorView")).not.toHaveClass(/active-view/);
await expect(page.locator("#mentorView")).not.toBeVisible();
await expect(page.locator(".module-nav")).toBeVisible();
await expect(page.locator(".module-tab.active")).toHaveAttribute("data-view", "limitPool");
await page.locator('[data-view="mentorView"]').first().click();
await expect(page.locator("#mentorView")).toHaveClass(/active-view/);
await expect(page.locator(".module-nav")).toBeVisible();
await expect(page.locator(".module-tab.active")).toHaveAttribute("data-view", "mentorView");
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(22);
await expect(page.locator("#mentorPageSubtitle")).toContainText("数据日期");
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
});
test("mentor keeps two columns and opens the profile floating dialog on desktop", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
await page.locator('[data-view="mentorView"]').first().click();
await page.locator('#mentorList [data-mentor-id="source-b"]').click();
await expect(page.locator("#mentorView .mentor-profile-panel")).toHaveCount(0);
const mentorLayout = await page.locator("#mentorView .mentor-layout").boundingBox();
const mentorLibrary = await page.locator("#mentorView .mentor-sidebar").boundingBox();
const mentorChat = await page.locator("#mentorView .mentor-chat-panel").boundingBox();
expect(Math.abs(mentorChat.x - (mentorLibrary.x + mentorLibrary.width))).toBeLessThanOrEqual(1);
expect(Math.abs(mentorChat.x + mentorChat.width - (mentorLayout.x + mentorLayout.width))).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
await page.locator("#mentorProfileButton").click();
await expect(page.locator("#mentorProfileDialog")).toBeVisible();
await expect(page.locator("#mentorProfileDialogName")).toHaveText("多源老师");
await expect(page.locator("#mentorProfileDialogEvidence")).toHaveText("公开访谈与多源材料");
await page.locator('[data-mentor-dialog-close="mentorProfileDialog"]').click();
await expect(page.locator("#mentorProfileDialog")).not.toBeVisible();
});
test("mentor pins, custom order and streamed replies work together", async ({ page }) => {
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
await page.locator('[data-view="mentorView"]').first().click();
await page.locator('[data-mentor-id="source-c"]').click();
await page.locator("#mentorPinButton").click();
await page.locator('[data-mentor-pin="source-c"]').click();
await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-c");
await expect(page.locator('#mentorList [data-mentor-card="source-c"] .mentor-badge.pinned')).toHaveText("置顶");
await page.locator('[data-mentor-id="source-b"]').click();
await page.locator("#mentorPinButton").click();
await page.locator('[data-mentor-pin="source-b"]').click();
await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-b");
await page.locator("#mentorSortToggle").click();
@@ -2942,22 +2879,19 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
await page.locator("#sendMentorQuestion").click();
const answer = page.locator("#mentorMessages .mentor-message.assistant").last();
await expect(answer).toContainText("先看市场结构。");
await expect(answer.locator(".mentor-message-content")).toHaveCount(2);
await expect(answer.locator(".mentor-answer-heading")).toHaveCount(2);
await expect(answer.locator(".mentor-answer-list li")).toHaveCount(2);
await expect(answer.locator("br")).toHaveCount(0);
await expect(page.locator("#mentorMessages .assistant-stream-caret")).toHaveCount(0);
const followUps = answer.locator("[data-mentor-follow-up]");
await expect(followUps).toHaveCount(3);
await expect(followUps.first().locator(".lucide")).toHaveCount(0);
await expect(answer.locator("small")).toHaveCount(0);
const messageCount = await page.locator("#mentorMessages .mentor-message").count();
await followUps.first().click();
await expect(page.locator("#mentorQuestion")).toHaveValue("哪些信号代表确认?");
await expect(page.locator("#mentorMessages .mentor-message")).toHaveCount(messageCount);
const userLabel = page.locator("#mentorMessages .mentor-message.user .mentor-message-label").first();
await expect(userLabel).not.toContainText("我 ·");
const assistantBody = await page.locator("#mentorMessages .mentor-message.assistant .mentor-message-body").last().boundingBox();
expect(assistantBody.width).toBeLessThanOrEqual(900);
await page.locator("#themeToggle").click();
await expect(page.locator("html")).not.toHaveClass(/theme-switching/);
const userMessage = page.locator("#mentorMessages .mentor-message.user");
const darkUserMessageStyle = await userMessage.evaluate((element) => ({
background: getComputedStyle(element).backgroundColor,
@@ -2967,140 +2901,34 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
}));
expect(darkUserMessageStyle.background).not.toBe("rgb(255, 255, 255)");
expect(darkUserMessageStyle.border).not.toBe("rgb(255, 255, 255)");
expect(darkUserMessageStyle.contentBackground).toBe("rgb(53, 89, 140)");
expect(darkUserMessageStyle.contentColor).toBe("rgb(234, 241, 251)");
expect(darkUserMessageStyle.contentBackground).toBe("rgb(35, 54, 74)");
expect(darkUserMessageStyle.contentColor).toBe("rgb(232, 234, 237)");
const darkMessageStyle = await answer.evaluate((element) => {
const style = getComputedStyle(element);
const content = element.querySelector(".mentor-message-content");
const contentStyle = getComputedStyle(content);
const heading = element.querySelector(".mentor-answer-heading");
const label = element.querySelector(".mentor-message-label");
const meta = element.querySelector("small");
return {
background: style.backgroundColor,
border: style.borderTopColor,
shadow: style.boxShadow,
contentColor: getComputedStyle(content).color,
contentBackground: contentStyle.backgroundColor,
contentBorder: contentStyle.borderTopColor,
contentColor: contentStyle.color,
headingColor: getComputedStyle(heading).color,
labelColor: getComputedStyle(label).color,
metaColor: getComputedStyle(meta).color,
};
});
expect(darkMessageStyle.background).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.border).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.background).toBe("rgba(0, 0, 0, 0)");
expect(darkMessageStyle.shadow).toBe("none");
expect(darkMessageStyle.contentBackground).toBe("rgb(32, 36, 40)");
expect(darkMessageStyle.contentBorder).toBe("rgb(52, 58, 64)");
expect(darkMessageStyle.contentColor).toBe("rgb(232, 234, 237)");
expect(darkMessageStyle.headingColor).toBe("rgb(232, 234, 237)");
expect(darkMessageStyle.labelColor).toBe("rgb(124, 130, 138)");
});
test("mentor avatars map per contact id and follow the final day/night palette", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
const mentors = [
{
id: "xiaobai-perspective", name: "小白", description: "个人复盘记录蒸馏", tagline: "复盘自己",
focus: ["个人复盘"], evidence: { grade: "A", label: "私有原始语料", note: "私人语料" }, quality: {}, private: true,
},
{
id: "kobe92-perspective", name: "52科比", description: "情绪周期心法", tagline: "先看周期",
focus: ["情绪周期"], evidence: { grade: "A", label: "心法文本", note: "心法" }, quality: {}, private: false,
},
{
id: "beijingchaojia-perspective", name: "北京炒家", description: "实盘记录", tagline: "实盘为先",
focus: ["实盘"], evidence: { grade: "A", label: "实盘资料", note: "实盘" }, quality: {}, private: false,
},
{
id: "chaojiyangjia-perspective", name: "炒股养家", description: "原始语料", tagline: "情绪为上",
focus: ["情绪"], evidence: { grade: "B", label: "原始语料", note: "语料" }, quality: {}, private: false,
},
];
await mockApplication(page, session("admin", true), { mentors });
await page.goto("/index.html");
await page.locator('[data-view="mentorView"]').first().click();
const expectations = {
"xiaobai-perspective": ["mentor-avatar-tone-violet", "rgb(238, 236, 253)", "rgb(106, 92, 245)"],
"kobe92-perspective": ["mentor-avatar-tone-blue", "rgb(227, 240, 255)", "rgb(51, 112, 255)"],
"beijingchaojia-perspective": ["mentor-avatar-tone-green", "rgb(221, 245, 229)", "rgb(46, 164, 79)"],
"chaojiyangjia-perspective": ["mentor-avatar-tone-orange", "rgb(253, 238, 221)", "rgb(217, 122, 27)"],
};
for (const [id, [tone, background, ink]] of Object.entries(expectations)) {
const avatar = page.locator(`#mentorList [data-mentor-id="${id}"] .mentor-avatar`);
await expect(avatar).toHaveClass(new RegExp(tone));
const palette = await avatar.evaluate((element) => {
const style = getComputedStyle(element);
return { background: style.backgroundColor, color: style.color };
});
expect(palette.background).toBe(background);
expect(palette.color).toBe(ink);
}
await page.locator('[data-mentor-id="kobe92-perspective"]').click();
await expect(page.locator("#activeMentorAvatar")).toHaveClass(/mentor-avatar-tone-blue/);
await page.locator("#themeToggle").click();
const nightPalette = await page.locator('#mentorList [data-mentor-id="beijingchaojia-perspective"] .mentor-avatar')
.evaluate((element) => {
const style = getComputedStyle(element);
return { background: style.backgroundColor, color: style.color };
});
expect(nightPalette.background).toBe("rgb(35, 74, 56)");
expect(nightPalette.color).toBe("rgb(95, 206, 143)");
await expect(page.locator("#activeMentorAvatar")).toHaveClass(/mentor-avatar-tone-blue/);
});
test("mentor floating dialogs open centered, save notes per account, and keep composer one-line", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
await page.locator('[data-view="mentorView"]').first().click();
await page.locator('#mentorList [data-mentor-id="source-b"]').click();
await page.locator("#mentorNoteButton").click();
await expect(page.locator("#mentorNoteDialog")).toBeVisible();
const noteDialog = await page.locator("#mentorNoteDialog").boundingBox();
expect(noteDialog.width).toBeLessThanOrEqual(400);
expect(Math.abs(noteDialog.x + noteDialog.width / 2 - 720)).toBeLessThanOrEqual(3);
expect(Math.abs(noteDialog.y + noteDialog.height / 2 - 450)).toBeLessThanOrEqual(3);
await page.locator("#mentorNoteInput").fill("多源老师:确认后再行动");
await expect(page.locator("#mentorNoteInput")).toHaveValue("多源老师:确认后再行动");
await page.locator('[data-mentor-dialog-close="mentorNoteDialog"]').click();
await expect(page.locator("#mentorNoteDialog")).not.toBeVisible();
await page.locator("#mentorNoteButton").click();
await expect(page.locator("#mentorNoteInput")).toHaveValue("多源老师:确认后再行动");
await page.locator('[data-mentor-dialog-close="mentorNoteDialog"]').click();
await page.locator("#mentorProfileButton").click();
await expect(page.locator("#mentorProfileDialog")).toBeVisible();
await expect(page.locator("#mentorProfileDialogName")).toHaveText("多源老师");
await expect(page.locator("#mentorProfileDialogEvidence")).toHaveText("公开访谈与多源材料");
const profileDialog = await page.locator("#mentorProfileDialog").boundingBox();
expect(profileDialog.width).toBeLessThanOrEqual(400);
expect(Math.abs(profileDialog.x + profileDialog.width / 2 - 720)).toBeLessThanOrEqual(3);
await page.locator('[data-mentor-dialog-close="mentorProfileDialog"]').click();
await expect(page.locator("#mentorProfileDialog")).not.toBeVisible();
const disclaimerStyle = await page.locator("#mentorView .mentor-disclaimer").evaluate((el) => ({
align: getComputedStyle(el).textAlign,
}));
expect(disclaimerStyle.align).toBe("center");
const composerHint = page.locator("#mentorView .mentor-composer-hint");
await expect(composerHint).toHaveCount(1);
await expect(composerHint).toBeVisible();
const composerInput = page.locator("#mentorQuestion");
const oneLineHeight = (await composerInput.boundingBox()).height;
expect(oneLineHeight).toBeGreaterThanOrEqual(24);
expect(oneLineHeight).toBeLessThanOrEqual(34);
await composerInput.fill("第一行\n第二行");
const grownHeight = (await composerInput.boundingBox()).height;
expect(grownHeight).toBeGreaterThan(oneLineHeight + 8);
await composerInput.press("Shift+Enter");
await composerInput.type("第三行");
await composerInput.fill("只发送这一行");
await page.locator("#sendMentorQuestion").click();
await expect(page.locator("#mentorMessages .mentor-message.user").last()).toContainText("只发送这一行");
const resetHeight = (await composerInput.boundingBox()).height;
expect(resetHeight).toBeLessThanOrEqual(34);
expect(darkMessageStyle.labelColor).toBe("rgb(127, 137, 147)");
expect(darkMessageStyle.metaColor).toBe("rgb(127, 137, 147)");
});
test("global dialogs share the stage 18 geometry without changing account or admin access", async ({ page }) => {
@@ -3144,7 +2972,6 @@ test("global dialogs share the stage 18 geometry without changing account or adm
expect(assistantGeometry.contentDisplay).toBe("flex");
await page.locator("#closeAssistantDialog").click();
await openHeaderCommandMenu(page);
await page.locator("#accountButton").click();
await page.locator('[data-account-panel="membership"]').click();
await expect(page.locator("#settingsDialog")).toHaveAttribute("aria-labelledby", "accountDialogTitle");
@@ -3155,7 +2982,6 @@ test("global dialogs share the stage 18 geometry without changing account or adm
expect(Math.abs(accountBox.y + accountBox.height / 2 - 450)).toBeLessThanOrEqual(2);
await page.locator("#closeSettingsDialog").click();
await openHeaderCommandMenu(page);
await page.locator("#settingsButton").click();
await expect(page.locator("#adminDialog")).toHaveAttribute("aria-labelledby", "adminDialogTitle");
await expect(page.locator("#adminSectionSelect")).toBeVisible();
@@ -3175,205 +3001,3 @@ test("global dialogs share the stage 18 geometry without changing account or adm
expect(mobileGeometry.width).toBeLessThanOrEqual(375);
expect(mobileGeometry.documentOverflow).toBeLessThanOrEqual(1);
});
test("merged header keeps date, detail fields and 13 pool columns after dual-review rework", async ({ page }) => {
const previousLimits = dashboard.limits;
dashboard.limits = [{
code: "002141",
name: "贤丰控股",
streak: 5,
change: 10.02,
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: "板块龙头连板打开空间",
}];
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
dashboard.limits = previousLimits;
await page.waitForTimeout(400);
await expect(page.locator("#currentPageSubtitle")).toContainText("市场复盘 ·");
await expect(page.locator("#headerMenuButton")).toBeVisible();
await expect(page.locator("#headerCommandGroup")).toBeHidden();
await expect(page.locator("#themeToggle")).toBeVisible();
await expect(page.locator("#refreshButton")).toBeHidden();
await page.locator('[data-view="limitPool"]').first().click();
await expect(page.locator("#currentPageSubtitle")).toContainText("市场复盘 ·");
await expect(page.locator("#currentPageSubtitle")).not.toHaveText("市场复盘");
await page.setViewportSize({ width: 1280, height: 800 });
await expect(page.locator(".tape-optional").first()).toBeHidden();
await page.locator("#overviewToggle").click();
const detailText = await page.locator(".tape-detail").innerText();
for (const label of ["市场情绪", "上涨家数", "下跌家数", "涨停", "跌停", "炸板", "封板率", "两市成交", "数据日期"]) {
expect(detailText).toContain(label);
}
expect(detailText).not.toContain("涨停 / 跌停");
await expect(page.locator("#tapeLimitDown")).toBeVisible();
await expect(page.locator("#detailBroken")).toBeVisible();
await expect(page.locator("#dataDateMetric")).toBeVisible();
await expect(page.locator(".tape-detail-date")).toBeVisible();
expect(await page.locator("#dataDateMetric").evaluate((node) => Boolean(node.closest(".metric-wide")))).toBe(false);
await page.keyboard.press("Escape");
const measurePoolTable = () => {
const wrap = document.querySelector("#limitPool .pool-table-card");
const table = document.querySelector("#limitTable");
const reason = document.querySelector("#limitTable .reason-column");
const reasonCell = document.querySelector("#limitTable .pool-reason-cell");
const headerCell = document.querySelector("#limitPool .data-table thead th");
const rowCell = document.querySelector("#limitPool .data-table tbody td");
const cells = [...document.querySelectorAll("#limitTable tbody td")];
const numberCells = [...document.querySelectorAll("#limitTable tbody td.num, #limitTable tbody td.number")];
const header = document.querySelector(".app-header");
const headerStyle = headerCell ? getComputedStyle(headerCell) : null;
const rowStyle = rowCell ? getComputedStyle(rowCell) : null;
const widths = cells.map((cell) => cell.getBoundingClientRect().width);
const numberWidths = numberCells.map((cell) => cell.getBoundingClientRect().width);
return {
tableLayout: table ? getComputedStyle(table).tableLayout : "",
columnCount: document.querySelectorAll("#limitTable thead th").length,
minCellWidth: widths.length ? Math.min(...widths) : 0,
minNumberWidth: numberWidths.length ? Math.min(...numberWidths) : 0,
wrapOverflow: wrap ? wrap.scrollWidth - wrap.clientWidth : 0,
tableWidth: table ? table.getBoundingClientRect().width : 0,
wrapWidth: wrap ? wrap.getBoundingClientRect().width : 0,
cardOverflow: wrap ? wrap.scrollWidth - wrap.clientWidth : 0,
reasonVisible: Boolean(reason && wrap && reason.getBoundingClientRect().right <= wrap.getBoundingClientRect().right + 1),
reasonText: reason ? reason.textContent.trim() : "",
reasonTitle: reasonCell?.getAttribute("title") || "",
headerOverflow: header ? header.scrollWidth - header.clientWidth : 0,
headerHeight: headerStyle ? Number.parseFloat(headerStyle.height) : 0,
headerFont: headerStyle ? Number.parseFloat(headerStyle.fontSize) : 0,
rowHeight: rowStyle ? Number.parseFloat(rowStyle.height) : 0,
};
};
const pool1280 = await page.evaluate(measurePoolTable);
expect(pool1280.columnCount).toBe(13);
expect(pool1280.tableLayout).toBe("auto");
expect(pool1280.minCellWidth).toBeGreaterThanOrEqual(40);
expect(pool1280.minNumberWidth).toBeGreaterThanOrEqual(50);
expect(pool1280.wrapOverflow).toBeGreaterThan(0);
expect(pool1280.reasonTitle).toContain("板块龙头连板打开空间");
expect(pool1280.headerHeight).toBe(36);
expect(pool1280.rowHeight).toBeGreaterThanOrEqual(40);
await page.setViewportSize({ width: 1600, height: 1000 });
await page.locator('[data-view="limitPool"]').first().click();
const tableFit = await page.evaluate(measurePoolTable);
expect(tableFit.reasonText).toContain("涨停原因");
expect(tableFit.reasonVisible).toBe(true);
expect(tableFit.cardOverflow).toBeLessThanOrEqual(1);
expect(tableFit.headerOverflow).toBeLessThanOrEqual(1);
expect(tableFit.reasonTitle).toContain("板块龙头连板打开空间");
expect(tableFit.headerHeight).toBe(36);
expect(tableFit.headerFont).toBe(12.5);
expect(tableFit.rowHeight).toBeGreaterThanOrEqual(40);
expect(tableFit.tableLayout).toBe("fixed");
expect(tableFit.columnCount).toBe(13);
await page.locator('[data-view="heavenView"]').first().click();
await expect(page.locator(".app-header .overview-strip")).toBeHidden();
await expect(page.locator("#overviewToggle")).toBeHidden();
await page.setViewportSize({ width: 390, height: 844 });
await page.locator('[data-view="limitPool"]').first().click({ force: true });
const pool390 = await page.evaluate(measurePoolTable);
expect(pool390.columnCount).toBe(13);
expect(pool390.tableLayout).toBe("auto");
expect(pool390.minCellWidth).toBeGreaterThanOrEqual(40);
expect(pool390.minNumberWidth).toBeGreaterThanOrEqual(50);
expect(pool390.wrapOverflow).toBeGreaterThan(0);
expect(pool390.reasonTitle).toContain("板块龙头连板打开空间");
await page.locator("#mobileMarketViewSelect").selectOption("sentimentCycleView");
await page.locator("#overviewToggle").click();
const mobileDetail = await page.locator(".tape-detail").innerText();
for (const label of ["市场情绪", "上涨家数", "下跌家数", "涨停", "跌停", "炸板", "封板率", "两市成交", "数据日期"]) {
expect(mobileDetail).toContain(label);
}
await expect(page.locator("#dataDateMetric")).toBeVisible();
});
test("B-199 screener review and account surfaces fit day night viewports", async ({ page }) => {
const fs = require("node:fs");
const path = require("node:path");
const shotDir = path.join(__dirname, "../../runtime/b199-shots");
fs.mkdirSync(shotDir, { recursive: true });
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(String(error)));
const overflowX = () => page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
const shot = (name) => page.screenshot({ path: path.join(shotDir, `${name}.png`), fullPage: true });
await mockApplication(page, session("admin", true));
await page.setViewportSize({ width: 1600, height: 1000 });
await page.goto("/index.html");
await page.locator('[data-view="screenerView"]').first().click();
await expect(page.locator("#screenerView")).toHaveClass(/active-view/);
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("screener-1600-day");
await page.locator('[data-view="reviewWorkspaceView"]').first().click();
await expect(page.locator("#reviewWorkspaceView")).toHaveClass(/active-view/);
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("review-1600-day");
await openHeaderCommandMenu(page);
await page.locator("#accountButton").click();
await page.locator('[data-account-panel="membership"]').click();
await expect(page.locator("#settingsDialog")).toBeVisible();
await shot("account-1600-day");
await page.locator("#closeSettingsDialog").click();
await openHeaderCommandMenu(page);
await page.locator("#settingsButton").click();
await expect(page.locator("#adminDialog")).toBeVisible();
await shot("admin-1600-day");
await page.locator("#closeAdminDialog").click();
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await page.locator('[data-view="screenerView"]').first().click();
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("screener-1600-night");
await page.locator('[data-view="reviewWorkspaceView"]').first().click();
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("review-1600-night");
await page.setViewportSize({ width: 1280, height: 800 });
await page.locator('[data-view="screenerView"]').first().click();
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("screener-1280-night");
await page.locator('[data-view="reviewWorkspaceView"]').first().click();
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("review-1280-night");
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await page.locator('[data-view="screenerView"]').first().click();
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("screener-1280-day");
await page.locator('[data-view="reviewWorkspaceView"]').first().click();
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("review-1280-day");
await page.locator('[data-view="screenerView"]').first().click();
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator("#screenerView")).toHaveClass(/active-view/);
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("screener-390-day");
await page.locator(".mobile-primary-tab[data-view=\"reviewWorkspaceView\"]").click();
await expect(page.locator("#reviewWorkspaceView")).toHaveClass(/active-view/);
expect(await overflowX()).toBeLessThanOrEqual(1);
await shot("review-390-day");
expect(pageErrors).toEqual([]);
});
+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()