Compare commits

...
Author SHA1 Message Date
施工员andmultica-agent a6e8ad242d fix(HEL-79): Sheet 关闭后不再拦截指针(pointer-events 修复)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 12:16:23 +08:00
施工员andmultica-agent 38f307d591 feat(HEL-79): 手机端 P2a 表格引擎+六个池/榜列表页
- 新增 StickyHScrollTable:冻结表头/首列、横滑、pan-x 触控
- 涨停池/炸板池/跌停池/昨日涨停/涨停表现/人气榜接真数据(沿用电脑接口)
- 功能页顶栏返回+标题+日期按钮;底部日期抽屉(日历网格+快捷胶囊)
- 点名称/代码弹底部 DetailSheet(走 /api/stock/{code}/preview)
- 加载骨架/空态/错误态按动效规范;列配置进 nav.config
- 其余 6 个行情页仍为占位(归 P2b)

Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 11:59:07 +08:00
施工员andmultica-agent a92caa4f3f feat(HEL-76): 手机端丝滑度整改②——换页过渡与启动登录反馈
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 11:11:17 +08:00
施工员andmultica-agent 7752648e4b feat(HEL-75): 手机端丝滑度整改①——动效Token/按压反馈/日夜平滑/空态入场
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 10:35:50 +08:00
施工员andmultica-agent 6174efaee5 fix(HEL-72): [hidden] 规则强制 display:none,修复 tabbar/返回键被 display 顶掉
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 09:54:55 +08:00
施工员andmultica-agent 574a375292 feat(HEL-72): 手机端底部五入口导航栏改版
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 09:22:03 +08:00
施工员andmultica-agent 7bf5d8c2b6 feat(HEL-70): 手机端 P1 四大图标页(行情/工具/复盘/系统)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 08:47:34 +08:00
6144a480c7 fix(HEL-69): serve_static 目录 index 兜底,闸门改跳 /m/
审核通过后部署侧小改:目录请求返回 index.html,避免 /m/ 回退桌面页;分流闸门同步改用干净 /m/ 地址。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 01:47:53 +08:00
施工员andmultica-agent b213b4362a feat: HEL-68 手机端 P0 同网址分流闸门与手机壳
在电脑端 index.html head 加极早分流闸门(?ui 覆盖优先,720px/移动 UA 判定,首屏前跳转 /m/);新建 frontend/m/ 手机壳(index.html、tokens/shell CSS、api/session/router/boot JS、nav.config),首页五入口、占位图标页/功能页、登录页、日/夜主题与栈式返回。未改电脑 DOM/样式,未碰问天。

Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 01:34:07 +08:00
施工员andmultica-agent 1b333b6b93 docs: 修正交接手册当前提交号并更新任务清单状态
Co-authored-by: multica-agent <github@multica.ai>
2026-08-23 14:12:23 +08:00
施工员andmultica-agent 224e2a7f21 docs: 整理 app/docs 交接手册(需求/进度/任务/首页)并标记旧版 HANDOFF
Co-authored-by: multica-agent <github@multica.ai>
2026-08-23 14:07:07 +08:00
施工员andmultica-agent ed9858e330 fix: align auction/themes/popularity header right-side icons with other pages
Remove the stale body[data-active-view] .overview-strip { flex: 0 0 auto }
override from the three market-insight page stylesheets. Since the merged
header (B-147) moved the market tape into .app-header, this rule no longer
targets the old in-main overview strip and instead overrides the shared
shell rule (.app-header .overview-strip { flex: 1 0 auto }), which pushed the
header-actions icons off the right edge on auction, theme library and
popularity pages. Deleting the override lets the shared shell owner drive the
tape growth so the right-side icons sit flush like every other page.

Regenerate architecture-inventory.json line metrics for the CSS change.

Co-authored-by: multica-agent <github@multica.ai>
2026-08-21 23:31:37 +08:00
3ae07b8aae B-221: keep header tape readable and commands inside the viewport
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 15:24:38 +08:00
5a507239a3 B-217: show full header username without 108px truncation
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 13:39:22 +08:00
152c0000ce B-214: keep desktop header commands visible instead of behind ellipsis
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 12:39:22 +08:00
cf7183d0c0 fix: restore B-147 dialog tokens and unclip screener headers
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 08:18:05 +08:00
22d4dcd339 feat: apply B-147 visual tokens to screener, review and account surfaces
Unify intelligent screener, review workspace, and account/admin chrome with shared tokens, table density, and left-aligned first columns.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 06:39:25 +08:00
ee37223722 fix: left-align theme rank numbers and bind aux font size
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 05:13:40 +08:00
ad08d309c6 feat: apply B-147 visual tokens to remaining market data pages
Unify ladder, themes, rotation, auction, dragon-tiger and popularity with shared tokens, left-aligned first columns, and table density.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 04:34:36 +08:00
6ab910eeea fix: isolate limit-pool fixed layout to 1440+ breakpoints
Keep the 1600 no-overflow table, but restore natural column widths
and horizontal scrolling at 1280 and 390 so cells are readable.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 04:04:59 +08:00
f67100929b fix: fit 13 limit-pool columns at 1600 and restyle leftover tokens
Keep the limit-up table inside the card without horizontal scroll,
raise header/row metrics to the B-147 spec, restore night sort accent,
and switch the day-mode repair badge to the shared blue token.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 03:35:40 +08:00
df45638edd fix: keep tape date visible and finish dual-review shell polish
Give the overview date its own class so mobile cannot hide it, keep mentor
subtitles fully readable, and align sentiment table header tokens.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 02:31:52 +08:00
bb67085dd4 fix: merge B-157 and B-158 shell review blockers
Keep 1024-1439 broken/seal metrics in the detail panel, fold desktop admin actions into the ellipsis menu, and fit the 13-column limit pool at 1600 without hiding the data date.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 02:19:04 +08:00
e778c883db feat: apply B-147 visual tokens to shell and three representative pages
Closes B-156

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 00:43:09 +08:00
MS-01-Codexandmultica-agent b60a4cb682 fix: reload sentiment cycle history after backfill
backfillData() only refreshed system settings after a successful backfill,
leaving state.sentimentHistory/sentimentHistoryKey populated with the stale
pre-backfill cache, so an already-open sentiment cycle page kept showing the
old one-day history.

After a successful backfill, clear both cache fields and, when the sentiment
cycle view is active, force loadSentimentHistory(true) so the history renders
immediately. Add a frontend contract test covering the refresh path.

Co-authored-by: multica-agent <github@multica.ai>
2026-08-18 21:52:15 +08:00
MS-01-Codexandmultica-agent 74304795ec test: cover m0004 add_mentor_note migration paths
Co-authored-by: multica-agent <github@multica.ai>
2026-08-18 20:12:18 +08:00
MS-01-Codexandmultica-agent 0fba71f62c fix: register m0004 add_mentor_note migration
Co-authored-by: multica-agent <github@multica.ai>
2026-08-18 18:18:45 +08:00
MS-01-Codexandmultica-agent e0cba74f8e fix: reconnect mentor page into the project shell
Remove the mentor immersive rules in shared/shell.css that hid the module
nav, header actions, market tape, overview strip and status bar while
mentorView was active, which made the page look like an independent site.
Mentor now stays inside the canonical 小白复盘 shell and owns its own
"问师 + 数据日期" title header inside #mentorView (B-92 content intact).

Also fix the shell's latent 1024px overflow: .main min-width 1080 now only
applies at >=1280, and the overview strip scrolls internally instead of
clipping its right side when it is wider than the content column.

Update the contract and e2e baselines to assert the shell stays visible
and usable on mentor, page switching leaves no residue, and add a
dedicated shell-integration e2e test.

Co-authored-by: multica-agent <github@multica.ai>
2026-08-18 17:19:03 +08:00
MS-01-Codexandmultica-agent 39b6f71443 fix: align mentor page with final day/night visual review
Resolve B-98 visual review deviations and B-97 atomicity finding:
- Scoped mentorView header to title/subtitle + theme toggle only (hides
  date, refresh, background refresh, system management and account menus).
- Map contact avatars by stable id to the final day/night palette (52科比
  and self stay blue; others use the final violet/green/orange/red/teal/
  purple/yellow tones).
- Grade badges read A级/B级/C级; pinned contacts render a pin + 置顶
  badge; row-side pin controls are removed (chat header keeps pinning).
- Chat header drops grade badges; subtitle shows the active contact
  description/tagline.
- Assistant message column caps at 900px and centers on wide screens;
  user bubble keeps its 60% cap.
- Widen the directory search field (~218px target), keep filter/sort on
  the same row, and make the filter button icon-only (no chevron).
- Follow-up links drop the leading icon; no '日期 · 回答完成' meta line;
  follow-up divider is 382px and left-aligned with the text column.
- Own-message meta shows time only; composer height converges to ~85px
  while keeping auto-grow and the 168px cap.
- Selected contact right inset ~10px, header action radius 8px, and a
  thin list scrollbar instead of the reserved gutter.
- Remove the iFinD settings switch fragment from index.html so the mentor
  commit no longer carries half of the parallel iFinD work; the id
  contract test now tolerates dangling references introduced only by
  uncommitted working-tree edits.
- Add contract + e2e assertions for the header visibility, avatar tone
  mapping, badge copy, chat subtitle, message max-width, composer height,
  and follow-up area.

Co-authored-by: multica-agent <github@multica.ai>
2026-08-18 13:15:10 +08:00
MS-01-Codexandmultica-agent 8ac3adbb5d feat: restore mentor page to final day/night spec
Rebuild the mentor workspace per the approved final design (day.png/night.png):
- mentorView gets a dedicated full-width immersive shell (independent 56px
  top bar with page title slot + theme mode text, hidden module-nav/market
  tape/overview/status bar) scoped to body[data-active-view=mentorView].
- Assistant messages become borderless body text with name/time above; only
  user messages use a blue bubble; keep loading/error/streaming caret states.
- 4 quick topics stay visible with history; composer restored to a framed
  ~94px card with bottom-left shortcut hint and bottom-right send button.
- Directory tools merged into one row (search + filter menu + sort); the
  filter menu still offers all/A/B/C; list selected state is an inset rounded
  fill; contact rows are borderless 75px items.
- Chat header always shows pin/note/profile/clear; pin reuses /api/mentors/
  preferences; theme toggle stays the single #themeToggle.
- Night tokens match the spec including the two distinct blues (#316FEF link,
  #5B8DEF quote/selected icon). Mobile (<768) stacks panels with no horizontal
  overflow; 1024+ follows the desktop spec.
- Update stale test baselines (300px sidebar, 94px composer, hint presence,
  night bubble color, centered disclaimer) and regenerate the architecture
  inventory.

Co-authored-by: multica-agent <github@multica.ai>
2026-08-18 11:54:57 +08:00
61 changed files with 7200 additions and 1464 deletions
+7 -1
View File
@@ -1,8 +1,14 @@
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)
MIGRATIONS = (
M0001_ADOPT_LEGACY,
M0002_JOB_RUNS,
M0003_LLM_AUDIT,
M0004_MENTOR_NOTES,
)
__all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"]
@@ -0,0 +1,24 @@
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",
)
+7
View File
@@ -104,6 +104,13 @@ class HttpTransportMixin:
except ValueError:
self.send_error(HTTPStatus.FORBIDDEN)
return
if candidate.is_dir():
candidate = (candidate / "index.html").resolve()
try:
candidate.relative_to(STATIC_DIR.resolve())
except ValueError:
self.send_error(HTTPStatus.FORBIDDEN)
return
if not candidate.is_file():
candidate = STATIC_DIR / "index.html"
try:
+45 -45
View File
@@ -370,29 +370,29 @@
}
],
"css_layers": [
"/shared/tokens.css?v=20260806-2",
"/shared/tokens.css?v=20260820-3",
"/shared/base.css?v=20260806-1",
"/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/shell.css?v=20260820-8",
"/shared/auth.css?v=20260820-5",
"/shared/components/controls.css?v=20260820-2",
"/shared/components/navigation.css?v=20260820-1",
"/shared/components/cards.css?v=20260820-1",
"/shared/components/tables.css?v=20260820-1",
"/shared/components/dialogs.css?v=20260820-3",
"/shared/components/feedback.css?v=20260806-1",
"/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=20260806-2",
"/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/heaven/foundation.css?v=20260806-2",
"/pages/review/foundation.css?v=20260806-1"
"/pages/review/foundation.css?v=20260820-4"
],
"frontend_composition": {
"shell": "frontend/index.html",
@@ -441,8 +441,8 @@
},
{
"path": "frontend/pages/screener/foundation.css",
"bytes": 102987,
"lines": 6565
"bytes": 103547,
"lines": 6576
},
{
"path": "frontend/pages/heaven/page.js",
@@ -451,8 +451,8 @@
},
{
"path": "frontend/shared/shell.css",
"bytes": 51978,
"lines": 3224
"bytes": 63550,
"lines": 3757
},
{
"path": "backend/features/heaven/engine.py",
@@ -461,8 +461,8 @@
},
{
"path": "frontend/index.html",
"bytes": 45846,
"lines": 643
"bytes": 47131,
"lines": 646
},
{
"path": "backend/features/screener/catalog.py",
@@ -471,8 +471,8 @@
},
{
"path": "frontend/pages/auction/foundation.css",
"bytes": 34990,
"lines": 2409
"bytes": 35247,
"lines": 2416
},
{
"path": "database.py",
@@ -531,7 +531,7 @@
},
{
"path": "frontend/pages/pools/page.html",
"bytes": 14958,
"bytes": 14942,
"lines": 235
},
{
@@ -541,8 +541,8 @@
},
{
"path": "frontend/shared/admin.js",
"bytes": 13975,
"lines": 256
"bytes": 14145,
"lines": 261
},
{
"path": "backend/features/heaven/market_context.py",
@@ -574,6 +574,11 @@
"bytes": 10539,
"lines": 244
},
{
"path": "frontend/shared/dashboard.js",
"bytes": 9993,
"lines": 220
},
{
"path": "backend/data/providers/tushare_sectors.py",
"bytes": 9876,
@@ -600,9 +605,9 @@
"lines": 238
},
{
"path": "frontend/shared/dashboard.js",
"bytes": 8424,
"lines": 194
"path": "frontend/pages/mentor/page.html",
"bytes": 8357,
"lines": 116
},
{
"path": "backend/features/screener/formula.py",
@@ -644,11 +649,6 @@
"bytes": 6202,
"lines": 141
},
{
"path": "frontend/pages/mentor/page.html",
"bytes": 6190,
"lines": 89
},
{
"path": "backend/features/screener/selection.py",
"bytes": 6092,
@@ -704,6 +704,11 @@
"bytes": 4276,
"lines": 91
},
{
"path": "frontend/shared/theme.js",
"bytes": 4258,
"lines": 117
},
{
"path": "backend/features/screener/engine.py",
"bytes": 4242,
@@ -714,11 +719,6 @@
"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": 3309,
"bytes": 3316,
"lines": 55
},
{
+5
View File
@@ -1,3 +1,8 @@
> ⚠️ **本文档已过时,仅留档备查,请勿删除。**
> 本交接说明核实于 2026-08-06,其中「当前提交」「当前状态」「正在处理的事项」「验证记录」等已与代码现状不符(当时的未提交改动现已合并,项目已推进到全站视觉统一收尾阶段)。
> 最新内容请看 `docs/项目需求.md`、`docs/最新进度.md`、`docs/任务清单.md` 和 `docs/README.md`。
> 架构与维护规矩仍以根目录 `AGENTS.md`、`ARCHITECTURE.md` 为准;本文第 2、6 节(架构与决策)仍可作参考。
# 小白复盘项目交接说明
> 核实日期:2026-08-06Asia/Shanghai
+33 -9
View File
@@ -1,11 +1,35 @@
# 文档索引
# 小白复盘 · 交接手册首页(首页说明)
- `product/小白复盘-完整产品规格说明书.md`:从零恢复产品时的完整功能与行为资产
- `HANDOFF.md`:当前仓库、架构、完成度、风险和后续验收的交接基线。
- `issues/README.md`:尚未完成事项的独立 Issue 索引;Issue 不是已创建的 Gitea 工单。
- `maintenance/人工维护指南.md`:当前正式源码的启动、修改、验收、数据和回退流程。
- `governance/`:架构决策、注册表治理和历次结构治理记录。
- `migration/`:从旧根目录保真迁入`app/`的历史账本、证据和失败版本记录。
> 一句话:这是「小白复盘」项目的交接手册入口。新来的智能体(或人)先看这一页,再按下面顺序读四份文档,就能知道这个项目是干什么的、干到哪了、下一步做什么
日常维护优先阅读根目录`AGENTS.md``ARCHITECTURE.md``HANDOFF.md`和维护指南。`migration/`只用于审计与
追溯,不参与应用启动、测试选择或运行时路径解析。
## 先读哪些文件(按顺序)
1. `项目需求.md` —— 这个项目是干什么的、要解决什么问题、有哪些功能。
2. `最新进度.md` —— 目前整体做到哪一步了。
3. `任务清单.md` —— 正在做 / 已做完 / 还没安排,三栏一目了然。
4. 本文件 `README.md` —— 就是你现在看的这一页。
读完上面四份,就算“接手”了。想深入了解实现细节,再往下读。
## 想深入了解时再读这些
- `product/小白复盘-完整产品规格说明书.md` —— 最完整、最权威的“产品需求”说明书,从零重建项目都用它。
- `maintenance/人工维护指南.md` —— 怎么启动、怎么改代码、怎么跑测试、怎么备份和回退。
- `governance/` —— 架构决策和历次结构治理记录。
- `migration/` —— 从旧代码保真迁进 `app/` 的历史账本和证据,只用于审计和追溯,不参与运行。
- 根目录的 `AGENTS.md`(维护硬规矩)、`ARCHITECTURE.md`(技术架构)。
## 更新规矩(每完成或新增一个任务都要做)
任何智能体完成或新增一个任务后,必须顺手把这份手册更新到位,不能只改代码:
1. 任务做完或新增 → 更新 `任务清单.md`:把任务从「正在做」挪到「已做完」,或把新任务加进对应栏目。
2. 整体进度变了 → 更新 `最新进度.md`
3. 需求或功能变了 → 更新 `项目需求.md`(重大变化还要同步 `product/` 里的完整说明书)。
4. 更新完提交并推送进仓库(保存并上传到放代码的网站),不能只留在自己电脑里。
## 注意事项
- 旧文档不能删:被替代的旧文档开头要加一行「⚠️ 本文档已过时,仅留档备查,请勿删除」,再写新版。
- 用中文大白话写,专业词要带通俗解释,让不懂代码的人也能看懂。
- 「问天」板块是冻结区,任何改动都不许碰;写文档时别误导后来人去改它。
+1 -1
View File
@@ -16,4 +16,4 @@
| [010](ISSUE-010-live-provider-llm-readiness.md) | 数据源与 LLM 在线就绪核验 | 待运行核验 | P0 |
| [011](ISSUE-011-public-deployment-hardening.md) | 公网部署加固 | 未来范围 | P3 |
关闭任一 Issue 前,必须更新 `docs/HANDOFF.md` 的状态、验证日期和回档提交;不能只改 Issue 标题。
关闭任一 Issue 前,必须同步更新 `docs/最新进度.md``docs/任务清单.md` 的状态、验证日期和回档提交;不能只改 Issue 标题。旧版 `docs/HANDOFF.md` 已过时,仅留档备查。
+35
View File
@@ -0,0 +1,35 @@
# 任务清单
> 分三栏:正在做 / 已做完 / 还没安排。以任务板(Multica)和仓库内 `issues/` 目录为准,核实日期 2026-08-23。
## 正在做
| 任务 | 说明 | 状态 |
|---|---|---|
| 全站视觉统一改造收尾 | 主线。17 个阶段已完成,正在最终验收、代码合并 | 收尾中 |
| 手机端独立重新设计 | 先出视觉/交互规范和技术架构方案,等老板确认后再施工 | 方案送审中 |
## 已做完
| 任务 | 说明 |
|---|---|
| 全站视觉统一改造(17 个阶段) | 公共外壳、各市场数据页、智能工具页、账户页、顶栏布局返工等,逐批通过功能 + 视觉双重审核 |
| 数据库历史问题修复收口 | 老数据未被改动 |
| 顶端栏图标对齐 | 集合竞价 / 题材库 / 人气热榜三页,已部署 `.36:8765`(提交 `ed9858e` |
| 情绪周期页头改造等页面细节 | 去背景框、圆角分段控件 / 导出按钮(任务卡 HEL-31/36/42 等) |
| 架构治理(保真迁移) | 从旧根目录迁进 `app/`,模块化单体 + 注册表 + 统一验收工具,2026-08-01 人工验收 |
| 仓库交接手册整理 | 四份中文交接文档(需求 / 进度 / 任务 / 首页),已提交推送(提交 `224e2a7` |
## 还没安排
| 任务 | 说明 | 大致优先级 |
|---|---|---|
| 游资档案历史画像 | 更完整的游资历史操作画像 | 低 |
| 完整 IC 动态多因子选股 | 需要约 12 个月历史数据做因子有效性计算 | 中 |
| 公网部署加固 | 多实例、TLS、PostgreSQL 等(当前只内网用) | 低 / 未来 |
| 政策 / 宏观 / 隔夜消息数据 | 数据源还没定 | 中 |
| 分析师一致预期数据 | 数据源被阻塞 | 中低 |
| Level-2 竞价深度 | 需要授权 | 中低 |
> 说明:手机端施工,以及上面这些数据/算法类功能,做之前都要先和老板确认优先级,不要擅自开工。
> 仓库内更细的任务说明见 `issues/` 目录(ISSUE-001 到 ISSUE-011)。
+32
View File
@@ -0,0 +1,32 @@
# 最新进度
> 核实日期:2026-08-23,以代码仓库当前提交 `224e2a7` 为准(`ed9858e` 是最近一笔代码改动)。
## 一句话总结
项目主体功能早就做好并上线内网了。当前主线是「全站视觉统一改造」,已经走完 17 个阶段,正处于**最终验收、代码合并的收尾阶段**。同时新开了一条「手机端独立重新设计」的线(还在出方案,没动工)。本仓库的交接手册已整理完成(见 `任务清单.md`)。
## 已经做到哪了
- **代码结构**:已经从历史杂乱目录保真迁移进 `app/`,整理成「模块化单体」(一个 Python 进程、一个 SQLite 数据库、不需要构建工具的前端),用户在 2026-08-01 人工验收通过。
- **功能**:16 个主工作区都有正式实现(见 `项目需求.md`)。
- **视觉统一**:17 个阶段全部完成,每批都过了功能和视觉双重审核,返修版已部署到内网验收地址 `192.168.200.36:8765`
- **数据库历史问题**:已修复收口,老数据没有被改动。
- **最近的代码改动**(2026-08 下旬):主要是视觉收尾的细节——顶端栏图标对齐、设计令牌统一、问师/情绪周期等页面样式修整等,已合并进 `main`,并部署到内网 `.36:8765`
## 正在做的事
1. **全站视觉统一改造收尾**:最终验收 + 代码合并(主线)。
2. **手机端独立重新设计**:先出视觉规范和架构方案,等老板确认后再施工(见 `任务清单.md`)。
## 哪些还不能算“完成”
- **手机端**:当前有样式但基本是电脑端缩小,不可用。正在按“独立手机产品”重新设计,还没施工。
- **一些依赖外部数据/算法的高级功能还没做**:完整 IC 动态多因子选股、政策/宏观/隔夜消息、分析师一致预期、Level-2 竞价深度、游资档案历史画像(详见 `任务清单.md`)。
- **公网部署**:现在只在内网用,还没做公网加固。
## 关键时间点
- 2026-08-01`app/` 保真迁移完成,用户人工验收通过。
- 2026-08-06:上一版交接说明(`HANDOFF.md`)生成(现已过时,仅留档)。
- 2026-08-21:视觉收尾提交 `ed9858e` 合并进 `main`,部署到内网 `.36:8765`
+46
View File
@@ -0,0 +1,46 @@
# 项目需求
> 大白话版。逐页、逐字段的完整需求请看 `product/小白复盘-完整产品规格说明书.md`(那是最权威的“说明书”,从零重建项目都用它)。
## 这个项目是干什么的
「小白复盘」是一个给老板个人用的股票复盘工具网站,放在内网访问(地址 `192.168.200.36:8765`)。
它不是炒股下单软件,而是「收盘后和开盘前」用来复盘、观察市场的工具:
- 把当天(或最近)的真实行情、涨停跌停、板块轮动、集合竞价等信息整理清楚,帮老板复盘。
- 提供智能选股、问师(跟“游资思维”老师对话)等辅助分析。
- 记录自己的交易和复盘。
一句话:它帮你“看清市场、想清楚思路、记下来”,但不替你做买卖决定。
## 要解决什么问题
1. **市场信息太散**:涨停池、炸板池、跌停板、龙虎榜、人气榜、板块轮动这些信息本来分散在各处,这个网站把它们集中到一处,还配了日间/夜间两套配色,看起来统一。
2. **复盘靠脑子记不住**:提供“我的复盘”和交易日志,把每天的判断、操作、情绪记录下来。
3. **选股没思路**:智能选股用规则和因子(影响股价的数据指标)帮你筛出候选股票。
4. **想听“高手”怎么看**:问师模块可以按不同的游资思维(比如佛山无影脚、北京炒家等)跟老师对话。
## 主要功能(板块)
登录后侧栏有 16 个主工作区,默认进入「情绪周期」:
- **市场类(12 个)**:情绪周期、涨停池、炸板池、跌停板、昨日涨停、涨停表现、市场天梯、板块轮动、集合竞价、题材库、人气热榜、龙虎榜。
- **智能工具类(3 个)**:智能选股、问师、问天。
- **个人类(1 个)**:我的复盘。
其中「问天」是冻结区(见下面的硬规矩)。
## 几条硬规矩(不能破坏的边界)
- 「问天」板块是**冻结区**,任何改动都不许碰它。
- **不用假数据冒充真行情**;数据缺失就明说“没有/不可用”,不能编。
- **每个用户自己的数据互相隔离**(自选、复盘、对话、问天历史等),看不到别人的。
- **计算由程序确定性完成**(情绪周期、智能选股、问天排盘等),AI 大模型(LLM,就是会聊天的那个 AI)只负责解释或编译自然语言条件,不能改计算结果。
- **不接券商、不自动下单**,不承诺收益。
## 权限
- **普通用户**:能用大部分市场页面和「我的复盘」。
- **会员**:额外能用智能选股、问师、问天(需要管理员开通)。
- **管理员**:管理公共行情密钥、会员额度和系统配置等。
+89 -71
View File
@@ -5,6 +5,21 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>小白复盘</title>
<script>
(() => {
const params = new URLSearchParams(window.location.search);
const ui = params.get("ui");
const path = window.location.pathname;
const alreadyMobile = path === "/m" || path.indexOf("/m/") === 0;
const forcedMobile = ui === "mobile";
const forcedDesktop = ui === "desktop";
const autoMobile = window.matchMedia && window.matchMedia("(max-width: 720px)").matches;
const mobileUA = /Android|iPhone|iPad|iPod|Mobile|Windows Phone/i.test(navigator.userAgent || "");
if (!forcedDesktop && !alreadyMobile && (forcedMobile || autoMobile || mobileUA)) {
window.location.replace("/m/" + window.location.search + window.location.hash);
}
})();
</script>
<script>
(() => {
let theme = "light";
@@ -17,29 +32,29 @@
document.documentElement.style.colorScheme = theme;
})();
</script>
<link rel="stylesheet" href="/shared/tokens.css?v=20260806-2">
<link rel="stylesheet" href="/shared/tokens.css?v=20260820-3">
<link rel="stylesheet" href="/shared/base.css?v=20260806-1">
<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/shell.css?v=20260820-8">
<link rel="stylesheet" href="/shared/auth.css?v=20260820-5">
<link rel="stylesheet" href="/shared/components/controls.css?v=20260820-2">
<link rel="stylesheet" href="/shared/components/navigation.css?v=20260820-1">
<link rel="stylesheet" href="/shared/components/cards.css?v=20260820-1">
<link rel="stylesheet" href="/shared/components/tables.css?v=20260820-1">
<link rel="stylesheet" href="/shared/components/dialogs.css?v=20260820-3">
<link rel="stylesheet" href="/shared/components/feedback.css?v=20260806-1">
<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=20260806-2">
<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/heaven/foundation.css?v=20260806-2">
<link rel="stylesheet" href="/pages/review/foundation.css?v=20260806-1">
<link rel="stylesheet" href="/pages/review/foundation.css?v=20260820-4">
</head>
<body>
<section id="authGate" class="auth-gate" aria-label="账号登录">
@@ -68,12 +83,56 @@
<span id="mobilePageGroup">行情</span>
<strong id="mobilePageTitle">情绪周期</strong>
</div>
<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 class="app-page-context" aria-live="polite">
<strong id="currentPageTitle">小白复盘</strong>
<span id="currentPageSubtitle"></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">
@@ -82,7 +141,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></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="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>
@@ -93,12 +152,12 @@
<button class="button mobile-command-shortcut" type="button" data-mobile-command-target="alertButton"><i data-lucide="bell"></i><span>提醒</span></button>
<button class="button mobile-command-shortcut" type="button" data-mobile-command-target="assistantButton"><i data-lucide="message-circle-more"></i><span>助手</span></button>
</div>
<button id="refreshButton" class="button command-button" type="button"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
<button id="syncButton" class="button primary command-button" type="button" hidden><i data-lucide="cloud-download"></i><span>后台刷新</span></button>
<button id="settingsButton" class="button command-button" type="button" hidden><i data-lucide="settings-2"></i><span>系统管理</span></button>
<button id="refreshButton" class="button command-button" type="button" title="刷新"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
<button id="syncButton" class="button primary command-button" type="button" title="后台刷新" hidden><i data-lucide="cloud-download"></i><span>后台刷新</span></button>
<button id="settingsButton" class="button command-button" type="button" title="系统管理" hidden><i data-lucide="settings-2"></i><span>系统管理</span></button>
<div class="account-menu-shell">
<div id="accountRoleBadges" class="account-role-badges" aria-label="账号身份">
<span id="accountAdminBadge" class="account-role-badge admin-role-badge" hidden><i data-lucide="shield-check"></i><span>管理员</span></span>
<span id="accountAdminBadge" class="account-role-badge admin-role-badge" title="管理员" hidden><i data-lucide="shield-check"></i><span>管理员</span></span>
<button id="accountVipBadge" class="account-role-badge vip-role-badge" type="button" title="查看会员状态" hidden><b aria-hidden="true">V</b><span id="accountVipLabel">非会员</span></button>
</div>
<button id="accountButton" class="button account-button command-button" type="button" title="账号菜单" aria-haspopup="menu" aria-expanded="false" aria-controls="accountDropdown"><i data-lucide="circle-user-round"></i><span id="accountName">--</span><i class="account-menu-chevron" data-lucide="chevron-down"></i></button>
@@ -169,47 +228,6 @@
</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>
+219
View File
@@ -0,0 +1,219 @@
(function (global) {
"use strict";
const nav = {
entries: [
{ key: "market", title: "行情数据", subtitle: "情绪 · 梯队 · 题材 · 龙虎榜", icon: "bar-chart-3" },
{ key: "tools", title: "智能工具", subtitle: "智能选股 · 策略跟踪 · 问师", icon: "wand-2" },
{ key: "review", title: "我的复盘", subtitle: "自选 · 交易 · 复盘 · 笔记 · 提醒", icon: "notebook-pen" },
{ key: "assistant", title: "复盘助手", subtitle: "AI 对话复盘", icon: "message-square" },
{ key: "system", title: "系统管理", subtitle: "账号 · 密码 · 会员 · 设置", icon: "settings" }
],
hubs: {
market: {
title: "行情数据",
items: [
{ key: "market/sentiment", label: "情绪周期", icon: "activity" },
{ key: "market/limit-up", label: "涨停池", icon: "trending-up" },
{ key: "market/broken", label: "炸板池", icon: "zap" },
{ key: "market/limit-down", label: "跌停池", icon: "trending-down" },
{ key: "market/yesterday", label: "昨日涨停", icon: "history" },
{ key: "market/performance", label: "涨停表现", icon: "chart-line" },
{ key: "market/ladder", label: "市场天梯", icon: "layers" },
{ key: "market/rotation", label: "主题轮动", icon: "refresh-cw" },
{ key: "market/auction", label: "竞价", icon: "gavel" },
{ key: "market/themes", label: "题材库", icon: "book-open" },
{ key: "market/popularity", label: "人气榜", icon: "flame" },
{ key: "market/dragon", label: "龙虎榜", icon: "crown" }
]
},
tools: {
title: "智能工具",
items: [
{ key: "tools/screener", label: "智能选股", icon: "filter" },
{ key: "tools/tracking", label: "策略跟踪", icon: "target" },
{ key: "tools/mentor", label: "问师", icon: "bot" }
]
},
review: {
title: "我的复盘",
items: [
{ key: "review/watchlist", label: "自选股", icon: "star" },
{ key: "review/trades", label: "交易日志", icon: "scroll-text" },
{ key: "review/daily", label: "每日复盘", icon: "calendar-check" },
{ key: "review/notes", label: "个股笔记", icon: "sticky-note" },
{ key: "review/alerts", label: "提醒中心", icon: "bell" }
]
},
system: {
title: "系统管理",
items: [
{ key: "system/profile", label: "账号资料", icon: "user" },
{ key: "system/password", label: "修改密码", icon: "lock" },
{ key: "system/membership", label: "会员状态", icon: "gem" },
{ key: "system/admin", label: "系统设置", icon: "sliders-horizontal", adminOnly: true },
{ key: "system/members", label: "会员管理", icon: "users", adminOnly: true }
]
}
},
tableDefaults: {
frozenColumns: ["name", "code"],
primaryColumns: [],
scrollColumns: []
},
tableColumns: {
"market/limit-up": {
summary: "limit",
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "streak", label: "连板", type: "streak" },
{ key: "change", label: "涨幅%", type: "change" },
{ key: "price", label: "价格", type: "price" }
],
scrollColumns: [
{ key: "sector", label: "所属板块", type: "text" },
{ key: "first_time", label: "首封", type: "text" },
{ key: "last_time", label: "最后封板", type: "text" },
{ key: "open_times", label: "开板", type: "int" },
{ key: "turnover_rate", label: "换手%", type: "rate" },
{ key: "amount_billion", label: "成交额亿", type: "money" },
{ key: "seal_amount_million", label: "封单额万", type: "int", hideZero: true },
{ key: "reason", label: "涨停原因", type: "text", wide: true }
]
},
"market/broken": {
summary: "broken",
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "change", label: "现价涨幅%", type: "change" },
{ key: "limitGap", label: "距涨停%", type: "gap" },
{ key: "price", label: "价格", type: "price" }
],
scrollColumns: [
{ key: "sector", label: "所属板块", type: "text" },
{ key: "first_time", label: "首次触板", type: "text" },
{ key: "open_times", label: "开板", type: "int" },
{ key: "turnover_rate", label: "换手%", type: "rate" },
{ key: "amount_billion", label: "成交额亿", type: "money" },
{ key: "reason", label: "炸板原因", type: "text", wide: true }
]
},
"market/limit-down": {
summary: "down",
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "change", label: "跌幅%", type: "change" },
{ key: "price", label: "价格", type: "price" }
],
scrollColumns: [
{ key: "sector", label: "所属板块", type: "text" },
{ key: "turnover_rate", label: "换手%", type: "rate" },
{ key: "amount_billion", label: "成交额亿", type: "money" },
{ key: "streak", label: "连续跌停", type: "int", hideZero: true },
{ key: "reason", label: "风险线索", type: "text", wide: true }
]
},
"market/yesterday": {
summary: "yesterday",
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "current_change", label: "今日涨幅%", type: "change" },
{ key: "outcome", label: "今日结果", type: "outcome" },
{ key: "prior_streak", label: "昨日高度", type: "int" }
],
scrollColumns: [
{ key: "current_streak", label: "当前高度", type: "height" },
{ key: "sector", label: "所属板块", type: "text" },
{ key: "reason", label: "涨停逻辑", type: "text", wide: true }
]
},
"market/performance": {
summary: "performance",
frozenColumns: [
{ key: "label", label: "梯队", type: "text", width: 96 }
],
primaryColumns: [
{ key: "advance_rate", label: "晋级率%", type: "advance" },
{ key: "advanced", label: "晋级", type: "int" },
{ key: "positive_rate", label: "收红率%", type: "rate" }
],
scrollColumns: [
{ key: "count", label: "样本", type: "int" },
{ key: "average_change", label: "平均涨幅%", type: "change" }
]
},
"market/popularity": {
summary: "popularity",
sources: ["combined", "ths", "dc"],
columns: {
combined: {
frozenColumns: [
{ key: "rank", label: "#", type: "rank", width: 40 },
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "price", label: "最新价", type: "price" },
{ key: "change", label: "涨跌幅%", type: "change" }
],
scrollColumns: [
{ key: "ths_rank", label: "同花顺", type: "int", hideZero: true },
{ key: "dc_rank", label: "东方财富", type: "int", hideZero: true },
{ key: "rank_change", label: "排名变化", type: "move" },
{ key: "concepts", label: "热门概念", type: "concepts" }
]
},
ths: {
frozenColumns: [
{ key: "rank", label: "#", type: "rank", width: 40 },
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "price", label: "最新价", type: "price" },
{ key: "change", label: "涨跌幅%", type: "change" }
],
scrollColumns: [
{ key: "dc_rank", label: "东方财富", type: "int", hideZero: true },
{ key: "rank_change", label: "排名变化", type: "move" },
{ key: "concepts", label: "热门概念", type: "concepts" },
{ key: "dual_source", label: "榜单状态", type: "dual" }
]
},
dc: {
frozenColumns: [
{ key: "rank", label: "#", type: "rank", width: 40 },
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "price", label: "最新价", type: "price" },
{ key: "change", label: "涨跌幅%", type: "change" }
],
scrollColumns: [
{ key: "ths_rank", label: "同花顺", type: "int", hideZero: true },
{ key: "rank_change", label: "排名变化", type: "move" },
{ key: "concepts", label: "热门概念", type: "concepts" },
{ key: "dual_source", label: "榜单状态", type: "dual" }
]
}
}
}
}
};
global.MobileNav = nav;
})(window);
+735
View File
@@ -0,0 +1,735 @@
/* 手机端行情列表页(P2a):表格引擎 + 六个池/榜页 + 抽屉/详情 Sheet */
#m-view.m-view-feature {
padding: 0;
}
.m-page {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
height: calc(100vh - var(--mobile-header-height) - var(--mobile-tabbar-height) - var(--mobile-safe-bottom));
height: calc(100dvh - var(--mobile-header-height) - var(--mobile-tabbar-height) - var(--mobile-safe-bottom));
}
/* 顶部日期按钮 */
.m-date-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 32px;
padding: 0 8px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface);
color: var(--text-secondary);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--motion-press-release) var(--ease-press),
background-color var(--motion-press-release) var(--ease-press),
color var(--motion-pop) var(--ease-press);
}
.m-date-btn:active {
background: var(--surface-hover);
transform: scale(0.96);
transition-duration: var(--motion-press);
}
.m-date-btn svg {
flex: 0 0 auto;
}
.m-date-btn-text {
font-size: var(--font-size-caption);
font-variant-numeric: tabular-nums;
}
/* 结论条 */
.m-summary {
display: flex;
gap: 8px;
padding: 12px 12px 8px;
overflow-x: auto;
flex: 0 0 auto;
-webkit-overflow-scrolling: touch;
}
.m-summary-item {
flex: 0 0 auto;
min-width: 96px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
border-radius: 8px;
background: var(--surface);
box-shadow: var(--elevation-card);
transition: background-color var(--motion-pop) var(--ease-press),
border-color var(--motion-pop) var(--ease-press);
}
.m-summary-item span {
font-size: var(--font-size-caption);
color: var(--text-secondary);
}
.m-summary-item strong {
font-size: var(--font-size-metric);
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.m-summary-item.is-up strong { color: var(--market-up); }
.m-summary-item.is-down strong { color: var(--market-down); }
.m-conclusion {
padding: 4px 12px 0;
font-size: var(--font-size-label);
color: var(--text-secondary);
line-height: 1.5;
flex: 0 0 auto;
}
.m-conclusion b { color: var(--text-primary); }
/* 人气榜来源切换 */
.m-source-tabs {
display: flex;
gap: 8px;
padding: 8px 12px;
flex: 0 0 auto;
}
.m-source-tab {
flex: 1;
height: 36px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface);
color: var(--text-secondary);
font-size: var(--font-size-label);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--motion-press-release) var(--ease-press),
background-color var(--motion-press-release) var(--ease-press),
color var(--motion-pop) var(--ease-press);
}
.m-source-tab:active {
background: var(--surface-hover);
transform: scale(0.96);
transition-duration: var(--motion-press);
}
.m-source-tab.active {
background: var(--action-soft);
border-color: var(--action);
color: var(--action);
font-weight: 600;
}
/* 表格横向滚动容器 */
.m-table-scroll {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
-webkit-overflow-scrolling: touch;
/* 有意用 pan-x pan-y:横滑看其余列,纵向滚动看长列表,二者共存不互斥 */
touch-action: pan-x pan-y;
overscroll-behavior: contain;
background: var(--surface);
}
.m-table {
border-collapse: separate;
border-spacing: 0;
width: max-content;
min-width: 100%;
}
.m-table th,
.m-table td {
box-sizing: border-box;
padding: 0 8px;
white-space: nowrap;
font-size: var(--font-size-table);
border-bottom: 1px solid var(--border);
}
.m-table thead th {
position: sticky;
top: 0;
z-index: 2;
height: var(--mobile-table-header-height);
background: var(--surface);
color: var(--text-secondary);
font-size: var(--font-size-caption);
font-weight: 500;
border-bottom: 1px solid var(--border-strong);
}
.m-table tbody td {
height: var(--mobile-table-row-height);
background: var(--surface);
transition: background-color var(--motion-press-release) var(--ease-press);
}
.m-table tbody tr:active td {
background: var(--surface-selected);
transition-duration: var(--motion-press);
}
.m-table .m-frozen {
position: sticky;
z-index: 1;
background: var(--surface);
}
.m-table thead .m-frozen {
z-index: 3;
}
.m-table .m-frozen {
box-shadow: 1px 0 0 var(--border-strong), 6px 0 10px -8px rgba(0, 0, 0, 0.28);
}
.m-table tbody .m-frozen {
box-shadow: 1px 0 0 var(--border-strong), 6px 0 10px -8px rgba(0, 0, 0, 0.18);
}
.m-align-right { text-align: right; }
.m-align-left { text-align: left; }
.m-align-center { text-align: center; }
/* 单元格内容 */
.m-stock {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
max-width: 100%;
}
.m-stock-name {
font-size: var(--font-size-table);
font-weight: 500;
color: var(--text-primary);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.m-stock-code {
font-size: var(--font-size-aux);
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
.m-rank {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
}
.m-rank b {
font-size: var(--font-size-table);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.m-rank.is-top b {
font-size: var(--font-size-card-title);
font-weight: 600;
color: var(--warning);
}
.m-rank-hot {
font-size: 10px;
line-height: 1.4;
color: var(--warning);
background: var(--warning-soft);
border-radius: 4px;
padding: 0 3px;
}
.m-pill {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: var(--font-size-aux);
background: var(--action-soft);
color: var(--action);
font-weight: 500;
}
.m-pill.is-high {
background: var(--market-up-soft);
color: var(--market-up);
}
.m-pill--plain {
background: var(--surface-muted);
color: var(--text-secondary);
}
.m-tag {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: var(--font-size-aux);
font-weight: 500;
}
.m-tag--advance { background: var(--market-up-soft); color: var(--market-up); }
.m-tag--fail { background: var(--surface-muted); color: var(--text-secondary); }
.m-tag--broken { background: var(--warning-soft); color: var(--warning); }
.m-tag--down { background: var(--market-down-soft); color: var(--market-down); }
.m-tag--dual { background: var(--action-soft); color: var(--action); }
.m-num {
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.m-num.up { color: var(--market-up); }
.m-num.down { color: var(--market-down); }
.m-num.is-active { color: var(--market-up); font-weight: 600; }
.m-num.is-warning { color: var(--warning); font-weight: 600; }
.m-num.is-neutral { color: var(--text-secondary); font-weight: 600; }
.m-num.is-new { color: var(--action); }
.m-cell-text {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-primary);
}
/* 骨架屏 */
.m-skeleton {
padding: 0;
}
.m-skeleton-row {
display: flex;
align-items: center;
height: var(--mobile-table-row-height);
padding: 0 12px;
border-bottom: 1px solid var(--border-subtle);
}
.m-skeleton-bar {
display: block;
width: 100%;
height: 14px;
border-radius: 4px;
background: var(--surface-muted);
animation: m-skeleton-breathe 1200ms ease-in-out infinite;
}
@keyframes m-skeleton-breathe {
0% { opacity: 1; }
50% { opacity: 0.55; }
100% { opacity: 1; }
}
/* 空态 / 错误态 */
.m-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 100%;
padding: 40px 20px;
text-align: center;
}
.m-state-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
margin-bottom: 4px;
border-radius: 50%;
background: var(--surface-muted);
color: var(--text-tertiary);
}
.m-state p {
margin: 0;
font-size: var(--font-size-card-title);
font-weight: 600;
color: var(--text-primary);
}
.m-state small {
font-size: var(--font-size-label);
color: var(--text-secondary);
}
.m-state--error p {
color: var(--warning);
}
.m-btn-retry {
width: auto;
min-width: 120px;
padding: 0 24px;
margin-top: 8px;
}
/* 底部抽屉 / Sheet */
.m-sheet-root {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 120;
/* 空根节点常驻 #m-app,关闭后不再拦截指针(否则会挡住顶栏/表格/底栏) */
pointer-events: none;
}
.m-sheet-root.is-open {
pointer-events: auto;
}
.m-sheet-backdrop {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: var(--backdrop);
opacity: 0;
transition: opacity 160ms linear;
}
.m-sheet-root.is-open .m-sheet-backdrop {
opacity: 1;
transition: opacity 200ms linear;
}
.m-sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
max-height: 85vh;
display: flex;
flex-direction: column;
background: var(--surface);
border-radius: var(--mobile-sheet-radius) var(--mobile-sheet-radius) 0 0;
transform: translateY(100%);
transition: transform var(--motion-exit) var(--ease-exit);
padding-bottom: var(--mobile-safe-bottom);
}
.m-sheet--detail {
height: 85vh;
}
.m-sheet-root.is-open .m-sheet {
transform: translateY(0);
transition: transform var(--motion-enter) var(--ease-enter);
}
.m-sheet-handle {
display: flex;
align-items: center;
justify-content: center;
height: 28px;
flex: 0 0 auto;
cursor: grab;
}
.m-sheet-handle::before {
content: "";
width: 32px;
height: 4px;
border-radius: 2px;
background: var(--border-strong);
}
.m-sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 52px;
padding: 0 16px 0 20px;
border-bottom: 1px solid var(--border-subtle);
flex: 0 0 auto;
}
.m-sheet-head h2 {
margin: 0;
font-size: var(--font-size-page-title);
font-weight: 600;
}
.m-sheet-head-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.m-sheet-head-code {
font-size: var(--font-size-label);
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
.m-sheet-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--mobile-touch-size);
height: var(--mobile-touch-size);
flex: 0 0 var(--mobile-touch-size);
border: 0;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--motion-press-release) var(--ease-press),
background-color var(--motion-press-release) var(--ease-press);
}
.m-sheet-close:active {
background: var(--surface-hover);
transform: scale(0.94);
transition-duration: var(--motion-press);
}
.m-sheet-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 12px 16px 16px;
-webkit-overflow-scrolling: touch;
}
/* 日期抽屉 */
.m-quick-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.m-quick {
flex: 1;
height: 44px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface);
color: var(--text-primary);
font-size: var(--font-size-label);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--motion-press-release) var(--ease-press),
background-color var(--motion-press-release) var(--ease-press);
}
.m-quick:active {
background: var(--surface-hover);
transform: scale(0.96);
transition-duration: var(--motion-press);
}
.m-cal-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.m-cal-title {
font-size: var(--font-size-card-title);
font-weight: 600;
}
.m-cal-nav {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--mobile-touch-size);
height: var(--mobile-touch-size);
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.m-cal-nav:disabled {
opacity: 0.35;
cursor: default;
}
.m-cal-nav:active:not(:disabled) {
background: var(--surface-hover);
}
.m-cal-week {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 4px;
}
.m-cal-week span {
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-caption);
color: var(--text-tertiary);
}
.m-cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.m-cal-cell {
height: 44px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
font-size: var(--font-size-body);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--motion-press-release) var(--ease-press),
background-color var(--motion-press-release) var(--ease-press);
}
.m-cal-cell:active:not(:disabled) {
background: var(--surface-hover);
transform: scale(0.94);
transition-duration: var(--motion-press);
}
.m-cal-cell:disabled {
color: var(--text-tertiary);
opacity: 0.4;
cursor: default;
}
.m-cal-cell.is-selected {
background: var(--action);
color: var(--text-inverse);
font-weight: 600;
}
.m-cal-cell.is-empty {
cursor: default;
}
/* 详情 Sheet */
.m-detail-top {
display: flex;
flex-direction: column;
gap: 6px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.m-detail-name {
display: flex;
align-items: baseline;
gap: 8px;
font-size: var(--font-size-page-title);
font-weight: 600;
}
.m-detail-code {
font-size: var(--font-size-label);
color: var(--text-tertiary);
font-weight: 400;
font-variant-numeric: tabular-nums;
}
.m-detail-quote {
display: flex;
align-items: baseline;
gap: 10px;
}
.m-detail-price {
font-size: 32px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.m-detail-price.up { color: var(--market-up); }
.m-detail-price.down { color: var(--market-down); }
.m-detail-change {
font-size: var(--font-size-card-title);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.m-detail-change.up { color: var(--market-up); }
.m-detail-change.down { color: var(--market-down); }
.m-detail-meta {
font-size: var(--font-size-label);
color: var(--text-secondary);
}
.m-detail-chart {
margin-top: 12px;
height: 96px;
}
.m-detail-chart svg {
width: 100%;
height: 100%;
display: block;
}
.m-detail-note {
margin-top: 12px;
font-size: var(--font-size-aux);
color: var(--text-tertiary);
}
/* 减少动画降级 */
@media (prefers-reduced-motion: reduce) {
.m-sheet {
transition: opacity var(--motion-fade) var(--ease-press);
}
.m-sheet-root .m-sheet {
transform: none;
opacity: 0;
}
.m-sheet-root.is-open .m-sheet {
transform: none;
opacity: 1;
}
.m-skeleton-bar {
animation-duration: 2000ms;
}
.m-date-btn:active,
.m-source-tab:active,
.m-quick:active,
.m-cal-cell:active,
.m-sheet-close:active {
transform: none;
}
}
+559
View File
@@ -0,0 +1,559 @@
[hidden] {
display: none !important;
}
#m-app,
#m-app * {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background: #f4f5f7;
}
#m-app {
min-height: 100vh;
background: var(--canvas);
color: var(--text-primary);
overflow-x: hidden;
-webkit-tap-highlight-color: transparent;
transition: background-color var(--motion-pop) var(--ease-press),
border-color var(--motion-pop) var(--ease-press);
}
#m-boot-splash {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--canvas);
z-index: 200;
}
#m-boot-splash .m-brand-mark {
margin-bottom: 0;
}
.m-motion-fade-in {
animation: m-fade-in var(--motion-fade) var(--ease-press) both;
}
@keyframes m-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.m-motion-rise-in {
animation: m-rise-in var(--motion-enter) var(--ease-enter) both;
}
@keyframes m-rise-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.m-motion-push-in {
animation: m-push-in var(--motion-enter) var(--ease-enter) both;
}
@keyframes m-push-in {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: none; }
}
.m-motion-pop-in {
animation: m-pop-in var(--motion-pop) var(--ease-enter) both;
}
@keyframes m-pop-in {
from { opacity: 0; transform: translateX(-16px); }
to { opacity: 1; transform: none; }
}
.m-motion-boot-in {
animation: m-fade-in var(--motion-press-release) var(--ease-press) both;
}
.m-header {
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
height: var(--mobile-header-height);
padding: 0 4px;
background: var(--surface);
border-bottom: 1px solid var(--border);
transition: background-color var(--motion-pop) var(--ease-press),
border-color var(--motion-pop) var(--ease-press);
}
.m-header-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--mobile-touch-size);
height: var(--mobile-touch-size);
flex: 0 0 var(--mobile-touch-size);
border: 0;
background: transparent;
color: var(--text-primary);
cursor: pointer;
transition: transform var(--motion-press-release) var(--ease-press),
background-color var(--motion-press-release) var(--ease-press);
}
.m-header-btn:active {
background: var(--surface-hover);
transform: scale(0.94);
transition-duration: var(--motion-press);
}
.m-title {
flex: 1;
margin: 0;
font-size: var(--font-size-page-title);
font-weight: 600;
text-align: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: color var(--motion-pop) var(--ease-press);
}
.m-actions {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
}
.m-view {
padding: var(--mobile-page-padding);
padding-bottom: calc(var(--mobile-page-padding) + var(--mobile-safe-bottom));
max-width: 100%;
overflow-x: hidden;
}
#m-app[data-tabbar="true"] .m-view {
padding-bottom: calc(var(--mobile-tabbar-height) + var(--mobile-safe-bottom) + var(--mobile-page-padding));
}
.m-placeholder {
padding: 40px 16px;
text-align: center;
}
.m-placeholder-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
margin-bottom: 12px;
border-radius: 50%;
background: var(--surface-muted);
color: var(--text-tertiary);
}
.m-placeholder h2 {
margin: 0 0 8px;
font-size: var(--font-size-card-title);
font-weight: 600;
}
.m-placeholder p {
margin: 0;
font-size: var(--font-size-label);
color: var(--text-secondary);
line-height: 1.5;
}
.m-hub-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.m-grid-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: var(--mobile-icon-cell);
padding: 8px 4px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--motion-press-release) var(--ease-press),
background-color var(--motion-press-release) var(--ease-press);
}
.m-grid-item:active {
background: var(--surface-hover);
transform: scale(0.94);
transition-duration: var(--motion-press);
}
.m-grid-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: var(--action-soft);
color: var(--action);
flex: 0 0 auto;
transition: background-color var(--motion-pop) var(--ease-press),
border-color var(--motion-pop) var(--ease-press);
}
.m-grid-icon svg {
width: 22px;
height: 22px;
}
.m-grid-label {
font-size: 12px;
line-height: 1.3;
color: var(--text-primary);
text-align: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
transition: color var(--motion-pop) var(--ease-press);
}
.m-auth {
padding-top: 8px;
}
.m-auth-brand {
text-align: center;
margin: 24px 0 20px;
}
.m-brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--action);
color: var(--text-inverse);
font-size: 24px;
font-weight: 600;
margin-bottom: 12px;
}
.m-auth-brand h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.m-auth-brand p {
margin: 4px 0 0;
font-size: var(--font-size-label);
color: var(--text-secondary);
}
.m-auth-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.m-auth-tab {
flex: 1;
height: 44px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface);
color: var(--text-secondary);
font-size: var(--font-size-body);
cursor: pointer;
}
.m-auth-tab.active {
background: var(--action-soft);
border-color: var(--action);
color: var(--action);
font-weight: 600;
}
.m-form-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.m-form-field span {
font-size: var(--font-size-label);
color: var(--text-secondary);
}
.m-form-field input {
height: 44px;
padding: 0 12px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface);
color: var(--text-primary);
font-size: 14px;
}
.m-form-field input:focus {
outline: none;
border-color: var(--action);
}
.m-auth-error {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 8px;
background: var(--market-up-soft);
color: var(--market-up);
font-size: var(--font-size-label);
}
.m-btn-primary {
width: 100%;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 0;
border-radius: 8px;
background: var(--action);
color: var(--text-inverse);
font-size: var(--font-size-body);
font-weight: 600;
cursor: pointer;
transition: transform var(--motion-press-release) var(--ease-press),
background-color var(--motion-press-release) var(--ease-press);
}
.m-btn-primary:active {
background: var(--action-hover);
transform: scale(0.98);
transition-duration: var(--motion-press);
}
.m-btn-primary:disabled {
opacity: 0.5;
cursor: default;
pointer-events: none;
}
.m-btn-spinner {
display: inline-block;
width: 14px;
height: 14px;
flex: 0 0 auto;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: m-spin 800ms linear infinite;
}
@keyframes m-spin {
to { transform: rotate(360deg); }
}
.m-tabbar {
position: fixed;
left: 0;
right: 0;
bottom: var(--m-keyboard-inset);
z-index: 60;
display: flex;
align-items: stretch;
height: calc(var(--mobile-tabbar-height) + var(--mobile-safe-bottom));
padding-bottom: var(--mobile-safe-bottom);
background: var(--surface);
border-top: 1px solid var(--border);
transition: background-color var(--motion-pop) var(--ease-press),
border-color var(--motion-pop) var(--ease-press);
}
.m-tabbar-item {
flex: 1;
min-width: 0;
min-height: var(--mobile-tabbar-height);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
border: 0;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--motion-press-release) var(--ease-press),
background-color var(--motion-press-release) var(--ease-press),
color var(--motion-pop) var(--ease-press);
}
.m-tabbar-item:active {
background: var(--surface-hover);
transform: scale(0.94);
transition-duration: var(--motion-press), var(--motion-press), var(--motion-pop);
}
.m-tabbar-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
flex: 0 0 auto;
transition: transform var(--motion-press-release) var(--ease-press);
}
.m-tabbar-item:active .m-tabbar-icon {
transform: scale(0.88);
transition-duration: var(--motion-press);
}
.m-tabbar-label {
font-size: var(--mobile-tabbar-label-size);
line-height: 1.2;
color: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.m-tabbar-item.active {
color: var(--action);
}
.m-theme-section {
margin-bottom: 12px;
}
.m-theme-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 12px;
background: var(--surface);
box-shadow: var(--elevation-card);
transition: background-color var(--motion-pop) var(--ease-press),
border-color var(--motion-pop) var(--ease-press);
}
.m-theme-row-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: var(--action-soft);
color: var(--action);
flex: 0 0 auto;
transition: background-color var(--motion-pop) var(--ease-press),
border-color var(--motion-pop) var(--ease-press);
}
.m-theme-row-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.m-theme-row-body strong {
font-size: var(--font-size-card-title);
font-weight: 600;
line-height: 1.4;
}
.m-theme-row-body small {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
.m-theme-switch {
position: relative;
flex: 0 0 auto;
width: 48px;
height: 28px;
padding: 0;
border: 0;
border-radius: 999px;
background: var(--border-strong);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.m-theme-switch[aria-checked="true"] {
background: var(--action);
}
.m-theme-switch-thumb {
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--text-inverse);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
transition: transform 0.15s ease;
}
.m-theme-switch[aria-checked="true"] .m-theme-switch-thumb {
transform: translateX(20px);
}
@media (prefers-reduced-motion: reduce) {
.m-motion-rise-in,
.m-motion-push-in,
.m-motion-pop-in {
animation-name: m-fade-in;
animation-duration: 100ms;
}
.m-motion-fade-in {
animation-duration: 100ms;
}
.m-grid-item:active,
.m-header-btn:active,
.m-btn-primary:active,
.m-tabbar-item:active {
transform: none;
}
.m-tabbar-item:active .m-tabbar-icon {
transform: none;
}
}
+100
View File
@@ -0,0 +1,100 @@
#m-app {
color-scheme: light;
--canvas: #f4f5f7;
--surface: #ffffff;
--surface-muted: #f2f3f5;
--surface-subtle: #f8f9fb;
--surface-raised: #ffffff;
--surface-hover: #f2f3f5;
--surface-selected: #eaf1fe;
--border: #eceded;
--border-strong: #dee0e3;
--border-subtle: #eef0f3;
--text-primary: #1f2329;
--text-secondary: #646a73;
--text-tertiary: #8f959e;
--text-inverse: #ffffff;
--action: #3370ff;
--action-hover: #2b5fd9;
--action-soft: #eaf1fe;
--market-up: #e04536;
--market-up-soft: #fdecea;
--market-down: #16a34a;
--market-down-soft: #e9f7ee;
--warning: #b45309;
--warning-soft: #fdf3e3;
--chart-up: #c93f45;
--chart-down: #087a55;
--elevation-card: 0 1px 2px rgba(31, 35, 41, .04);
--elevation-float: 0 12px 32px rgba(0, 0, 0, .14);
--backdrop: rgba(17, 24, 39, .48);
--motion-press: 100ms;
--motion-press-release: 160ms;
--motion-fade: 120ms;
--motion-enter: 260ms;
--motion-exit: 200ms;
--motion-pop: 240ms;
--ease-enter: cubic-bezier(0.22, 1, 0.36, 1);
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
--ease-press: ease-out;
--mobile-header-height: 52px;
--mobile-touch-size: 44px;
--mobile-safe-bottom: env(safe-area-inset-bottom, 0px);
--mobile-page-padding: 12px;
--mobile-chart-height: 240px;
--mobile-table-row-height: 44px;
--mobile-table-header-height: 40px;
--mobile-icon-cell: 78px;
--mobile-sheet-radius: 12px;
--mobile-min-width: 320px;
--mobile-tabbar-height: 56px;
--mobile-tabbar-label-size: 11px;
--m-keyboard-inset: 0px;
--font-size-aux: 11.5px;
--font-size-caption: 12.5px;
--font-size-label: 13px;
--font-size-table: 13.5px;
--font-size-body: 14px;
--font-size-card-title: 15px;
--font-size-page-title: 18px;
--font-size-metric: 24px;
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
font-size: 14px;
}
#m-app[data-theme="dark"] {
color-scheme: dark;
--canvas: #141519;
--surface: #232529;
--surface-muted: #2a2d33;
--surface-subtle: #202329;
--surface-raised: #232529;
--surface-hover: #2a2d33;
--surface-selected: #2b3b58;
--border: #2b2e34;
--border-strong: #3a3e47;
--border-subtle: #2b2e34;
--text-primary: #e8eaed;
--text-secondary: #a9adb3;
--text-tertiary: #7c828a;
--text-inverse: #ffffff;
--action: #5b8def;
--action-hover: #7ba5f5;
--action-soft: #2b3b58;
--market-up: #f26762;
--market-up-soft: #3d2829;
--market-down: #43bc8a;
--market-down-soft: #22362c;
--warning: #e2ad58;
--warning-soft: #3d3220;
--chart-up: #f06d73;
--chart-down: #43bc8a;
--elevation-card: 0 1px 2px rgba(0, 0, 0, .28);
--elevation-float: 0 12px 32px rgba(0, 0, 0, .46);
--backdrop: rgba(0, 0, 0, .66);
}
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>小白复盘</title>
<link rel="stylesheet" href="css/tokens.css">
<link rel="stylesheet" href="css/shell.css">
<link rel="stylesheet" href="css/features.css">
</head>
<body>
<div id="m-app" data-theme="light">
<script>
(() => {
let theme = "light";
try {
theme = localStorage.getItem("xiaobaiTheme") === "dark" ? "dark" : "light";
} catch (_error) {
theme = "light";
}
document.getElementById("m-app").setAttribute("data-theme", theme);
})();
</script>
<div id="m-boot-splash" aria-hidden="true">
<span class="m-brand-mark"></span>
</div>
<header id="m-header" class="m-header">
<button id="m-back" class="m-header-btn" type="button" aria-label="返回" hidden>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
</button>
<h1 id="m-title" class="m-title">小白复盘</h1>
<div id="m-actions" class="m-actions"></div>
</header>
<main id="m-view" class="m-view"></main>
<nav id="m-tabbar" class="m-tabbar" aria-label="主导航" hidden></nav>
</div>
<script src="config/nav.config.js"></script>
<script src="js/api.js"></script>
<script src="js/session.js"></script>
<script src="js/router.js"></script>
<script src="js/pages.js"></script>
<script src="js/boot.js"></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
(function (global) {
"use strict";
let csrfToken = "";
function setCsrfToken(token) {
csrfToken = token || "";
}
function requestOptions(method, body) {
const normalized = String(method || "GET").toUpperCase();
const options = { method: normalized, headers: {} };
if (!["GET", "HEAD", "OPTIONS"].includes(normalized) && csrfToken) {
options.headers["X-CSRF-Token"] = csrfToken;
}
if (body !== null && body !== undefined) {
options.headers["Content-Type"] = "application/json";
options.body = JSON.stringify(body);
}
return options;
}
async function request(url, method, body) {
const response = await fetch(url, requestOptions(method, body));
let payload = {};
try {
payload = await response.json();
} catch (_error) {
payload = {};
}
if (!response.ok || payload.error) {
const error = new Error(payload.error || "请求失败");
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
global.MobileAPI = { setCsrfToken, request };
})(window);
+84
View File
@@ -0,0 +1,84 @@
(function (global) {
"use strict";
const THEME_KEY = "xiaobaiTheme";
function readTheme() {
try {
return global.localStorage.getItem(THEME_KEY) === "dark" ? "dark" : "light";
} catch (_error) {
return "light";
}
}
function applyTheme(theme) {
const normalized = theme === "dark" ? "dark" : "light";
const app = document.getElementById("m-app");
app.dataset.theme = normalized;
app.style.colorScheme = normalized;
document.body.style.backgroundColor = normalized === "dark" ? "#141519" : "#f4f5f7";
return normalized;
}
function toggle() {
const next = readTheme() === "dark" ? "light" : "dark";
try {
global.localStorage.setItem(THEME_KEY, next);
} catch (_error) {
// Theme still applies for the current page when storage is unavailable.
}
applyTheme(next);
}
global.MobileTheme = { readTheme: readTheme, applyTheme: applyTheme, toggle: toggle };
function syncKeyboardInset() {
const app = document.getElementById("m-app");
const visual = global.visualViewport;
if (!visual) {
app.style.setProperty("--m-keyboard-inset", "0px");
return;
}
const inset = Math.max(0, global.innerHeight - visual.height - visual.offsetTop);
app.style.setProperty("--m-keyboard-inset", inset + "px");
}
function bindViewport() {
const visual = global.visualViewport;
if (visual) {
visual.addEventListener("resize", syncKeyboardInset);
visual.addEventListener("scroll", syncKeyboardInset);
}
syncKeyboardInset();
}
async function boot() {
applyTheme(readTheme());
bindViewport();
let session = { authenticated: false };
try {
session = await global.MobileSession.me();
} catch (_error) {
session = { authenticated: false };
}
const hash = global.location.hash || "";
if (!session.authenticated) {
if (!/^#\/?auth/.test(hash)) global.location.replace("#/auth");
} else if (/^#\/?auth/.test(hash)) {
global.location.replace("#/hub/market");
}
removeBootSplash();
global.MobileRouter.init();
}
function removeBootSplash() {
const splash = document.getElementById("m-boot-splash");
if (splash) splash.remove();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
})(window);
+870
View File
@@ -0,0 +1,870 @@
(function (global) {
"use strict";
/* ---------------------------------------------------------------- helpers */
function number(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function escapeHtml(value) {
return String(value == null ? "" : value).replace(/[&<>"']/g, function (ch) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch];
});
}
function formatNumber(value, digits) {
return new Intl.NumberFormat("zh-CN", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(number(value));
}
function changeClass(value) {
const n = number(value);
return n > 0 ? "up" : n < 0 ? "down" : "";
}
function streakLabel(streak) {
const value = Math.max(1, number(streak));
return value === 1 ? "首板" : value + "板";
}
function displayCompactDate(value) {
const text = String(value || "").replaceAll("-", "");
if (text.length !== 8) return value || "--";
return text.slice(0, 4) + "-" + text.slice(4, 6) + "-" + text.slice(6, 8);
}
function localDateString(value) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return year + "-" + month + "-" + day;
}
function todayString() {
return localDateString(new Date());
}
function parseLocalDate(value) {
const parts = String(value || "").split("-").map(Number);
return new Date(parts[0], (parts[1] || 1) - 1, parts[2] || 1);
}
function addDays(value, delta) {
const date = parseLocalDate(value);
date.setDate(date.getDate() + delta);
return localDateString(date);
}
function previousWeekday(value) {
let date = parseLocalDate(value);
date.setDate(date.getDate() - 1);
while (date.getDay() === 0 || date.getDay() === 6) date.setDate(date.getDate() - 1);
return localDateString(date);
}
const ICONS = {
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
inbox: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
};
function icon(name, size) {
const body = ICONS[name] || "";
return '<svg width="' + (size || 16) + '" height="' + (size || 16) + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + body + "</svg>";
}
/* ---------------------------------------------------------------- state */
const state = {
key: "",
requestedDate: "",
dashboard: null,
popularity: null,
popularitySource: "combined",
seq: 0,
calCursor: null,
};
let sheetToken = 0;
function pageConfig(key) {
const cfg = global.MobileNav && global.MobileNav.tableColumns ? global.MobileNav.tableColumns[key] : null;
return cfg || null;
}
function columnsFor(cfg) {
if (cfg.columns) return cfg.columns[state.popularitySource] || cfg.columns.combined;
return cfg;
}
/* ---------------------------------------------------------------- cells */
function columnWidth(col) {
if (col.width) return col.width;
if (col.type === "rank") return 40;
if (col.type === "stock") return 96;
if (col.wide) return 140;
if (col.type === "text" || col.type === "concepts" || col.type === "outcome" || col.type === "dual") return 96;
return 72;
}
function colAlign(col) {
if (col.type === "rank") return "m-align-center";
if (["change", "price", "rate", "money", "gap", "int", "streak", "advance", "height", "move"].indexOf(col.type) >= 0) return "m-align-right";
return "m-align-left";
}
function stockCell(row) {
return '<span class="m-stock">' +
'<span class="m-stock-name">' + escapeHtml(row.name || "--") + "</span>" +
'<span class="m-stock-code">' + escapeHtml(row.code || "") + "</span>" +
"</span>";
}
function rankCell(row, index) {
const value = number(row.rank) > 0 ? number(row.rank) : index + 1;
const hot = index < 3 ? '<span class="m-rank-hot">热</span>' : "";
return '<span class="m-rank' + (index < 3 ? " is-top" : "") + '"><b>' + value + "</b>" + hot + "</span>";
}
function streakCell(value) {
const n = number(value);
if (n <= 0) return "";
const high = n >= 4 ? " is-high" : "";
return '<span class="m-pill' + high + '">' + streakLabel(n) + "</span>";
}
function numCell(value, digits, signed) {
if (value == null || value === "") return '<span class="m-num"></span>';
const n = number(value);
const sign = signed && n > 0 ? "+" : "";
return '<span class="m-num">' + sign + formatNumber(n, digits) + "</span>";
}
function changeCell(value) {
if (value == null || value === "") return '<span class="m-num"></span>';
const n = number(value);
return '<span class="m-num ' + changeClass(n) + '">' + (n > 0 ? "+" : "") + formatNumber(n, 2) + "</span>";
}
function intCell(value, hideZero) {
const n = Math.round(number(value));
if (hideZero && n === 0) return '<span class="m-num"></span>';
if (value == null || value === "") return '<span class="m-num"></span>';
return '<span class="m-num">' + n.toLocaleString("zh-CN") + "</span>";
}
function advanceCell(value) {
const n = number(value);
const cls = n === 0 ? "is-neutral" : n < 20 ? "is-warning" : "is-active";
return '<span class="m-num ' + cls + '">' + formatNumber(n, 1) + "</span>";
}
function outcomeCell(value) {
const map = { "晋级": "advance", "断板": "fail", "炸板": "broken", "跌停": "down" };
const cls = map[value] || "fail";
return '<span class="m-tag m-tag--' + cls + '">' + escapeHtml(value || "") + "</span>";
}
function heightCell(value) {
const n = number(value);
if (n <= 0) return "";
return '<span class="m-pill m-pill--plain">' + n + "</span>";
}
function moveCell(value) {
const n = value == null ? null : number(value);
if (n == null) return '<span class="m-num is-new">新</span>';
if (n > 0) return '<span class="m-num up">\u2191' + n + "</span>";
if (n < 0) return '<span class="m-num down">\u2193' + Math.abs(n) + "</span>";
return '<span class="m-num">持平</span>';
}
function conceptsCell(value) {
const list = Array.isArray(value) ? value : [];
const text = list.slice(0, 3).join("、");
return '<span class="m-cell-text" title="' + escapeHtml(list.join("、")) + '">' + escapeHtml(text) + "</span>";
}
function dualCell(value) {
const dual = Boolean(value);
return '<span class="m-tag ' + (dual ? "m-tag--dual" : "") + '">' + (dual ? "双榜共识" : "单榜入选") + "</span>";
}
function textCell(value) {
const text = String(value == null ? "" : value);
return '<span class="m-cell-text" title="' + escapeHtml(text) + '">' + escapeHtml(text) + "</span>";
}
function cellHtml(col, row, index) {
const value = row[col.key];
switch (col.type) {
case "stock": return stockCell(row);
case "rank": return rankCell(row, index);
case "streak": return streakCell(value);
case "change": return changeCell(value);
case "price": return numCell(value, 2);
case "rate": return numCell(value, 2);
case "money": return numCell(value, 2);
case "gap": return numCell(value, 2);
case "int": return intCell(value, col.hideZero);
case "advance": return advanceCell(value);
case "outcome": return outcomeCell(value);
case "height": return heightCell(value);
case "move": return moveCell(value);
case "concepts": return conceptsCell(value);
case "dual": return dualCell(value);
case "text":
default: return textCell(value);
}
}
/* ---------------------------------------------------------------- table */
function buildTable(cols, rows) {
const frozen = cols.frozenColumns || [];
const primary = cols.primaryColumns || [];
const scroll = cols.scrollColumns || [];
const ordered = frozen.concat(primary, scroll);
let left = 0;
const frozenLeft = frozen.map(function (col) {
const offset = left;
left += columnWidth(col);
return offset;
});
function cellOpen(col, index, isHead) {
const isFrozen = index < frozen.length;
const width = columnWidth(col);
const clamp = col.type === "text" || col.type === "concepts" ? ";max-width:" + width + "px" : "";
const style = "min-width:" + width + "px" + clamp + (isFrozen ? ";width:" + width + "px;left:" + frozenLeft[index] + "px" : "");
const cls = (isHead ? "m-th" : "m-td") + " " + colAlign(col) + (isFrozen ? " m-frozen" : "");
return '<' + (isHead ? "th" : "td") + ' class="' + cls + '" style="' + style + '">';
}
const head = ordered.map(function (col, i) {
return cellOpen(col, i, true) + escapeHtml(col.label) + (i < frozen.length ? "</th>" : "</th>");
}).join("");
const body = rows.map(function (row, rIndex) {
const cells = ordered.map(function (col, i) {
return cellOpen(col, i, false) + cellHtml(col, row, rIndex) + "</td>";
}).join("");
const code = /^\d{6}$/.test(String(row.code || "")) ? ' data-code="' + escapeHtml(row.code) + '"' : "";
return "<tr" + code + ">" + cells + "</tr>";
}).join("");
return '<table class="m-table"><thead><tr>' + head + "</tr></thead><tbody>" + body + "</tbody></table>";
}
function skeletonHtml(rowCount) {
const rows = [];
for (let i = 0; i < rowCount; i += 1) {
rows.push('<div class="m-skeleton-row"><span class="m-skeleton-bar"></span></div>');
}
return '<div class="m-skeleton" aria-hidden="true">' + rows.join("") + "</div>";
}
function emptyHtml() {
return '<div class="m-state m-motion-rise-in">' +
'<span class="m-state-icon">' + icon("inbox", 26) + "</span>" +
"<p>该交易日暂无相关数据</p>" +
'<small>可点右上角日期切换交易日</small>' +
"</div>";
}
function errorHtml(message) {
return '<div class="m-state m-state--error m-motion-fade-in">' +
'<p>' + escapeHtml(message || "数据加载失败") + "</p>" +
'<button class="m-btn-primary m-btn-retry" type="button" data-action="retry">重试</button>' +
"</div>";
}
/* ---------------------------------------------------------------- summary */
function summaryCard(label, value, tone) {
const cls = tone === "up" ? " is-up" : tone === "down" ? " is-down" : "";
return '<div class="m-summary-item' + cls + '"><span>' + escapeHtml(label) + "</span><strong>" + escapeHtml(value) + "</strong></div>";
}
function buildSummary(key, cfg) {
const dash = state.dashboard || {};
const overview = dash.overview || {};
const cards = [];
if (cfg.summary === "limit") {
const limits = dash.limits || [];
const maxStreak = limits.reduce(function (m, r) { return Math.max(m, number(r.streak)); }, 0);
const seal = overview.seal_rate != null ? formatNumber(overview.seal_rate, 1) + "%" : "--";
cards.push(summaryCard("涨停家数", limits.length, "up"));
cards.push(summaryCard("最高连板", maxStreak ? maxStreak + " 板" : "--"));
cards.push(summaryCard("封板率", seal));
} else if (cfg.summary === "broken") {
const broken = dash.broken || [];
const limits = dash.limits || [];
const seal = overview.seal_rate != null ? formatNumber(overview.seal_rate, 1) + "%" : "--";
cards.push(summaryCard("炸板家数", broken.length, "down"));
cards.push(summaryCard("涨停家数", overview.limit_up_count != null ? overview.limit_up_count : limits.length, "up"));
cards.push(summaryCard("封板率", seal));
} else if (cfg.summary === "down") {
const downs = dash.down_limits || [];
const maxStreak = downs.reduce(function (m, r) { return Math.max(m, number(r.streak)); }, 0);
const cluster = downSectorCluster(downs);
cards.push(summaryCard("跌停家数", downs.length, "down"));
cards.push(summaryCard("连续跌停", maxStreak ? maxStreak + " 天" : "--"));
cards.push(summaryCard("集中板块", cluster || "--"));
} else if (cfg.summary === "yesterday") {
const rows = dash.yesterday_limits || [];
const total = rows.length;
const advance = rows.filter(function (r) { return r.outcome === "晋级"; }).length;
const positive = rows.filter(function (r) { return number(r.current_change) > 0; }).length;
const risk = rows.filter(function (r) { return ["炸板", "跌停"].indexOf(r.outcome) >= 0; }).length;
const rate = function (v) { return total ? formatNumber(v / total * 100, 1) + "%" : "--"; };
cards.push(summaryCard("晋级率", rate(advance), "up"));
cards.push(summaryCard("兑现率", rate(positive), "up"));
cards.push(summaryCard("亏钱效应", rate(risk), "down"));
} else if (cfg.summary === "performance") {
const up = number(overview.up_count);
const down = number(overview.down_count);
const ratio = up + down > 0 ? formatNumber(up / (up + down) * 100, 1) + "%" : "--";
cards.push(summaryCard("上涨", up + " 家", "up"));
cards.push(summaryCard("下跌", down + " 家", "down"));
cards.push(summaryCard("红盘率", ratio, "up"));
} else {
return "";
}
if (!cards.length) return "";
return '<div class="m-summary">' + cards.join("") + "</div>";
}
function downSectorCluster(rows) {
const counts = {};
rows.forEach(function (row) {
const sector = String(row.sector || "其他").trim() || "其他";
if (sector === "其他") return;
counts[sector] = (counts[sector] || 0) + 1;
});
let cluster = null;
Object.keys(counts).forEach(function (sector) {
if (!cluster || counts[sector] > cluster.count) cluster = { sector: sector, count: counts[sector] };
});
return cluster && cluster.count >= 2 ? cluster.sector + " \u00d7" + cluster.count : "";
}
/* ---------------------------------------------------------------- rows */
function brokenLimitRate(row) {
const name = String(row.name || "").toUpperCase();
const code = String(row.code || "").replace(/\D/g, "");
if (name.indexOf("ST") >= 0) return 10;
if (/^(300|301|688|689)/.test(code)) return 20;
if (/^(4|8|92)/.test(code)) return 30;
return 10;
}
function prepareRows(key) {
const dash = state.dashboard || {};
if (key === "market/limit-up") return dash.limits || [];
if (key === "market/broken") {
return (dash.broken || []).map(function (row) {
row.limitGap = Math.max(0, brokenLimitRate(row) - number(row.change));
return row;
});
}
if (key === "market/limit-down") return dash.down_limits || [];
if (key === "market/yesterday") return dash.yesterday_limits || [];
if (key === "market/performance") return normalizePerformanceRows(dash.limit_performance || []);
if (key === "market/popularity") {
return state.popularity ? (state.popularity[state.popularitySource] || []) : [];
}
return [];
}
function normalizePerformanceRows(rows) {
const groups = {};
(rows || []).forEach(function (row) {
const level = Math.max(1, number(row.level));
const displayLevel = Math.min(level, 5);
const group = groups[displayLevel] || {
level: displayLevel,
label: displayLevel === 1 ? "昨日首板" : displayLevel === 5 ? "昨日5板+" : "昨日" + displayLevel + "板",
count: 0,
advanced: 0,
positive: 0,
changeTotal: 0,
};
const count = number(row.count);
group.count += count;
group.advanced += number(row.advanced);
group.positive += count * number(row.positive_rate) / 100;
group.changeTotal += count * number(row.average_change);
groups[displayLevel] = group;
});
return Object.keys(groups)
.map(Number)
.sort(function (a, b) { return b - a; })
.map(function (level) {
const group = groups[level];
return {
level: group.level,
label: group.label,
count: group.count,
advanced: group.advanced,
advance_rate: group.count ? group.advanced / group.count * 100 : 0,
positive_rate: group.count ? group.positive / group.count * 100 : 0,
average_change: group.count ? group.changeTotal / group.count : 0,
};
});
}
function performanceConclusion() {
const overview = (state.dashboard && state.dashboard.overview) || {};
const up = number(overview.up_count);
const down = number(overview.down_count);
const breadthRate = up + down > 0 ? up / (up + down) * 100 : 50;
const stance = breadthRate < 25 ? "宜守不宜攻" : breadthRate < 45 ? "控制仓位,聚焦核心" : "保持精选,跟随强势梯队";
const phase = overview.sentiment_phase || "观察";
return '<div class="m-conclusion">结论:<b>' + escapeHtml(stance) + "</b>,当前情绪周期「" + escapeHtml(phase) + "」。</div>";
}
/* ---------------------------------------------------------------- render */
function updateHeader(title, dateText) {
if (global.MobileRouter && global.MobileRouter.updateHeader) {
global.MobileRouter.updateHeader({ title: title, back: true, actions: dateButtonHtml(dateText) });
}
}
function dateButtonHtml(dateText) {
return '<button class="m-date-btn" type="button" data-action="date" aria-label="选择日期">' +
icon("calendar", 15) +
'<span class="m-date-btn-text">' + escapeHtml(dateText || state.requestedDate || todayString()) + "</span>" +
"</button>";
}
function sourceTabsHtml(cfg) {
if (!cfg.sources || !cfg.sources.length) return "";
const labels = { combined: "双榜综合", ths: "同花顺", dc: "东方财富" };
return '<div class="m-source-tabs" role="tablist" aria-label="热榜来源">' +
cfg.sources.map(function (source) {
const active = source === state.popularitySource;
return '<button class="m-source-tab' + (active ? " active" : "") + '" type="button" role="tab" aria-selected="' + (active ? "true" : "false") + '" data-action="source" data-source="' + source + '">' + escapeHtml(labels[source] || source) + "</button>";
}).join("") +
"</div>";
}
function renderPage(key) {
const cfg = pageConfig(key);
if (!cfg) return;
state.key = key;
state.requestedDate = todayString();
state.dashboard = null;
state.popularity = null;
state.popularitySource = "combined";
state.calCursor = null;
document.getElementById("m-view").classList.add("m-view-feature");
const title = findLabel(key) || key;
updateHeader(title, state.requestedDate);
const summary = buildSummary(key, cfg);
const sourceTabs = sourceTabsHtml(cfg);
const content = '<div class="m-page" data-page="' + escapeHtml(key) + '">' +
summary +
sourceTabs +
'<div class="m-table-scroll" id="m-table-scroll">' + skeletonHtml(8) + "</div>" +
"</div>";
document.getElementById("m-view").innerHTML = content;
load();
}
function findLabel(key) {
const hubs = global.MobileNav && global.MobileNav.hubs ? global.MobileNav.hubs : {};
for (const hubKey in hubs) {
const items = hubs[hubKey].items || [];
for (const item of items) {
if (item.key === key) return item.label;
}
}
return key;
}
function load() {
const key = state.key;
const cfg = pageConfig(key);
if (!cfg) return;
const seq = ++state.seq;
const requestedDate = state.requestedDate;
const url = key === "market/popularity"
? "/api/popularity?trade_date=" + encodeURIComponent(requestedDate)
: "/api/dashboard?trade_date=" + encodeURIComponent(requestedDate);
global.MobileAPI.request(url).then(function (payload) {
if (seq !== state.seq) return;
if (key === "market/popularity") {
state.popularity = payload;
} else {
state.dashboard = payload;
}
const meta = payload.meta || {};
state.requestedDate = displayCompactDate(meta.requested_date || meta.trade_date || requestedDate);
const title = findLabel(key) || key;
updateHeader(title, state.requestedDate);
renderData(key, cfg);
}).catch(function (error) {
if (seq !== state.seq) return;
renderError(error && error.message ? error.message : "数据加载失败");
});
}
function renderData(key, cfg) {
const cols = columnsFor(cfg);
const rows = prepareRows(key);
const scroll = document.getElementById("m-table-scroll");
if (!scroll) return;
if (!rows.length) {
scroll.innerHTML = emptyHtml();
} else {
scroll.innerHTML = buildTable(cols, rows);
}
const page = document.querySelector(".m-page");
if (!page) return;
let summary = buildSummary(key, cfg);
if (key === "market/performance" && summary) summary += performanceConclusion();
const existingSummary = page.querySelector(".m-summary");
if (existingSummary) {
existingSummary.outerHTML = summary || "";
} else if (summary) {
const tabs = page.querySelector(".m-source-tabs");
const tmp = document.createElement("div");
tmp.innerHTML = summary;
const node = tmp.firstChild;
page.insertBefore(node, tabs || scroll);
}
scroll.classList.remove("m-motion-fade-in");
void scroll.offsetWidth;
scroll.classList.add("m-motion-fade-in");
}
function renderError(message) {
const scroll = document.getElementById("m-table-scroll");
if (scroll) scroll.innerHTML = errorHtml(message);
const page = document.querySelector(".m-page");
if (page) {
const summary = page.querySelector(".m-summary");
if (summary) summary.remove();
}
}
/* ---------------------------------------------------------------- sheets */
function ensureSheetRoot() {
let root = document.getElementById("m-sheet-root");
if (!root) {
root = document.createElement("div");
root.id = "m-sheet-root";
root.className = "m-sheet-root";
document.getElementById("m-app").appendChild(root);
}
return root;
}
function openSheet(content, opts) {
const root = ensureSheetRoot();
sheetToken += 1;
root.innerHTML =
'<div class="m-sheet-backdrop" data-sheet-backdrop></div>' +
'<div class="m-sheet' + (opts && opts.detail ? " m-sheet--detail" : "") + '" role="dialog" aria-modal="true">' +
'<div class="m-sheet-handle" aria-hidden="true"></div>' +
content +
"</div>";
global.requestAnimationFrame(function () { root.classList.add("is-open"); });
bindSheetDrag(root);
}
function closeSheet() {
const root = document.getElementById("m-sheet-root");
if (!root) return;
const token = sheetToken;
root.classList.remove("is-open");
global.setTimeout(function () {
if (sheetToken === token && !root.classList.contains("is-open")) root.innerHTML = "";
}, 340);
}
function bindSheetDrag(root) {
const sheet = root.querySelector(".m-sheet");
const handle = root.querySelector(".m-sheet-handle");
if (!sheet || !handle) return;
let startY = 0;
let dragging = false;
handle.addEventListener("touchstart", function (event) {
dragging = true;
startY = event.touches[0].clientY;
sheet.style.transition = "none";
}, { passive: true });
handle.addEventListener("touchmove", function (event) {
if (!dragging) return;
const dy = event.touches[0].clientY - startY;
if (dy > 0) sheet.style.transform = "translateY(" + dy + "px)";
}, { passive: true });
handle.addEventListener("touchend", function (event) {
if (!dragging) return;
dragging = false;
sheet.style.transition = "";
const dy = event.changedTouches[0].clientY - startY;
if (dy > sheet.offsetHeight / 3) {
closeSheet();
} else {
sheet.style.transform = "";
}
});
}
function selectDate(dateStr) {
if (!dateStr) return;
state.requestedDate = dateStr;
closeSheet();
load();
}
function openDateSheet() {
state.calCursor = {
year: parseLocalDate(state.requestedDate).getFullYear(),
month: parseLocalDate(state.requestedDate).getMonth(),
};
openSheet(
'<div class="m-sheet-head">' +
'<h2>选择日期</h2>' +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button>" +
"</div>" +
'<div class="m-sheet-body" id="m-date-sheet-body"></div>',
{ detail: false }
);
renderDateSheetBody();
}
function renderDateSheetBody() {
const body = document.getElementById("m-date-sheet-body");
if (!body || !state.calCursor) return;
const year = state.calCursor.year;
const month = state.calCursor.month;
const today = todayString();
const now = parseLocalDate(today);
const currentMonth = now.getFullYear() * 12 + now.getMonth();
const cursorMonth = year * 12 + month;
const prevDisabled = cursorMonth <= currentMonth - 12;
const nextDisabled = cursorMonth >= currentMonth;
const quick = [
{ label: "今天", date: todayString() },
{ label: "昨天", date: addDays(today, -1) },
{ label: "前一交易日", date: previousTradeDate() },
];
const quickHtml = quick.map(function (item) {
return '<button class="m-quick" type="button" data-quick-date="' + escapeHtml(item.date) + '">' + escapeHtml(item.label) + "</button>";
}).join("");
body.innerHTML =
'<div class="m-quick-row">' + quickHtml + "</div>" +
'<div class="m-cal-head">' +
'<button class="m-cal-nav" type="button" data-cal-nav="prev"' + (prevDisabled ? " disabled" : "") + " aria-label=\"上个月\">" + icon("chevron-left", 18) + "</button>" +
'<span class="m-cal-title">' + year + " 年 " + (month + 1) + " 月</span>" +
'<button class="m-cal-nav" type="button" data-cal-nav="next"' + (nextDisabled ? " disabled" : "") + " aria-label=\"下个月\">" + icon("chevron-right", 18) + "</button>" +
"</div>" +
'<div class="m-cal-week">' + ["一", "二", "三", "四", "五", "六", "日"].map(function (d) { return "<span>" + d + "</span>"; }).join("") + "</div>" +
'<div class="m-cal-grid">' + calendarCells(year, month) + "</div>";
}
function calendarCells(year, month) {
const first = new Date(year, month, 1);
const startWeekday = (first.getDay() + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const today = todayString();
const cells = [];
for (let i = 0; i < startWeekday; i += 1) cells.push('<span class="m-cal-cell is-empty"></span>');
for (let d = 1; d <= daysInMonth; d += 1) {
const date = new Date(year, month, d);
const dateStr = localDateString(date);
const dow = date.getDay();
const disabled = dow === 0 || dow === 6 || dateStr > today;
const selected = dateStr === state.requestedDate;
cells.push(
'<button class="m-cal-cell' + (selected ? " is-selected" : "") + '" type="button" data-date="' + dateStr + '"' + (disabled ? " disabled" : "") + ">" + d + "</button>"
);
}
return cells.join("");
}
function previousTradeDate() {
const dash = state.dashboard || {};
if (dash.meta && dash.meta.previous_trade_date) return displayCompactDate(dash.meta.previous_trade_date);
if (state.popularity && state.popularity.meta && state.popularity.meta.previous_trade_date) {
return displayCompactDate(state.popularity.meta.previous_trade_date);
}
return previousWeekday(todayString());
}
function openDetailSheet(code) {
openSheet(
'<div class="m-sheet-head">' +
'<div class="m-sheet-head-title"><span class="m-sheet-head-code">' + escapeHtml(code) + '</span></div>' +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button>" +
"</div>" +
'<div class="m-sheet-body" id="m-detail-sheet-body">' +
'<div class="m-skeleton">' +
'<div class="m-skeleton-row"><span class="m-skeleton-bar"></span></div>' +
'<div class="m-skeleton-row"><span class="m-skeleton-bar"></span></div>' +
'<div class="m-skeleton-row"><span class="m-skeleton-bar"></span></div>' +
"</div>" +
"</div>",
{ detail: true }
);
loadDetail(code);
}
function loadDetail(code) {
const token = sheetToken;
global.MobileAPI.request("/api/stock/" + encodeURIComponent(code) + "/preview").then(function (payload) {
if (token !== sheetToken) return;
renderDetail(payload);
}).catch(function (error) {
if (token !== sheetToken) return;
const body = document.getElementById("m-detail-sheet-body");
if (body) body.innerHTML = errorHtml(error && error.message ? error.message : "行情预览加载失败");
});
}
function renderDetail(payload) {
const body = document.getElementById("m-detail-sheet-body");
if (!body) return;
const stock = payload.stock || {};
const prices = payload.prices || [];
const meta = payload.meta || {};
const name = stock.name && stock.name !== "--" ? stock.name : "--";
const price = number(stock.price);
const change = number(stock.change);
const industry = stock.industry && stock.industry !== "其他" ? stock.industry : (stock.sector || "其他");
const spark = sparklineHtml(prices);
body.innerHTML =
'<div class="m-detail-top">' +
'<div class="m-detail-name">' + escapeHtml(name) + '<span class="m-detail-code">' + escapeHtml(stock.code || "") + "</span></div>" +
'<div class="m-detail-quote">' +
'<strong class="m-detail-price ' + changeClass(change) + '">' + (price ? formatNumber(price, 2) : "--") + "</strong>" +
'<span class="m-detail-change ' + changeClass(change) + '">' + (change > 0 ? "+" : "") + formatNumber(change, 2) + "%</span>" +
"</div>" +
'<div class="m-detail-meta">' + escapeHtml(industry) + " · " + escapeHtml(displayCompactDate(meta.trade_date) || "--") + "</div>" +
"</div>" +
spark +
'<div class="m-detail-note">' + escapeHtml(meta.notice || "数据来源:本地行情") + "</div>";
}
function sparklineHtml(prices) {
const closes = (prices || []).map(function (bar) { return number(bar.close); }).filter(function (v) { return v > 0; });
if (closes.length < 2) return "";
const min = Math.min.apply(null, closes);
const max = Math.max.apply(null, closes);
const range = max - min || 1;
const width = 320;
const height = 96;
const step = width / (closes.length - 1);
const points = closes.map(function (value, i) {
const x = Math.round(i * step);
const y = Math.round(height - (value - min) / range * height);
return x + "," + y;
}).join(" ");
const up = closes[closes.length - 1] >= closes[0];
return '<div class="m-detail-chart"><svg viewBox="0 0 ' + width + " " + height + '" preserveAspectRatio="none" aria-hidden="true">' +
'<polyline fill="none" style="stroke:' + (up ? "var(--market-up)" : "var(--market-down)") + '" stroke-width="1.5" points="' + points + '"/>' +
"</svg></div>";
}
/* ---------------------------------------------------------------- events */
function bindEvents() {
window.addEventListener("hashchange", closeSheet);
document.addEventListener("click", function (event) {
const dateBtn = event.target.closest("[data-action=date]");
if (dateBtn) { openDateSheet(); return; }
const retry = event.target.closest("[data-action=retry]");
if (retry) { load(); return; }
const sourceTab = event.target.closest("[data-action=source]");
if (sourceTab) {
state.popularitySource = sourceTab.dataset.source || "combined";
const cfg = pageConfig(state.key);
if (cfg) {
const rows = prepareRows(state.key);
const scroll = document.getElementById("m-table-scroll");
if (scroll) scroll.innerHTML = rows.length ? buildTable(columnsFor(cfg), rows) : emptyHtml();
document.querySelectorAll(".m-source-tab").forEach(function (tab) {
const active = tab.dataset.source === state.popularitySource;
tab.classList.toggle("active", active);
tab.setAttribute("aria-selected", String(active));
});
}
return;
}
const closeBtn = event.target.closest("[data-sheet-close]");
if (closeBtn) { closeSheet(); return; }
const backdrop = event.target.closest("[data-sheet-backdrop]");
if (backdrop) { closeSheet(); return; }
const quick = event.target.closest("[data-quick-date]");
if (quick) { selectDate(quick.dataset.quickDate); return; }
const calNav = event.target.closest("[data-cal-nav]");
if (calNav) {
if (calNav.disabled) return;
const dir = calNav.dataset.calNav === "next" ? 1 : -1;
const monthIndex = state.calCursor.year * 12 + state.calCursor.month + dir;
state.calCursor = { year: Math.floor(monthIndex / 12), month: ((monthIndex % 12) + 12) % 12 };
renderDateSheetBody();
return;
}
const calCell = event.target.closest("[data-date]");
if (calCell) {
if (calCell.disabled) return;
selectDate(calCell.dataset.date);
return;
}
const row = event.target.closest("[data-code]");
if (row) {
const interactive = event.target.closest("button, a, input, select, textarea");
if (!interactive) openDetailSheet(row.dataset.code);
}
});
}
/* ---------------------------------------------------------------- init */
bindEvents();
global.MobilePages = {
render: renderPage,
has: function (key) { return Boolean(pageConfig(key)); },
};
})(window);
+423
View File
@@ -0,0 +1,423 @@
(function (global) {
"use strict";
const ICONS = {
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
"sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
"moon": '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
"bar-chart-3": '<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
"wand-2": '<path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72Z"/><path d="m14 7 3 3"/><path d="M5 6v4"/><path d="M19 14v4"/><path d="M10 2v2"/><path d="M7 8H3"/><path d="M21 16h-4"/><path d="M11 3H9"/>',
"notebook-pen": '<path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4"/><path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/><path d="m21.38 5.63-3-3a1 1 0 0 0-1.42 0l-5.01 5.01a2 2 0 0 0-.5.85l-.84 2.87a.5.5 0 0 0 .62.62l2.87-.84a2 2 0 0 0 .85-.5z"/>',
"message-square": '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
"settings": '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
"inbox": '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
"activity": '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
"trending-up": '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
"trending-down": '<polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/>',
"zap": '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
"history": '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
"chart-line": '<path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/>',
"layers": '<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
"refresh-cw": '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
"gavel": '<path d="m14 13-7.5 7.5c-.83.83-2.17.83-3 0 0 0 0 0 0 0a2.12 2.12 0 0 1 0-3L11 10"/><path d="m16 16 6-6"/><path d="m8 8 6-6"/><path d="m9 7 8 8"/><path d="m21 11-8-8"/>',
"book-open": '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
"flame": '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
"crown": '<path d="M11.562 3.266a.5.5 0 0 1 .876 0L15.39 8.87a1 1 0 0 0 1.516.294L21.183 5.5a.5.5 0 0 1 .798.519l-2.834 10.246a1 1 0 0 1-.956.735H5.81a1 1 0 0 1-.957-.735L2.02 6.02a.5.5 0 0 1 .798-.519l4.276 3.664a1 1 0 0 0 1.516-.294z"/><path d="M5 21h14"/>',
"filter": '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
"target": '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
"bot": '<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>',
"star": '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
"scroll-text": '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
"calendar-check": '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="m9 16 2 2 4-4"/>',
"sticky-note": '<path d="M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8Z"/><path d="M15 3v4a2 2 0 0 0 2 2h4"/>',
"bell": '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
"user": '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
"lock": '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
"gem": '<path d="M6 3h12l4 6-10 13L2 9Z"/><path d="M11 3 8 9l4 13 4-13-3-6"/><path d="M2 9h20"/>',
"sliders-horizontal": '<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>',
"users": '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'
};
function icon(name, size) {
const body = ICONS[name] || "";
return '<svg width="' + (size || 22) + '" height="' + (size || 22) + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + body + "</svg>";
}
function escapeHtml(value) {
return String(value == null ? "" : value).replace(/[&<>"']/g, function (ch) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch];
});
}
const DEFAULT_HASH = "#/hub/market";
const stack = [];
let internalNav = 0;
let authMode = "login";
const VIEW_MOTION_CLASSES = ["m-motion-fade-in", "m-motion-push-in", "m-motion-pop-in", "m-motion-boot-in"];
function applyViewMotion(motion) {
const view = document.getElementById("m-view");
VIEW_MOTION_CLASSES.forEach(function (cls) {
view.classList.remove(cls);
});
void view.offsetWidth;
view.classList.add(motion || "m-motion-fade-in");
}
function currentHash() {
let hash = window.location.hash || "";
if (!hash || hash === "#" || hash === "#/") return DEFAULT_HASH;
if (hash.charAt(0) !== "#") hash = "#" + hash;
return hash;
}
function routeOf(hash) {
const path = hash.replace(/^#\/?/, "");
const parts = path.split("/").filter(Boolean);
return { name: parts[0] || "home", params: parts.slice(1) };
}
function currentRoute() {
return routeOf(stack.length ? stack[stack.length - 1] : currentHash());
}
function entryKeyOfRoute(route) {
if (route.name === "hub") return route.params[0];
if (route.name === "feature") return route.params[0];
if (route.name === "assistant") return "assistant";
return null;
}
function entryRoute(key) {
return key === "assistant" ? "#/assistant/chat" : "#/hub/" + key;
}
function setHash(hash) {
internalNav++;
window.location.hash = hash;
}
function navigate(hash) {
stack.push(hash);
setHash(hash);
render("m-motion-push-in");
}
function replace(hash, motion) {
if (stack.length) stack[stack.length - 1] = hash;
else stack.push(hash);
setHash(hash);
render(motion);
}
function resetStack(hash) {
stack.length = 0;
stack.push(hash);
setHash(hash);
render();
}
function parentRoute(route) {
if (route.name === "feature") return "#/hub/" + route.params[0];
return null;
}
function back() {
if (stack.length > 1) {
stack.pop();
setHash(stack[stack.length - 1]);
render("m-motion-pop-in");
return;
}
const parent = parentRoute(currentRoute());
if (parent) replace(parent, "m-motion-pop-in");
}
function switchEntry(key) {
const route = currentRoute();
const current = entryKeyOfRoute(route);
if (current === key) {
if (route.name === "feature") {
resetStack(entryRoute(key));
}
return;
}
resetStack(entryRoute(key));
}
function updateHeader(options) {
document.getElementById("m-title").textContent = options.title || "小白复盘";
document.getElementById("m-back").hidden = !options.back;
document.getElementById("m-actions").innerHTML = options.actions || "";
}
function placeholderHtml(title, subtitle) {
return '<div class="m-placeholder m-motion-rise-in">' +
'<span class="m-placeholder-icon">' + icon("inbox", 26) + "</span>" +
"<h2>" + escapeHtml(title) + "</h2>" +
"<p>" + escapeHtml(subtitle || "该页面将在后续批次实现,返回即可继续浏览。") + "</p>" +
"</div>";
}
function findFeatureLabel(key) {
const hubs = global.MobileNav.hubs;
for (const hubKey in hubs) {
const items = hubs[hubKey].items || [];
for (const item of items) {
if (item.key === key) return item.label;
}
}
return null;
}
function themeToggleSection() {
const dark = document.getElementById("m-app").dataset.theme === "dark";
return '<div class="m-theme-section">' +
'<div class="m-theme-row">' +
'<span class="m-theme-row-icon">' + icon(dark ? "moon" : "sun") + "</span>" +
'<span class="m-theme-row-body"><strong>外观主题</strong><small>' + (dark ? "当前:夜间模式" : "当前:日间模式") + "</small></span>" +
'<button class="m-theme-switch" type="button" data-theme-toggle role="switch" aria-checked="' + (dark ? "true" : "false") + '" aria-label="切换日间/夜间模式"><span class="m-theme-switch-thumb"></span></button>' +
"</div></div>";
}
function syncThemeToggleUI() {
const dark = document.getElementById("m-app").dataset.theme === "dark";
const toggle = document.querySelector("[data-theme-toggle]");
if (toggle) toggle.setAttribute("aria-checked", dark ? "true" : "false");
const rowIcon = document.querySelector(".m-theme-row-icon");
if (rowIcon) rowIcon.innerHTML = icon(dark ? "moon" : "sun");
const rowBodySmall = document.querySelector(".m-theme-row-body small");
if (rowBodySmall) rowBodySmall.textContent = dark ? "当前:夜间模式" : "当前:日间模式";
}
function visibleHubItems(hub) {
const items = hub && hub.items ? hub.items : [];
return items.filter(function (item) {
return !item.adminOnly || global.MobileSession.isAdmin();
});
}
function renderHub(key) {
const hub = global.MobileNav.hubs[key];
if (!hub) {
replace(DEFAULT_HASH);
return;
}
const items = visibleHubItems(hub);
updateHeader({ title: hub.title, back: false });
const section = key === "system" ? themeToggleSection() : "";
document.getElementById("m-view").innerHTML =
section +
'<ul class="m-hub-grid">' +
items.map(function (item) {
return '<li>' +
'<button class="m-grid-item" type="button" data-route="#/feature/' + item.key + '">' +
'<span class="m-grid-icon">' + icon(item.icon) + "</span>" +
'<span class="m-grid-label">' + escapeHtml(item.label) + "</span>" +
"</button></li>";
}).join("") +
"</ul>";
}
function renderFeature(key) {
if (global.MobilePages && global.MobilePages.has(key)) {
global.MobilePages.render(key);
return;
}
const title = findFeatureLabel(key) || key;
updateHeader({ title: title, back: true });
document.getElementById("m-view").innerHTML = placeholderHtml(title, "该功能页将在后续批次实现。");
}
function renderAssistant() {
updateHeader({ title: "复盘助手", back: false });
document.getElementById("m-view").innerHTML = placeholderHtml("复盘助手", "聊天工作台将在后续批次实现。");
}
function renderAuth() {
updateHeader({ title: "小白复盘", back: false, actions: "" });
document.getElementById("m-view").innerHTML = [
'<div class="m-auth">',
'<div class="m-auth-brand">',
'<span class="m-brand-mark">复</span>',
'<h2>小白复盘</h2>',
'<p>登录后进入你的复盘空间</p>',
"</div>",
'<div class="m-auth-tabs">',
'<button class="m-auth-tab active" type="button" data-auth-mode="login">登录</button>',
'<button class="m-auth-tab" type="button" data-auth-mode="register">注册</button>',
"</div>",
'<form id="m-auth-form">',
'<label class="m-form-field"><span>账号名</span><input id="m-auth-username" type="text" minlength="3" maxlength="30" autocomplete="username" required></label>',
'<label class="m-form-field"><span>密码</span><input id="m-auth-password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required></label>',
'<label class="m-form-field" id="m-auth-confirm-field" hidden><span>确认密码</span><input id="m-auth-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"></label>',
'<p class="m-auth-error" id="m-auth-error" hidden></p>',
'<button class="m-btn-primary" id="m-auth-submit" type="submit"><span class="m-btn-spinner" aria-hidden="true" hidden></span><span class="m-btn-label">登录</span></button>',
"</form>",
"</div>"
].join("");
authMode = "login";
bindAuth();
}
function setAuthMode(mode) {
authMode = mode === "register" ? "register" : "login";
document.querySelectorAll("[data-auth-mode]").forEach(function (button) {
button.classList.toggle("active", button.dataset.authMode === authMode);
});
document.getElementById("m-auth-confirm-field").hidden = authMode !== "register";
document.getElementById("m-auth-confirm").required = authMode === "register";
document.getElementById("m-auth-password").autocomplete = authMode === "register" ? "new-password" : "current-password";
const submitLabel = document.querySelector("#m-auth-submit .m-btn-label");
if (submitLabel) submitLabel.textContent = authMode === "register" ? "注册并进入" : "登录";
document.getElementById("m-auth-error").hidden = true;
}
function bindAuth() {
document.querySelectorAll("[data-auth-mode]").forEach(function (button) {
button.addEventListener("click", function () { setAuthMode(button.dataset.authMode); });
});
document.getElementById("m-auth-form").addEventListener("submit", submitAuth);
}
function showAuthError(element, message) {
element.textContent = message;
element.classList.remove("m-motion-fade-in");
void element.offsetWidth;
element.classList.add("m-motion-fade-in");
element.hidden = false;
}
async function submitAuth(event) {
event.preventDefault();
const username = document.getElementById("m-auth-username").value.trim();
const password = document.getElementById("m-auth-password").value;
const errorElement = document.getElementById("m-auth-error");
if (authMode === "register" && password !== document.getElementById("m-auth-confirm").value) {
showAuthError(errorElement, "两次输入的密码不一致。");
return;
}
const submit = document.getElementById("m-auth-submit");
const spinner = submit.querySelector(".m-btn-spinner");
const label = submit.querySelector(".m-btn-label");
submit.disabled = true;
spinner.hidden = false;
label.textContent = authMode === "register" ? "注册中…" : "登录中…";
try {
if (authMode === "register") {
await global.MobileSession.register(username, password);
} else {
await global.MobileSession.login(username, password);
}
replace(DEFAULT_HASH);
} catch (error) {
showAuthError(errorElement, error.message || "账号操作失败");
} finally {
submit.disabled = false;
spinner.hidden = true;
label.textContent = authMode === "register" ? "注册并进入" : "登录";
}
}
function buildTabbar() {
document.getElementById("m-tabbar").innerHTML = global.MobileNav.entries.map(function (entry) {
return '<button class="m-tabbar-item" type="button" data-tab="' + entry.key + '">' +
'<span class="m-tabbar-icon">' + icon(entry.icon, 24) + "</span>" +
'<span class="m-tabbar-label">' + escapeHtml(entry.title) + "</span>" +
"</button>";
}).join("");
}
function updateTabbar(activeKey) {
document.querySelectorAll("#m-tabbar .m-tabbar-item").forEach(function (button) {
const active = button.dataset.tab === activeKey;
button.classList.toggle("active", active);
if (active) button.setAttribute("aria-current", "page");
else button.removeAttribute("aria-current");
});
}
function render(motion) {
const route = currentRoute();
document.getElementById("m-view").classList.remove("m-view-feature");
if (route.name === "home") {
replace(DEFAULT_HASH);
return;
}
const tabbar = document.getElementById("m-tabbar");
if (route.name === "auth") {
tabbar.hidden = true;
document.getElementById("m-app").dataset.tabbar = "false";
} else {
tabbar.hidden = false;
document.getElementById("m-app").dataset.tabbar = "true";
updateTabbar(entryKeyOfRoute(route));
}
if (route.name === "hub") renderHub(route.params[0]);
else if (route.name === "feature") renderFeature(route.params.join("/"));
else if (route.name === "assistant") renderAssistant();
else if (route.name === "auth") renderAuth();
applyViewMotion(motion);
}
function bindGlobalEvents() {
document.getElementById("m-view").addEventListener("click", function (event) {
const entry = event.target.closest("[data-route]");
if (entry) {
navigate(entry.dataset.route);
}
});
document.getElementById("m-tabbar").addEventListener("click", function (event) {
const item = event.target.closest("[data-tab]");
if (item) switchEntry(item.dataset.tab);
});
document.getElementById("m-back").addEventListener("click", back);
document.addEventListener("click", function (event) {
const toggle = event.target.closest("[data-theme-toggle]");
if (!toggle) return;
global.MobileTheme.toggle();
syncThemeToggleUI();
});
window.addEventListener("hashchange", function () {
if (internalNav > 0) {
internalNav--;
return;
}
const hash = currentHash();
if (stack.length > 1 && hash === stack[stack.length - 2]) {
stack.pop();
render("m-motion-pop-in");
} else if (hash !== stack[stack.length - 1]) {
stack.push(hash);
render("m-motion-push-in");
}
});
}
function init() {
bindGlobalEvents();
buildTabbar();
stack.length = 0;
const raw = window.location.hash || "";
if (!raw || raw === "#" || raw === "#/") {
stack.push(DEFAULT_HASH);
setHash(DEFAULT_HASH);
} else {
stack.push(currentHash());
}
render("m-motion-boot-in");
}
global.MobileRouter = {
init: init,
navigate: navigate,
replace: replace,
back: back,
render: render,
currentRoute: currentRoute,
updateHeader: updateHeader
};
})(window);
+51
View File
@@ -0,0 +1,51 @@
(function (global) {
"use strict";
const state = { user: null, csrfToken: "", authenticated: false };
function applySession(payload) {
state.user = payload.user || null;
state.csrfToken = payload.csrf_token || "";
state.authenticated = Boolean(payload.authenticated);
global.MobileAPI.setCsrfToken(state.csrfToken);
return state;
}
async function me() {
const payload = await global.MobileAPI.request("/api/auth/me");
return applySession(payload);
}
async function login(username, password) {
const payload = await global.MobileAPI.request("/api/auth/login", "POST", {
username: username,
password: password
});
return applySession(payload);
}
async function register(username, password) {
const payload = await global.MobileAPI.request("/api/auth/register", "POST", {
username: username,
password: password
});
return applySession(payload);
}
async function logout() {
try {
await global.MobileAPI.request("/api/auth/logout", "POST", {});
} finally {
state.user = null;
state.csrfToken = "";
state.authenticated = false;
global.MobileAPI.setCsrfToken("");
}
}
function isAdmin() {
return Boolean(state.user && state.user.role === "admin");
}
global.MobileSession = { state: state, me: me, login: login, register: register, logout: logout, isAdmin: isAdmin };
})(window);
+14 -14
View File
@@ -42,15 +42,15 @@
];
const fragments = [
["pools", "/pages/pools/page.html?v=20260803-1", ["limitPool", "brokenView", "downView", "yesterdayView", "performanceView"]],
["pools", "/pages/pools/page.html?v=20260820-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=20260803-1", ["ladderView"]],
["ladder", "/pages/ladder/page.html?v=20260820-1", ["ladderView"]],
["screener", "/pages/screener/page.html?v=20260804-1", ["screenerView", "screenerTrackingView"]],
["mentor", "/pages/mentor/page.html?v=20260806-2", ["mentorView"]],
["rotation", "/pages/rotation/page.html?v=20260803-1", ["rotationView"]],
["mentor", "/pages/mentor/page.html?v=20260820-1", ["mentorView"]],
["rotation", "/pages/rotation/page.html?v=20260820-1", ["rotationView"]],
["auction", "/pages/auction/page.html?v=20260803-1", ["auctionView"]],
["themes", "/pages/themes/page.html?v=20260803-1", ["themeLibraryView"]],
["themes", "/pages/themes/page.html?v=20260820-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=20260729-1",
"/pages/pools/page.js?v=20260820-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=20260729-1",
"/pages/rotation/page.js?v=20260729-1",
"/pages/ladder/page.js?v=20260820-1",
"/pages/rotation/page.js?v=20260820-1",
"/pages/auction/page.js?v=20260729-1",
"/pages/themes/page.js?v=20260729-1",
"/pages/popularity/page.js?v=20260729-1",
"/pages/dragon-tiger/page.js?v=20260806-1",
"/pages/themes/page.js?v=20260820-1",
"/pages/popularity/page.js?v=20260820-1",
"/pages/dragon-tiger/page.js?v=20260820-1",
"/pages/screener/page.js?v=20260806-1",
"/pages/mentor/page.js?v=20260806-2",
"/pages/mentor/page.js?v=20260820-1",
"/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=20260806-1",
"/shared/shell.js?v=20260820-2",
"/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=20260803-1",
"/shared/dashboard.js?v=20260820-1",
"/shared/session.js?v=20260803-1",
"/shared/admin.js?v=20260803-1",
"/app.js?v=20260803-2",
+47 -40
View File
@@ -1,8 +1,8 @@
/* Canonical CSS owner: auction. Historical layers consolidated 2026-08-02. */
#auctionView .section-toolbar h2 {
font-size: 17px;
font-size: var(--font-size-page-title);
font-weight: 800;
font-weight: var(--font-weight-semibold);
}
@media (max-width: 767px) {
@@ -354,9 +354,9 @@
}
.auction-phase-notice[data-phase="archive"] {
background: rgb(238, 242, 246);
background: var(--surface-muted);
color: rgb(89, 101, 116);
color: var(--text-2);
}
.auction-phase-marker {
@@ -374,7 +374,7 @@
.auction-table {
min-width: 860px;
font-size: 12.5px;
font-size: var(--font-size-table);
}
.auction-table tbody tr:hover td {
@@ -462,7 +462,7 @@
transition: opacity var(--motion-fast) ease;
background: rgb(201, 214, 238);
background: var(--accent-soft);
}
.auction-amount-average {
@@ -522,7 +522,7 @@
}
.auction-table td {
height: 44px;
height: 40px;
padding-right: 12px;
@@ -530,7 +530,7 @@
border-right: 0px;
border-bottom-color: rgb(232, 237, 241);
border-bottom-color: var(--border);
}
.auction-table th {
@@ -540,13 +540,13 @@
border-right: 0px;
height: 38px;
height: 36px;
color: rgb(107, 114, 128);
color: var(--text-2);
font-size: 12px;
font-size: var(--font-size-caption);
border-bottom-color: rgb(232, 237, 241);
border-bottom-color: var(--border);
background: var(--table-header);
}
@@ -694,9 +694,9 @@
color: var(--r2-ink);
font-size: 17px;
font-size: var(--font-size-page-title);
font-weight: 800;
font-weight: var(--font-weight-semibold);
letter-spacing: 0px;
}
@@ -812,13 +812,13 @@
100% {
opacity: 0.42;
box-shadow: rgba(37, 99, 235, 0.18) 0px 0px 0px 0px;
box-shadow: 0 0 0 0 var(--focus-ring);
}
50% {
opacity: 1;
box-shadow: rgba(37, 99, 235, 0) 0px 0px 0px 4px;
box-shadow: 0 0 0 4px transparent;
}
}
@@ -896,7 +896,9 @@
.auction-export-button,
.auction-refresh-button {
min-height: 30px;
min-height: 32px;
height: 32px;
display: inline-flex;
@@ -906,17 +908,17 @@
gap: 5px;
padding: 5px 11px;
padding: 0 12px;
border: 1px solid var(--r2-line);
border: 1px solid var(--border-strong);
border-radius: 7px;
border-radius: 8px;
background: var(--surface);
color: var(--text-primary);
font-size: 12px;
font-size: var(--font-size-label);
font-weight: 500;
@@ -1144,7 +1146,7 @@
.auction-search-v2 {
min-width: 0px;
height: 31px;
height: 32px;
display: flex;
@@ -1154,9 +1156,9 @@
padding: 0px 9px;
border: 1px solid var(--r2-line);
border: 1px solid var(--border-strong);
border-radius: 7px;
border-radius: 8px;
background: var(--surface);
}
@@ -1193,7 +1195,7 @@
.auction-filter-segments button:focus-visible,
.auction-refresh-button:focus-visible,
.auction-tabs-v2 button:focus-visible {
outline: rgba(37, 99, 235, 0.28) solid 2px;
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@@ -1219,7 +1221,7 @@
border-collapse: collapse;
font-size: 12.5px;
font-size: var(--font-size-table);
}
.auction-table-v2 thead th {
@@ -1229,12 +1231,16 @@
z-index: 2;
height: 35px;
height: 36px;
padding: 0 12px;
background: var(--table-header);
color: var(--r2-sub);
font-size: var(--font-size-caption);
font-weight: 600;
text-align: left;
@@ -1242,6 +1248,11 @@
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;
}
@@ -1269,7 +1280,9 @@
}
.auction-table-v2 tbody td {
height: 47px;
height: 40px;
padding: 0 12px;
border-bottom: 1px solid var(--r2-line-soft);
@@ -1819,7 +1832,7 @@
border-radius: 3px 3px 0px 0px;
background: rgb(201, 214, 238);
background: var(--accent-soft);
transition: opacity 180ms, transform 180ms;
@@ -1915,7 +1928,7 @@
border-radius: 2px;
background: rgb(201, 214, 238);
background: var(--accent-soft);
}
.auction-volume-legend i.current {
@@ -2089,10 +2102,6 @@
overflow: hidden;
}
body[data-active-view="auctionView"] .overview-strip {
flex: 0 0 auto;
}
body[data-active-view="auctionView"] #auctionView.active-view {
min-height: 0px;
@@ -2138,11 +2147,9 @@
}
.auction-table-v2 tbody td {
height: 43px;
height: 40px;
padding-top: 6px;
padding-bottom: 6px;
padding: 0 12px;
}
}
@@ -2165,9 +2172,9 @@
}
.auc-head h2 {
font-size: 17px;
font-size: var(--font-size-page-title);
font-weight: 800;
font-weight: var(--font-weight-semibold);
}
.auc-grid {
+33 -19
View File
@@ -82,7 +82,7 @@
}
.dragon-operation-table th {
background: rgb(232, 240, 244);
background: var(--table-header);
}
.seat-cell {
@@ -952,6 +952,10 @@
margin: 0px;
color: var(--r2-ink);
font-size: var(--font-size-page-title);
font-weight: var(--font-weight-semibold);
}
.dragon-title-v2 > span {
@@ -991,7 +995,7 @@
align-items: center;
min-height: 34px;
min-height: 32px;
padding: 3px;
@@ -1003,19 +1007,19 @@
}
.dragon-view-tabs-v2 button {
min-height: 27px;
min-height: 26px;
padding: 0px 13px;
border: 0px;
border-radius: 5px;
border-radius: 6px;
background: transparent;
color: var(--r2-sub);
font-size: 11px;
font-size: var(--font-size-label);
cursor: pointer;
}
@@ -1037,11 +1041,15 @@
}
.dragon-action-v2 {
min-height: 34px;
min-height: 32px;
padding: 0px 11px;
height: 32px;
border-radius: 7px;
padding: 0px 12px;
border-radius: 8px;
font-size: var(--font-size-label);
}
.dragon-action-v2 .lucide {
@@ -1101,9 +1109,9 @@
}
.dragon-summary-v2 .dragon-metric span {
color: var(--r2-sub);
color: var(--text-3);
font-size: 11px;
font-size: var(--font-size-caption);
}
.dragon-summary-v2 .dragon-metric strong {
@@ -1111,9 +1119,9 @@
color: var(--r2-ink);
font-size: 18px;
font-size: var(--font-size-metric);
font-weight: 750;
font-weight: var(--font-weight-semibold);
font-variant-numeric: tabular-nums;
}
@@ -1245,7 +1253,7 @@
width: 230px;
height: 33px;
height: 32px;
flex: 0 0 auto;
@@ -1253,9 +1261,9 @@
padding: 0px 10px;
border: 1px solid rgb(216, 221, 229);
border: 1px solid var(--border-strong);
border-radius: 7px;
border-radius: 8px;
color: var(--r2-faint);
@@ -1263,9 +1271,9 @@
}
.dragon-search-v2:focus-within {
border-color: rgb(150, 181, 242);
border-color: var(--accent);
box-shadow: rgba(37, 99, 235, 0.09) 0px 0px 0px 3px;
box-shadow: 0 0 0 2px var(--focus-ring);
}
.dragon-search-v2 .lucide {
@@ -1421,7 +1429,7 @@
#dragonView .dragon-operation-table :is(th, td).row-number {
padding-inline: 8px;
text-align: center;
text-align: left;
}
#dragonView .dragon-operation-table td.stock-code {
@@ -1435,6 +1443,12 @@
z-index: 2;
height: 36px;
padding: 0 12px;
font-size: var(--font-size-caption);
background: var(--surface-subtle);
}
@@ -2389,7 +2403,7 @@ table.tbl {
border-collapse: collapse;
font-size: 12.5px;
font-size: var(--font-size-table);
}
.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 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>
<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>
<tbody>${(trader.operations || []).map((operation, index) => `
<tr data-code="${escapeHtml(operation.code)}">
<td class="row-number num">${index + 1}</td>
<td class="row-number">${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>
+26 -18
View File
@@ -14,7 +14,7 @@
padding: 9px 4px;
border-bottom: 1px solid rgb(232, 236, 239);
border-bottom: 1px solid var(--border);
cursor: pointer;
}
@@ -128,7 +128,7 @@
gap: 5px;
background: rgb(247, 249, 250);
background: var(--surface-muted);
cursor: pointer;
@@ -140,11 +140,11 @@
border: 1px dashed var(--border-strong);
border-radius: 6px;
border-radius: 8px;
color: var(--text-secondary);
font-size: 11px;
font-size: var(--font-size-caption);
white-space: nowrap;
}
@@ -152,7 +152,7 @@
.ladder-gap {
min-height: 68px;
background: repeating-linear-gradient(135deg, rgb(255, 255, 255), rgb(255, 255, 255) 9px, rgb(250, 251, 252) 9px, rgb(250, 251, 252) 18px);
background: repeating-linear-gradient(135deg, var(--surface), var(--surface) 9px, var(--surface-subtle) 9px, var(--surface-subtle) 18px);
}
.ladder-gap-note {
@@ -353,6 +353,12 @@
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;
}
@@ -384,13 +390,15 @@
}
.ladder-sort-segment button {
min-height: 28px;
padding: 4px 12px;
border-radius: 6px;
color: var(--r2-sub);
font-size: 12px;
font-size: var(--font-size-label);
}
.ladder-sort-segment button.active {
@@ -448,11 +456,11 @@
border-right: 1px solid var(--r2-line-soft);
background: linear-gradient(90deg, color-mix(in srgb, var(--tier-color) 8%, #fff), #fff);
background: linear-gradient(90deg, color-mix(in srgb, var(--tier-color) 8%, var(--surface)), var(--surface));
}
.market-ladder-tier.is-gap .market-ladder-label {
background: repeating-linear-gradient(45deg, rgb(250, 250, 250), rgb(250, 250, 250) 8px, rgb(243, 244, 246) 8px, rgb(243, 244, 246) 16px);
background: repeating-linear-gradient(45deg, var(--surface-subtle), var(--surface-subtle) 8px, var(--surface-muted) 8px, var(--surface-muted) 16px);
}
.market-ladder-level {
@@ -571,9 +579,9 @@
white-space: nowrap;
font-size: 13px;
font-size: var(--font-size-table);
font-weight: 800;
font-weight: var(--font-weight-semibold);
}
.market-ladder-stock-first .stock-code {
@@ -613,7 +621,7 @@
.market-ladder-tag.one-price {
background: var(--r2-up-soft);
color: rgb(194, 46, 46);
color: var(--r2-up);
font-weight: 700;
}
@@ -762,9 +770,9 @@
.market-ladder-insight-card > header h3 {
margin: 0px;
font-size: 13.5px;
font-size: var(--font-size-card-title);
font-weight: 700;
font-weight: var(--font-weight-semibold);
}
.market-ladder-insight-card > header span {
@@ -796,9 +804,9 @@
.market-ladder-apex strong {
color: var(--r2-up);
font-size: 26px;
font-size: var(--font-size-metric);
font-weight: 800;
font-weight: var(--font-weight-semibold);
}
.market-ladder-apex em {
@@ -900,7 +908,7 @@
}
.market-ladder-pyramid-row.is-gap > i {
background: repeating-linear-gradient(45deg, rgb(229, 231, 235), rgb(229, 231, 235) 4px, rgb(243, 244, 246) 4px, rgb(243, 244, 246) 8px);
background: repeating-linear-gradient(45deg, var(--border), var(--border) 4px, var(--surface-muted) 4px, var(--surface-muted) 8px);
}
.market-ladder-pyramid-row.is-gap > i b {
@@ -968,11 +976,11 @@
}
.market-ladder-rate-list > div > i b.is-low {
background: rgb(245, 158, 11);
background: var(--r2-amber);
}
.market-ladder-rate-list > div > i b.is-zero {
background: rgb(209, 213, 219);
background: var(--border-strong);
}
.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: "#2563eb", 2: "#16a34a", 3: "#d97706", 4: "#e04536" }[level] || "#9ca3af";
const color = { 1: "#3370ff", 2: "#16a34a", 3: "#b45309", 4: "#e04536" }[level] || "#8f959e";
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>
+6 -5
View File
@@ -790,7 +790,8 @@
border-radius: 4px;
}
:is(.global-search-dialog, .stock-dialog, .settings-dialog:not(.heaven-reading-dialog)) {
: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)) {
--dialog-line: var(--border);
--dialog-line-strong: var(--border-strong);
@@ -803,17 +804,17 @@
border: 1px solid var(--dialog-line-strong);
border-radius: 10px;
border-radius: var(--size-radius-dialog);
background: var(--surface);
color: var(--dialog-ink);
box-shadow: rgba(25, 36, 48, 0.2) 0px 26px 72px, rgba(25, 36, 48, 0.08) 0px 4px 14px;
box-shadow: var(--shadow-float);
}
:is(.global-search-dialog, .stock-dialog, .settings-dialog:not(.heaven-reading-dialog))::backdrop {
background: rgba(28, 39, 50, 0.46);
background: var(--backdrop);
backdrop-filter: blur(3px);
}
@@ -1133,7 +1134,7 @@
margin: 8px auto;
border-radius: 8px;
border-radius: var(--size-radius-dialog);
}
:is(.stock-dialog, .settings-dialog:not(.heaven-reading-dialog)) .dialog-header {
+764 -355
View File
File diff suppressed because it is too large Load Diff
+59 -32
View File
@@ -1,35 +1,34 @@
<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>
<header class="section-toolbar lad-head mentor-page-header">
<div class="section-title-group mentor-page-title">
<div id="mentorNotice" class="inline-notice" hidden></div>
<header class="mentor-page-header" aria-label="问师">
<div class="mentor-page-title">
<h2>问师</h2>
<span class="section-subtitle">与不同交易思维模型持续对话 · <span id="mentorDataDate">--</span></span>
<p id="mentorPageSubtitle">与不同交易思维模型持续对话 · 数据日期 --</p>
</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-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 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>
<p id="mentorSortHint" class="mentor-sort-hint" hidden>拖动联系人,或使用箭头调整顺序</p>
<button id="mentorSortToggle" class="mentor-sort-toggle" type="button" aria-pressed="false" title="整理联系人顺序"><i data-lucide="list-ordered"></i><span>整理</span></button>
</div>
<div id="mentorList" class="mentor-list"></div>
<div id="mentorListEmpty" class="mentor-list-empty" hidden>没有符合条件的思维模型</div>
<p class="mentor-evidence-legend">等级仅表示公开素材完整度</p>
<p id="mentorSortHint" class="mentor-sort-hint" hidden>拖动联系人,或使用箭头调整顺序</p>
</aside>
<section class="mentor-chat-panel" aria-label="问师对话">
@@ -37,11 +36,16 @@
<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><span id="activeMentorBadges" class="mentor-active-badges"></span></div>
<div class="mentor-active-title"><h3 id="activeMentorName">--</h3></div>
<p id="activeMentorStatus">思维模型已就绪</p>
</div>
</div>
<button id="clearMentorChatButton" class="icon-button mentor-clear-button" type="button" disabled aria-label="清空当前对话" title="清空对话"><i data-lucide="trash-2"></i></button>
<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>
<div id="mentorMessages" class="mentor-messages" aria-live="polite"></div>
<div id="mentorQuickPrompts" class="mentor-quick-prompts">
@@ -52,38 +56,61 @@
<button type="button" data-mentor-prompt="现在最需要防范的风险是什么?"><i data-lucide="shield-alert"></i>风险检查</button>
</div>
<form id="mentorChatForm" class="mentor-chat-form">
<label class="visually-hidden" for="mentorQuestion">向当前思维模型提问</label>
<textarea id="mentorQuestion" maxlength="2000" placeholder="输入市场、板块、个股代码或交易问题"></textarea>
<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>
<div class="mentor-composer-actions">
<span>Enter 发送 · Shift + Enter 换行</span>
<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>
<aside class="mentor-profile-panel" aria-label="当前思维模型资料">
</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">
<div class="mentor-profile-hero">
<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>
<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>
</div>
<section class="mentor-profile-section">
<h4>关注维度</h4>
<div id="activeMentorFocus" class="mentor-active-focus"></div>
<div id="mentorProfileDialogFocus" class="mentor-active-focus"></div>
</section>
<section class="mentor-profile-section">
<h4>资料依据</h4>
<strong id="mentorProfileSource">--</strong>
<p id="activeMentorEvidence">--</p>
<strong id="mentorProfileDialogSource">--</strong>
<p id="mentorProfileDialogEvidence">--</p>
</section>
<section class="mentor-profile-section mentor-profile-boundary">
<h4>数据边界</h4>
<p><i data-lucide="calendar-days"></i><span id="mentorProfileDataDate">--</span></p>
<p><i data-lucide="calendar-days"></i><span id="mentorProfileDialogDataDate">--</span></p>
<p><i data-lucide="database"></i><span>仅使用网页已提供的市场数据</span></p>
</section>
</aside>
</div>
</div>
</dialog>
</section>
+223 -37
View File
@@ -31,27 +31,100 @@ 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("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`);
setText("mentorPageSubtitle", `与不同交易思维模型持续对话 · 数据日期 ${displayCompactDate(setup.trade_date)}`);
setText("currentPageSubtitle", `与不同交易思维模型持续对话 · 数据日期 ${displayCompactDate(setup.trade_date)}`);
setText("activeMentorName", selected?.name || "--");
setText("mentorProfileName", selected?.name || "--");
setText("mentorProfileTagline", selected?.tagline || selected?.description || "--");
setText("mentorProfileSource", selected?.evidence?.label || "公开资料整理");
setText("mentorProfileDataDate", `行情数据 ${displayCompactDate(setup.trade_date)}`);
setText("activeMentorAvatar", mentorAvatarText(selected));
setText("mentorProfileAvatar", mentorAvatarText(selected));
document.querySelector("#activeMentorBadges").innerHTML = selected ? renderMentorBadges(selected, true) : "";
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("");
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);
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;
@@ -76,9 +149,15 @@ 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) => {
@@ -88,7 +167,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" data-grade="${escapeHtml(String(mentor.evidence?.grade || "").toLowerCase())}" aria-hidden="true">${escapeHtml(mentorAvatarText(mentor))}</span>
<span class="mentor-avatar ${mentorAvatarToneClass(mentor)}" 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>
@@ -98,16 +177,12 @@ 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-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>
` : ""}
<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("");
@@ -115,9 +190,6 @@ 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));
});
@@ -143,6 +215,36 @@ 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 || [];
@@ -246,14 +348,17 @@ async function persistMentorPreferences() {
}
}
function renderMentorBadges(mentor, expanded = false) {
function renderMentorBadges(mentor) {
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("");
}
@@ -262,6 +367,14 @@ 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;
@@ -287,27 +400,28 @@ function renderMentorMessages() {
} else {
container.innerHTML = state.mentorMessages.map((message) => `
<article class="mentor-message ${message.role} ${message.error ? "is-error" : ""}">
<span class="mentor-message-avatar" aria-hidden="true">${message.role === "user" ? "我" : escapeHtml(mentorAvatarText(selected))}</span>
<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>
<div class="mentor-message-body">
<div class="mentor-message-label">${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}</div>
<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>
${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 = state.mentorMessages.length > 0 || state.mentorLoading;
document.querySelector("#mentorQuickPrompts").hidden = false;
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 ? "正在生成回答..." : "思维模型已就绪");
setText("activeMentorStatus", state.mentorLoading ? "正在生成回答..." : (selected?.tagline || selected?.description || "思维模型已就绪"));
container.querySelectorAll("[data-mentor-follow-up]").forEach((button) => {
button.addEventListener("click", () => useMentorQuickPrompt(button.dataset.mentorFollowUp));
});
@@ -322,7 +436,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)}"><i data-lucide="corner-down-right"></i><span>${escapeHtml(item)}</span></button>`).join("")}
${items.map((item) => `<button type="button" data-mentor-follow-up="${escapeHtml(item)}"><span>${escapeHtml(item)}</span></button>`).join("")}
</div>
`;
}
@@ -342,6 +456,7 @@ 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();
@@ -362,7 +477,6 @@ 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)
: [];
@@ -431,6 +545,7 @@ async function streamMentorRequest(body, signal, onDelta, onMeta) {
function useMentorQuickPrompt(prompt) {
const input = document.querySelector("#mentorQuestion");
input.value = prompt || "";
syncMentorComposerHeight();
input.focus();
}
@@ -529,19 +644,24 @@ 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", () => {
state.mentorGrade = button.dataset.mentorGrade || "all";
document.querySelectorAll("[data-mentor-grade]").forEach((item) => {
item.classList.toggle("active", item === button);
});
renderMentorDirectory();
selectMentorGrade(button.dataset.mentorGrade);
closeMentorFilterMenu();
});
});
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));
});
@@ -550,4 +670,70 @@ 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);
});
}
+84 -26
View File
@@ -78,7 +78,7 @@
color: var(--r2-faint);
text-align: right;
text-align: left;
}
.redesigned-pool-view .data-table .number {
@@ -207,7 +207,7 @@
color: var(--r2-faint);
text-align: right;
text-align: left;
}
.redesigned-broken-view .data-table .number {
@@ -335,7 +335,7 @@
color: var(--r2-faint);
text-align: right;
text-align: left;
}
.redesigned-down-view .data-table .number {
@@ -455,7 +455,7 @@
color: var(--r2-faint);
text-align: right;
text-align: left;
}
.redesigned-yesterday-view .data-table .number {
@@ -716,7 +716,6 @@
#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;
@@ -726,9 +725,16 @@
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;
@@ -736,6 +742,12 @@
padding: 5px 9px;
}
#limitPool .data-table tbody td {
height: 40px;
padding: 0 8px;
}
#limitPool .main-grid {
grid-template-columns: minmax(0px, 1fr) 308px;
}
@@ -892,24 +904,6 @@
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);
@@ -2225,6 +2219,70 @@
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,
@@ -2235,9 +2293,9 @@
:is(#limitTable, #brokenTable, #downTable, #yesterdayTable) .row-number {
padding-right: 8px;
padding-left: 8px;
padding-left: 12px;
text-align: center;
text-align: left;
}
: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 num" aria-label="序号">序号</th>
<th class="row-number" 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 num">序号</th>
<th class="row-number">序号</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 num">序号</th>
<th class="row-number">序号</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 num">序号</th>
<th class="row-number">序号</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 num muted">${index + 1}</td>
<td class="row-number muted">${index + 1}</td>
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
<td class="number num"><span class="pool-streak-tag tag red">${streakLabel(row.streak)}</span></td>
<td class="number num"><span class="streak-pill${number(row.streak) >= 4 ? " high" : ""}">${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 num muted">${index + 1}</td>
<td class="row-number 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 num muted">${index + 1}</td>
<td class="row-number 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 num muted">${index + 1}</td>
<td class="row-number 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>
+46 -41
View File
@@ -22,11 +22,11 @@
}
.popularity-source-tag.dual {
border-color: rgb(230, 199, 115);
border-color: var(--warn);
background: var(--amber-soft);
color: rgb(118, 83, 20);
color: var(--warn);
}
.popularity-concepts {
@@ -44,21 +44,21 @@
}
#popularityView .data-table {
font-size: 12px;
font-size: var(--font-size-table);
}
#popularityView .data-table thead th {
height: 32px;
height: 36px;
padding: 6px 9px;
padding: 0 12px;
font-size: 11.5px;
font-size: var(--font-size-caption);
}
#popularityView .data-table tbody td {
height: 39px;
height: 40px;
padding: 5px 9px;
padding: 0 12px;
}
.redesigned-popularity-view {
@@ -93,6 +93,10 @@
margin: 0px;
color: var(--r2-ink);
font-size: var(--font-size-page-title);
font-weight: var(--font-weight-semibold);
}
.popularity-title-v2 > span {
@@ -140,7 +144,7 @@
align-items: center;
min-height: 34px;
min-height: 32px;
padding: 3px;
@@ -152,19 +156,19 @@
}
.popularity-source-tabs-v2 button {
min-height: 27px;
min-height: 26px;
padding: 0px 13px;
border: 0px;
border-radius: 5px;
border-radius: 6px;
background: transparent;
color: var(--r2-sub);
font-size: 11px;
font-size: var(--font-size-label);
cursor: pointer;
}
@@ -190,11 +194,15 @@
}
.popularity-refresh-v2 {
min-height: 34px;
min-height: 32px;
padding: 0px 11px;
height: 32px;
border-radius: 7px;
padding: 0px 12px;
border-radius: 8px;
font-size: var(--font-size-label);
}
.popularity-refresh-v2 .lucide {
@@ -244,15 +252,15 @@
width: 3px;
background: rgb(199, 216, 251);
background: var(--accent-soft);
}
.popularity-glance-v2 article:nth-child(2)::before {
background: rgb(183, 221, 207);
background: var(--down-soft);
}
.popularity-glance-v2 article.consensus::before {
background: rgb(230, 199, 115);
background: var(--warn);
}
.popularity-glance-v2 article > span {
@@ -372,7 +380,7 @@
width: 230px;
height: 33px;
height: 32px;
flex: 0 0 auto;
@@ -380,9 +388,9 @@
padding: 0px 10px;
border: 1px solid rgb(216, 221, 229);
border: 1px solid var(--border-strong);
border-radius: 7px;
border-radius: 8px;
color: var(--r2-faint);
@@ -390,9 +398,9 @@
}
.popularity-search-v2:focus-within {
border-color: rgb(150, 181, 242);
border-color: var(--accent);
box-shadow: rgba(37, 99, 235, 0.09) 0px 0px 0px 3px;
box-shadow: 0 0 0 2px var(--focus-ring);
}
.popularity-search-v2 .lucide {
@@ -446,15 +454,15 @@
z-index: 2;
height: 35px;
height: 36px;
padding: 7px 10px;
padding: 0 12px;
border-bottom-color: var(--r2-line);
color: var(--r2-sub);
font-size: 10.5px;
font-size: var(--font-size-caption);
}
.popularity-table-v2 thead th:nth-child(1) {
@@ -477,11 +485,11 @@
}
.popularity-table-v2 tbody td {
height: 43px;
height: 40px;
padding: 7px 10px;
padding: 0 12px;
font-size: 11.5px;
font-size: var(--font-size-table);
}
.popularity-table-v2 tbody tr {
@@ -541,9 +549,9 @@
color: var(--r2-ink);
font-size: 12px;
font-size: var(--font-size-table);
font-weight: 700;
font-weight: var(--font-weight-semibold);
text-overflow: ellipsis;
@@ -559,7 +567,7 @@
}
.popularity-list-rank-v2 {
color: rgb(64, 85, 115);
color: var(--text-2);
}
.popularity-movement-v2 {
@@ -611,10 +619,6 @@
overflow: hidden;
}
body[data-active-view="popularityView"] .overview-strip {
flex: 0 0 auto;
}
body[data-active-view="popularityView"] #popularityView.active-view {
min-height: 0px;
@@ -658,11 +662,9 @@
}
.popularity-table-v2 tbody td {
height: 39px;
height: 40px;
padding-top: 5px;
padding-bottom: 5px;
padding: 0 12px;
}
}
@@ -857,8 +859,11 @@
width: auto;
}
#popularityView .popularity-table-v2 th:first-child {
#popularityView .popularity-table-v2 th:first-child,
#popularityView .popularity-table-v2 td: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 = [
["排名", "number num"], ["股票", ""], ["最新价(元)", "number num"], ["涨跌幅(%", "number num"],
["排名", "row-number"], ["股票", ""], ["最新价(元)", "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="number num popularity-rank-v2"><b>${index + 1}</b>${index < 3 ? '<span>热</span>' : ""}</td>
<td class="row-number 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>
+92 -91
View File
@@ -217,10 +217,6 @@ 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;
}
@@ -242,7 +238,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
border-radius: 10px;
box-shadow: var(--shadow-xs);
box-shadow: var(--shadow-card);
min-height: 0px !important;
}
@@ -276,15 +272,15 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
.watchlist-section .data-table thead th {
height: 32px;
height: 36px;
padding: 6px 12px;
padding: 0 12px;
}
.watchlist-section .data-table tbody td {
height: 44px;
height: 40px;
padding: 7px 12px;
padding: 0 12px;
}
.trade-journal-section .trade-log-summary:empty {
@@ -384,7 +380,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
#reviewWorkspaceView .review-history-toggle {
min-height: 30px;
min-height: 32px;
display: inline-flex;
@@ -398,13 +394,13 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
border: 1px solid var(--review-line);
border-radius: 7px;
border-radius: 8px;
background: var(--surface);
color: rgb(55, 65, 81);
color: var(--text-secondary);
font-size: 11.5px;
font-size: var(--font-size-label);
white-space: nowrap;
}
@@ -469,7 +465,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
background: var(--surface);
box-shadow: rgba(16, 24, 40, 0.05) 0px 1px 2px;
box-shadow: var(--shadow-card);
}
#reviewWorkspaceView .review-card-heading > div {
@@ -515,9 +511,9 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
#reviewWorkspaceView .data-table thead th {
height: 32px;
height: 36px;
padding: 6px 12px;
padding: 0 12px;
border-bottom: 1px solid var(--review-line-soft);
@@ -525,21 +521,21 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
color: var(--review-faint);
font-size: 10.5px;
font-size: var(--font-size-caption);
font-weight: 600;
font-weight: var(--font-weight-semibold);
}
#reviewWorkspaceView .data-table tbody td {
height: 48px;
height: 40px;
padding: 7px 12px;
padding: 0 12px;
border-bottom: 1px solid var(--review-line-soft);
color: rgb(55, 65, 81);
color: var(--text-primary);
font-size: 12px;
font-size: var(--font-size-table);
}
#reviewWorkspaceView .data-table tbody tr:last-child td {
@@ -559,17 +555,17 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
#reviewWorkspaceView .stock-cell strong {
color: var(--review-ink);
font-size: 12px;
font-size: var(--font-size-table);
}
#reviewWorkspaceView .stock-cell small {
color: var(--review-faint);
font-size: 10px;
font-size: var(--font-size-aux);
}
#reviewWorkspaceView .review-watch-mark {
color: rgb(209, 213, 219);
color: var(--text-tertiary);
font-size: 15px;
@@ -577,7 +573,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
#reviewWorkspaceView .review-watch-mark.red {
color: rgb(224, 69, 54);
color: var(--market-up);
}
#reviewWorkspaceView .review-row-actions {
@@ -601,19 +597,19 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
#reviewWorkspaceView .table-action {
min-height: 25px;
min-height: 32px;
padding: 0px 6px;
border: 0px;
border-radius: 5px;
border-radius: 8px;
background: transparent;
color: var(--review-blue);
font-size: 10.5px;
font-size: var(--font-size-label);
}
#reviewWorkspaceView .table-action:hover {
@@ -621,13 +617,13 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
#reviewWorkspaceView .table-action.down {
color: rgb(139, 146, 158);
color: var(--text-tertiary);
}
#reviewWorkspaceView .table-action.down:hover {
background: var(--surface-hover);
color: rgb(209, 67, 67);
color: var(--market-up);
}
#reviewWorkspaceView .trade-log-table-frame .empty-state {
@@ -667,7 +663,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
#reviewWorkspaceView .trade-log-heading .button {
min-height: 29px;
min-height: 32px;
display: inline-flex;
@@ -679,13 +675,13 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
padding: 0px 10px;
border-radius: 7px;
border-radius: 8px;
background: var(--review-blue);
color: var(--text-inverse);
font-size: 11.5px;
font-size: var(--font-size-label);
}
#reviewWorkspaceView .trade-log-heading .button:hover {
@@ -831,35 +827,35 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
#reviewWorkspaceView .trade-action-add {
border-color: rgb(241, 196, 192);
border-color: var(--market-up);
background: rgb(253, 236, 234);
background: var(--market-up-soft);
color: rgb(214, 62, 50);
color: var(--market-up);
}
#reviewWorkspaceView .trade-action-buy {
border-color: rgb(241, 196, 192);
border-color: var(--market-up);
background: rgb(253, 236, 234);
background: var(--market-up-soft);
color: rgb(214, 62, 50);
color: var(--market-up);
}
#reviewWorkspaceView .trade-action-sell {
border-color: rgb(188, 225, 202);
border-color: var(--market-down);
background: rgb(234, 247, 239);
background: var(--market-down-soft);
color: rgb(22, 139, 67);
color: var(--market-down);
}
#reviewWorkspaceView .trade-action-trim {
border-color: rgb(188, 225, 202);
border-color: var(--market-down);
background: rgb(234, 247, 239);
background: var(--market-down-soft);
color: rgb(22, 139, 67);
color: var(--market-down);
}
#reviewWorkspaceView .trade-tags {
@@ -879,7 +875,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
background: var(--review-blue-soft);
color: rgb(82, 112, 167);
color: var(--action);
font-size: 8.5px;
}
@@ -899,7 +895,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
white-space: nowrap;
color: rgb(75, 85, 99);
color: var(--text-secondary);
font-size: 10.5px;
@@ -989,7 +985,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
#reviewWorkspaceView .journal-form .button {
min-height: 34px;
min-height: 32px;
display: inline-flex;
@@ -1001,13 +997,13 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
padding: 0px 14px;
border-radius: 7px;
border-radius: 8px;
background: var(--review-blue);
color: var(--text-inverse);
font-size: 12px;
font-size: var(--font-size-label);
}
#reviewWorkspaceView .journal-form .button:hover {
@@ -1247,9 +1243,9 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
color: var(--review-ink);
font-size: 19px;
font-size: var(--font-size-page-title);
font-weight: 750;
font-weight: var(--font-weight-semibold);
}
#reviewWorkspaceView .review-page-title .section-subtitle {
@@ -1261,7 +1257,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
white-space: nowrap;
font-size: 12px;
font-size: var(--font-size-caption);
}
#reviewWorkspaceView .review-card-heading {
@@ -1285,13 +1281,13 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
color: var(--review-ink);
font-size: 14.5px;
font-size: var(--font-size-card-title);
font-weight: 720;
font-weight: var(--font-weight-semibold);
}
#reviewWorkspaceView .review-add-watch {
min-height: 29px;
min-height: 32px;
display: inline-flex;
@@ -1303,23 +1299,23 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
padding: 0px 10px;
border: 1px solid rgb(185, 205, 248);
border: 1px solid var(--color-action-line);
border-radius: 7px;
border-radius: 8px;
background: rgb(245, 248, 255);
background: var(--action-soft);
color: var(--review-blue);
font-size: 11.5px;
font-size: var(--font-size-label);
font-weight: 650;
font-weight: var(--font-weight-semibold);
}
#reviewWorkspaceView .review-add-watch:hover {
border-color: rgb(142, 176, 244);
border-color: var(--action);
background: rgb(234, 241, 255);
background: var(--selected);
}
#reviewWorkspaceView .review-add-watch .lucide {
@@ -1374,17 +1370,17 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
#reviewWorkspaceView .review-watchlist-table .stock-cell strong {
font-size: 12.5px;
font-size: var(--font-size-table);
font-weight: 680;
font-weight: var(--font-weight-semibold);
}
#reviewWorkspaceView .review-watchlist-table .stock-cell small {
font-size: 10.5px;
font-size: var(--font-size-aux);
}
#reviewWorkspaceView .watch-attention-score {
color: rgb(63, 75, 94);
color: var(--text-primary);
font-size: 12px;
@@ -1452,11 +1448,11 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
#reviewWorkspaceView .journal-summary-field input:focus {
border-color: var(--review-blue-line);
box-shadow: rgba(37, 99, 235, 0.08) 0px 0px 0px 2px;
box-shadow: 0 0 0 2px var(--focus-ring);
}
#reviewWorkspaceView .journal-summary-field input::placeholder {
color: rgb(166, 173, 183);
color: var(--text-tertiary);
}
#reviewWorkspaceView .journal-form textarea {
@@ -1543,7 +1539,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
.watchlist-editor-form .form-field > label,
.watchlist-editor-form .form-field > span {
color: rgb(55, 65, 81);
color: var(--text-secondary);
font-size: 12px;
@@ -1561,7 +1557,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
padding: 0px 11px;
border: 1px solid rgb(223, 227, 232);
border: 1px solid var(--border-strong);
border-radius: 8px;
@@ -1569,9 +1565,9 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
.watchlist-search-control:focus-within {
border-color: rgb(184, 202, 244);
border-color: var(--color-action-line);
box-shadow: rgba(37, 99, 235, 0.07) 0px 0px 0px 3px;
box-shadow: 0 0 0 3px var(--focus-ring);
}
.watchlist-search-control .lucide {
@@ -1579,7 +1575,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
height: 15px;
color: rgb(154, 163, 176);
color: var(--text-tertiary);
}
.watchlist-search-control input {
@@ -1591,7 +1587,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
outline: 0px;
color: rgb(31, 41, 55);
color: var(--text-primary);
font-style: inherit;
@@ -1645,13 +1641,13 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
border-style: none none solid;
border-color: currentcolor currentcolor rgb(238, 240, 243);
border-color: currentcolor currentcolor var(--border);
border-image: none;
background: var(--surface);
color: rgb(31, 41, 55);
color: var(--text-primary);
text-align: left;
}
@@ -1673,7 +1669,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
.watchlist-search-results button small {
color: rgb(139, 148, 161);
color: var(--text-tertiary);
font-size: 10.5px;
}
@@ -1681,7 +1677,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
.watchlist-search-results button > b {
margin-left: auto;
color: rgb(105, 115, 134);
color: var(--text-secondary);
font-size: 11px;
@@ -1691,7 +1687,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
.watchlist-search-status {
padding: 14px 10px;
color: rgb(139, 148, 161);
color: var(--text-tertiary);
font-size: 11.5px;
@@ -1709,7 +1705,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
padding: 10px 12px;
border: 1px solid rgb(220, 229, 247);
border: 1px solid var(--action-soft);
border-radius: 8px;
@@ -1733,9 +1729,9 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
border-radius: 7px;
background: rgb(232, 239, 255);
background: var(--action-soft);
color: rgb(37, 99, 235);
color: var(--action);
}
.watchlist-selection-icon .lucide {
@@ -1753,7 +1749,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
.watchlist-selection strong {
color: rgb(31, 41, 55);
color: var(--text-primary);
font-size: 13.5px;
}
@@ -1763,13 +1759,13 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
gap: 8px;
color: rgb(123, 132, 145);
color: var(--text-tertiary);
font-size: 10.5px;
}
.watchlist-selection span b {
color: rgb(82, 96, 113);
color: var(--text-secondary);
font-weight: 600;
}
@@ -1791,7 +1787,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
resize: vertical;
border: 1px solid rgb(223, 227, 232);
border: 1px solid var(--border-strong);
border-radius: 8px;
@@ -1825,9 +1821,9 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
}
.watchlist-editor-form textarea:focus {
border-color: rgb(184, 202, 244);
border-color: var(--color-action-line);
box-shadow: rgba(37, 99, 235, 0.07) 0px 0px 0px 3px;
box-shadow: 0 0 0 3px var(--focus-ring);
}
#reviewWorkspaceView .trade-log-table th:nth-child(1) {
@@ -1873,7 +1869,7 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
font-size: 12px;
font-weight: 650;
font-weight: var(--font-weight-semibold);
min-height: 0px;
@@ -2201,3 +2197,8 @@ body[data-active-view="reviewWorkspaceView"] .overview-strip {
height: auto;
}
}
#reviewWorkspaceView .data-table thead th:first-child,
#reviewWorkspaceView .data-table tbody td:first-child {
text-align: left;
}
+60 -49
View File
@@ -25,7 +25,7 @@
}
.rotation-day.selected-day {
background: rgb(240, 246, 253);
background: var(--selected);
}
.rotation-sector-chip.selected {
@@ -81,11 +81,11 @@
}
.rotation-day:nth-child(2n+1) {
background: rgb(246, 248, 250);
background: var(--surface-subtle);
}
.rotation-day-sector {
border-color: rgb(231, 235, 239);
border-color: var(--border);
border-radius: 4px;
@@ -107,7 +107,7 @@
}
:where(#rotationView) .rotation-swatch {
background: rgba(53, 106, 230, 0.12);
background: var(--accent-soft);
}
:where(#rotationView) .rotation-tracker-copy {
@@ -157,9 +157,9 @@
cursor: pointer;
border-bottom: 1px dashed rgba(135, 149, 173, 0.22);
border-bottom: 1px dashed var(--border);
background: rgba(53, 106, 230, calc(.04 + var(--rotation-heat) * .46));
background: color-mix(in srgb, var(--accent) calc(4% + var(--rotation-heat) * 46%), transparent);
}
.redesigned-rotation-view {
@@ -185,9 +185,9 @@
color: var(--r2-ink);
font-size: 17px;
font-size: var(--font-size-page-title);
font-weight: 800;
font-weight: var(--font-weight-semibold);
letter-spacing: 0px;
}
@@ -241,7 +241,7 @@
color: var(--r2-sub);
font-size: 12px;
font-size: var(--font-size-label);
line-height: 1;
}
@@ -260,13 +260,15 @@
.rotation-order-control button:focus-visible,
.rotation-sector-chip:focus-visible,
.rotation-track-cancel:focus-visible {
outline: rgba(37, 99, 235, 0.28) solid 2px;
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.rotation-export-button {
min-height: 30px;
min-height: 32px;
height: 32px;
display: inline-flex;
@@ -274,17 +276,17 @@
justify-content: center;
padding: 5px 12px;
padding: 0 12px;
border: 1px solid var(--r2-line);
border: 1px solid var(--border-strong);
border-radius: 7px;
border-radius: 8px;
background: var(--surface);
color: rgb(55, 65, 81);
color: var(--text-primary);
font-size: 12px;
font-size: var(--font-size-label);
font-weight: 500;
}
@@ -433,17 +435,17 @@
}
#rotationView .rotation-swatch.strong {
background: rgb(112, 155, 245);
background: var(--accent);
}
#rotationView .rotation-swatch.warm {
background: rgb(203, 220, 255);
background: var(--accent-soft);
}
#rotationView .rotation-swatch.mild {
background: rgb(240, 244, 250);
background: var(--surface-muted);
border: 1px solid rgb(223, 230, 240);
border: 1px solid var(--border);
}
#rotationView .rotation-tracker {
@@ -487,7 +489,7 @@
}
#rotationView .rotation-tracker-copy span {
color: rgb(59, 98, 196);
color: var(--accent-hover);
font-size: 12px;
@@ -535,7 +537,7 @@
border-radius: 2px 2px 0px 0px;
background: rgb(147, 180, 245);
background: var(--accent);
}
#rotationView .rotation-tracker-spark small {
@@ -543,7 +545,7 @@
top: -1px;
color: rgb(59, 98, 196);
color: var(--accent-hover);
font-size: 9px;
}
@@ -555,7 +557,7 @@
border-style: dashed dashed none;
border-color: rgb(185, 200, 232) rgb(185, 200, 232) currentcolor;
border-color: var(--action-line) var(--action-line) currentcolor;
border-image: none;
@@ -567,7 +569,9 @@
}
.rotation-track-cancel {
min-height: 30px;
min-height: 32px;
height: 32px;
display: inline-flex;
@@ -575,21 +579,21 @@
justify-content: center;
border: 1px solid var(--r2-line);
border: 1px solid var(--border-strong);
border-radius: 7px;
border-radius: 8px;
background: var(--surface);
color: rgb(55, 65, 81);
color: var(--text-primary);
font-size: 12px;
font-size: var(--font-size-label);
font-weight: 500;
margin-left: auto;
padding: 4px 9px;
padding: 0 12px;
}
#rotationView .rotation-history {
@@ -697,7 +701,7 @@
padding: 5px;
background: rgb(251, 252, 254);
background: var(--surface-subtle);
}
#rotationView .rotation-sector-chip {
@@ -817,7 +821,7 @@
border-color: var(--r2-blue);
box-shadow: inset 3px 0 0 var(--r2-blue), 0 0 0 1px rgba(37, 99, 235, .12);
box-shadow: inset 3px 0 0 var(--r2-blue), 0 0 0 1px var(--accent-soft);
}
.rotation-cell-tooltip {
@@ -864,9 +868,9 @@
}
#rotationView .rotation-table thead th {
height: 37px;
height: 36px;
padding: 8px 12px;
padding: 0 12px;
border-bottom: 1px solid var(--r2-line);
@@ -874,7 +878,7 @@
color: var(--r2-sub);
font-size: 12px;
font-size: var(--font-size-caption);
font-weight: 600;
@@ -887,8 +891,13 @@
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: rgb(75, 85, 99);
color: var(--text-2);
cursor: pointer;
@@ -918,14 +927,16 @@
}
#rotationView .rotation-table tbody td {
height: 42px;
height: 40px;
padding: 8px 12px;
padding: 0 12px;
border-bottom: 1px solid var(--r2-line-soft);
color: var(--r2-ink);
font-size: var(--font-size-table);
white-space: nowrap;
}
@@ -934,7 +945,7 @@
}
#rotationView .rotation-table tbody tr:hover td {
background: rgb(248, 250, 255);
background: var(--table-hover);
}
#rotationView .rotation-table .stock-name {
@@ -954,9 +965,9 @@
}
#rotationView .trend-cool {
background: rgb(232, 244, 253);
background: var(--accent-soft);
color: rgb(37, 99, 235);
color: var(--accent);
}
#rotationView .trend-new {
@@ -1040,25 +1051,25 @@
}
#rotationView .rotation-sector-chip.heat-mild {
border-color: rgb(229, 234, 241);
border-color: var(--border);
background: rgb(246, 248, 251);
background: var(--surface-muted);
box-shadow: none;
}
#rotationView .rotation-sector-chip.heat-strong {
border-color: rgba(37, 99, 235, 0.24);
border-color: var(--action-line);
background: rgb(197, 215, 251);
background: var(--accent-soft);
box-shadow: none;
}
#rotationView .rotation-sector-chip.heat-warm {
border-color: rgba(37, 99, 235, 0.12);
border-color: var(--action-line);
background: rgb(231, 239, 255);
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
box-shadow: none;
}
@@ -1066,7 +1077,7 @@
#rotationView .rotation-sector-chip:hover {
z-index: 6;
border-color: rgba(37, 99, 235, 0.34);
border-color: var(--accent);
filter: saturate(1.06);
@@ -1080,7 +1091,7 @@
border-collapse: collapse;
font-size: 12.5px;
font-size: var(--font-size-table);
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="number num">序号</th><th>代码</th><th>股票</th>
<th class="row-number">序号</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="number num 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="row-number 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>
+116 -105
View File
@@ -1,8 +1,8 @@
/* Canonical CSS owner: screener. Historical layers consolidated 2026-08-02. */
#screenerView .section-toolbar h2 {
font-size: 17px;
font-size: var(--font-size-page-title);
font-weight: 800;
font-weight: var(--font-weight-semibold);
}
@media (max-width: 767px) {
@@ -25,7 +25,7 @@
.curated-detail-header > div > span,
.quant-panel-heading span {
color: rgb(100, 116, 139);
color: var(--text-secondary);
font-size: 12px;
@@ -35,7 +35,7 @@
:where(#screenerView) .curated-detail-header h3 {
margin: 3px 0px 0px;
color: rgb(17, 24, 39);
color: var(--text-primary);
letter-spacing: 0px;
}
@@ -45,7 +45,7 @@
border-radius: 5px;
background: rgb(232, 238, 252);
background: var(--action-soft);
}
:where(#screenerView) .curated-search .lucide {
@@ -55,19 +55,19 @@
:where(#screenerView) .curated-search input {
background: transparent;
color: rgb(17, 24, 39);
color: var(--text-primary);
}
.curated-strategy-card.active .curated-strategy-rank {
background: rgb(220, 232, 255);
background: var(--action-soft);
color: rgb(29, 78, 216);
color: var(--action);
}
:where(#screenerView) .curated-detail-header p {
margin: 9px 0px 0px;
color: rgb(100, 116, 139);
color: var(--text-secondary);
}
.curated-strategy-badges {
@@ -87,9 +87,9 @@
border-radius: 5px;
background: rgb(241, 245, 249);
background: var(--hover);
color: rgb(71, 85, 105);
color: var(--text-secondary);
font-weight: 650;
@@ -97,9 +97,9 @@
}
.curated-strategy-badges span:first-child {
background: rgb(234, 241, 255);
background: var(--action-soft);
color: rgb(29, 78, 216);
color: var(--action);
}
:where(#screenerView) .mini-section-heading {
@@ -111,11 +111,11 @@
:where(#screenerView) .mini-section-heading h4 {
font-weight: 740;
color: rgb(31, 41, 55);
color: var(--text-primary);
}
.mini-section-heading > span {
color: rgb(100, 116, 139);
color: var(--text-secondary);
font-size: 12px;
}
@@ -141,15 +141,15 @@
gap: 12px;
border-bottom: 1px solid rgb(238, 240, 243);
border-bottom: 1px solid var(--border);
}
:where(#screenerView) .curated-rule-row span {
color: rgb(71, 85, 105);
color: var(--text-secondary);
}
:where(#screenerView) .curated-rule-row strong {
color: rgb(17, 24, 39);
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
@@ -163,7 +163,7 @@
.curated-score-row > span:first-child {
overflow: hidden;
color: rgb(71, 85, 105);
color: var(--text-secondary);
text-overflow: ellipsis;
@@ -177,7 +177,7 @@
border-radius: 4px;
background: rgb(237, 240, 244);
background: var(--surface-muted);
}
.curated-score-track i {
@@ -187,11 +187,11 @@
border-radius: inherit;
background: rgb(79, 118, 199);
background: var(--action);
}
.curated-score-row strong {
color: rgb(51, 65, 85);
color: var(--text-primary);
text-align: right;
@@ -211,11 +211,11 @@
}
:where(#screenerView) .curated-data-status > .lucide {
color: rgb(22, 163, 74);
color: var(--market-down);
}
.curated-data-status.missing > .lucide {
color: rgb(217, 119, 6);
color: var(--warning-color);
}
.curated-data-status span {
@@ -225,13 +225,13 @@
:where(#screenerView) .curated-data-status strong {
display: block;
color: rgb(51, 65, 85);
color: var(--text-primary);
}
:where(#screenerView) .curated-data-status small {
display: block;
color: rgb(124, 135, 152);
color: var(--text-tertiary);
}
.quant-universe-grid .form-field {
@@ -239,16 +239,16 @@
}
.quant-universe-grid input[type="number"] {
height: 40px;
height: 32px;
}
.quant-rule-row input:focus,
.quant-rule-row select:focus {
border-color: rgb(37, 99, 235);
border-color: var(--action);
outline: 0px;
box-shadow: rgba(37, 99, 235, 0.1) 0px 0px 0px 3px;
box-shadow: 0 0 0 3px var(--focus-ring);
}
:where(#screenerView) .quant-remove-button {
@@ -268,7 +268,7 @@
float: right;
color: rgb(29, 78, 216);
color: var(--action);
}
:where(#screenerView) .quant-weight-status > div {
@@ -278,7 +278,7 @@
:where(#screenerView) .quant-weight-status i {
width: 100%;
background: rgb(37, 99, 235);
background: var(--action);
transition: width 180ms, background 180ms;
}
@@ -305,7 +305,7 @@
}
.quant-summary-pane {
border-top: 1px solid rgb(229, 231, 235);
border-top: 1px solid var(--border);
border-left: 0px;
}
@@ -549,7 +549,7 @@
padding: 12px;
background: rgb(248, 249, 251);
background: var(--surface-subtle);
}
#screenerView .strategy-list {
@@ -575,7 +575,7 @@
}
#screenerView .strategy-item.active {
border-color: rgb(201, 219, 241);
border-color: var(--color-action-line);
background: var(--action-soft);
}
@@ -724,7 +724,7 @@
#screenerView .backtest-panel {
padding: 10px 14px 12px;
background: rgb(251, 252, 253);
background: var(--surface-subtle);
}
#screenerView .backtest-panel > .workspace-heading {
@@ -1099,7 +1099,7 @@
}
.tracking-status.active {
border-color: rgb(197, 215, 237);
border-color: var(--color-action-line);
background: var(--action-soft);
@@ -1107,7 +1107,7 @@
}
.tracking-status.complete {
border-color: rgb(185, 223, 207);
border-color: var(--market-down);
background: var(--market-down-soft);
@@ -1149,9 +1149,9 @@
border-radius: 50%;
background: rgb(229, 231, 235);
background: var(--border);
color: rgb(156, 163, 175);
color: var(--text-tertiary);
font-size: 11px;
@@ -1189,11 +1189,11 @@
.screener-strategy-title b.neutral {
background: var(--surface-muted);
color: rgb(107, 114, 128);
color: var(--text-secondary);
}
:where(#screenerView) .screener-strategy-summary p {
color: rgb(107, 114, 128);
color: var(--text-secondary);
font-size: 12px;
}
@@ -1225,7 +1225,7 @@
border-right: 0px;
border-bottom-color: rgb(238, 240, 243);
border-bottom-color: var(--border);
}
#screenerView .screener-result-frame th {
@@ -1235,13 +1235,13 @@
border-right: 0px;
border-bottom-color: rgb(238, 240, 243);
border-bottom-color: var(--border);
height: 38px;
background: var(--table-header);
color: rgb(107, 114, 128);
color: var(--text-secondary);
font-size: 12px;
}
@@ -1281,7 +1281,7 @@
background: var(--surface);
box-shadow: rgba(15, 23, 42, 0.16) -12px 0px 32px;
box-shadow: var(--shadow-float);
}
.strategy-drawer[open] {
@@ -1323,7 +1323,7 @@
}
.strategy-drawer-header span {
color: rgb(156, 163, 175);
color: var(--text-tertiary);
font-size: 10.5px;
}
@@ -1365,7 +1365,7 @@
border-bottom: 1px solid var(--border);
background: rgb(248, 249, 251);
background: var(--surface-subtle);
}
#screenerView .strategy-drawer .strategy-list {
@@ -1602,7 +1602,7 @@
padding-top: 5px;
border-top: 1px solid rgb(238, 240, 243);
border-top: 1px solid var(--border);
}
#screenerView .screener-backtest-strip {
@@ -1684,7 +1684,7 @@
background: var(--surface);
color: rgb(31, 41, 55);
color: var(--text-primary);
font-style: inherit;
@@ -1722,7 +1722,7 @@
padding: 0px 9px;
color: rgb(31, 41, 55);
color: var(--text-primary);
font: inherit;
@@ -1749,7 +1749,7 @@
.curated-library-pane {
border-right: 0px;
border-bottom: 1px solid rgb(229, 231, 235);
border-bottom: 1px solid var(--border);
display: block;
@@ -1788,9 +1788,9 @@
}
.curated-search:focus-within {
border-color: rgb(142, 172, 239);
border-color: var(--action);
box-shadow: rgba(53, 106, 230, 0.1) 0px 0px 0px 3px;
box-shadow: 0 0 0 3px var(--focus-ring);
}
:where(#screenerView) .screener-page-heading {
@@ -1824,7 +1824,7 @@
}
:where(#screenerView) .curated-library-heading h3 {
color: rgb(17, 24, 39);
color: var(--text-primary);
letter-spacing: 0px;
}
@@ -1870,7 +1870,7 @@
justify-content: space-between;
border-bottom: 1px solid rgb(232, 235, 239);
border-bottom: 1px solid var(--border);
}
:where(#screenerView) .curated-execution-bar {
@@ -1890,7 +1890,7 @@
}
#screenerView .regime-evidence strong {
color: rgb(146, 88, 6);
color: var(--warning-color);
font-size: 12px;
@@ -1902,7 +1902,7 @@
overflow: visible;
color: rgb(162, 106, 24);
color: var(--warning-color);
font-size: 10.5px;
@@ -2119,10 +2119,6 @@ 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;
@@ -2350,7 +2346,7 @@ body[data-active-view="screenerView"] .overview-strip {
}
:where(#screenerView) .curated-strategy-card:hover {
box-shadow: rgba(37, 99, 235, 0.08) 0px 4px 14px;
box-shadow: var(--shadow-raised);
}
:where(#screenerView) .quant-screener-panel {
@@ -2414,7 +2410,7 @@ body[data-active-view="screenerView"] .overview-strip {
border-radius: 999px;
background: rgb(223, 229, 238);
background: var(--border-strong);
accent-color: var(--primary);
@@ -2532,19 +2528,19 @@ body[data-active-view="screenerView"] .overview-strip {
color: var(--r2-ink);
font-size: 17px;
font-size: var(--font-size-page-title);
font-weight: 800;
font-weight: var(--font-weight-semibold);
}
#screenerView .screener-page-heading .section-subtitle {
color: var(--r2-faint);
font-size: 12px;
font-size: var(--font-size-caption);
}
#screenerView .screener-mode-tabs {
min-height: 34px;
min-height: 32px;
display: inline-flex;
@@ -2662,7 +2658,7 @@ body[data-active-view="screenerView"] .overview-strip {
}
#screenerView .step-line.complete {
background: rgb(134, 213, 160);
background: var(--market-down);
}
#screenerView .screener-overview-card {
@@ -2712,7 +2708,7 @@ body[data-active-view="screenerView"] .overview-strip {
border-radius: 5px;
background: rgb(244, 246, 248);
background: var(--surface-muted);
color: var(--r2-sub);
@@ -2766,7 +2762,7 @@ body[data-active-view="screenerView"] .overview-strip {
}
#screenerView .regime-option:hover {
border-color: rgb(187, 200, 219);
border-color: var(--border-strong);
color: var(--r2-ink);
}
@@ -2979,7 +2975,7 @@ body[data-active-view="screenerView"] .overview-strip {
#screenerView .result-toolbar .count-badge {
border-radius: 5px;
background: rgb(241, 243, 246);
background: var(--surface-muted);
color: var(--r2-sub);
@@ -3039,25 +3035,29 @@ body[data-active-view="screenerView"] .overview-strip {
}
#screenerView .data-table {
font-size: 11.5px;
font-size: var(--font-size-table);
}
#screenerView .data-table thead th {
height: 39px;
height: 36px;
padding: 8px 11px;
padding: 0 12px;
background: var(--table-header);
color: var(--r2-sub);
font-size: 10.5px;
font-size: var(--font-size-caption);
font-weight: var(--font-weight-semibold);
}
#screenerView .data-table tbody td {
height: 45px;
height: 40px;
padding: 8px 11px;
padding: 0 12px;
font-size: var(--font-size-table);
}
#screenerView .data-table .stock-name {
@@ -3069,7 +3069,7 @@ body[data-active-view="screenerView"] .overview-strip {
overflow: hidden;
color: rgb(95, 107, 124);
color: var(--text-secondary);
text-overflow: ellipsis;
}
@@ -3091,13 +3091,13 @@ body[data-active-view="screenerView"] .overview-strip {
#screenerView .probability-value small {
margin-top: 3px;
font-weight: 500;
font-weight: var(--font-weight-medium);
display: block;
color: var(--r2-faint);
font-size: 9px;
font-size: var(--font-size-aux);
}
#screenerView .tracking-summary {
@@ -3341,7 +3341,7 @@ body[data-active-view="screenerView"] .overview-strip {
}
#screenerView .curated-school-filters button.active {
border-color: rgb(197, 212, 241);
border-color: var(--color-action-line);
background: var(--scr-blue-soft);
@@ -3562,7 +3562,7 @@ body[data-active-view="screenerView"] .overview-strip {
border-radius: 5px;
background: rgb(238, 241, 245);
background: var(--surface-muted);
color: var(--r2-sub);
@@ -3598,7 +3598,7 @@ body[data-active-view="screenerView"] .overview-strip {
border-radius: 4px;
background: rgb(238, 241, 245);
background: var(--surface-muted);
color: var(--r2-faint);
@@ -3953,7 +3953,7 @@ body[data-active-view="screenerView"] .overview-strip {
border-radius: 6px;
background: rgb(247, 248, 250);
background: var(--surface-subtle);
}
#screenerView .quant-rule-rows {
@@ -3989,7 +3989,7 @@ body[data-active-view="screenerView"] .overview-strip {
min-height: 30px;
border: 1px solid rgb(220, 225, 232);
border: 1px solid var(--border-strong);
border-radius: 6px;
@@ -4003,7 +4003,7 @@ body[data-active-view="screenerView"] .overview-strip {
min-height: 30px;
border: 1px solid rgb(220, 225, 232);
border: 1px solid var(--border-strong);
border-radius: 6px;
@@ -4075,7 +4075,7 @@ body[data-active-view="screenerView"] .overview-strip {
border-radius: 3px;
background: rgb(232, 235, 240);
background: var(--surface-muted);
}
#screenerView .quant-weight-status i {
@@ -4109,7 +4109,7 @@ body[data-active-view="screenerView"] .overview-strip {
}
#screenerView .strategy-drawer::backdrop {
background: rgba(20, 29, 44, 0.38);
background: var(--backdrop);
backdrop-filter: blur(3px);
}
@@ -4587,7 +4587,7 @@ body[data-active-view="screenerView"] .overview-strip {
}
#screenerTrackingView .tracking-back-button:hover {
border-color: rgb(185, 200, 228);
border-color: var(--color-action-line);
color: var(--scr-blue);
}
@@ -4743,23 +4743,27 @@ body[data-active-view="screenerView"] .overview-strip {
}
#screenerTrackingView .data-table {
font-size: 11.5px;
font-size: var(--font-size-table);
}
#screenerTrackingView .data-table thead th {
height: 39px;
height: 36px;
padding: 8px 11px;
padding: 0 12px;
background: var(--table-header);
font-size: 10.5px;
font-size: var(--font-size-caption);
font-weight: var(--font-weight-semibold);
}
#screenerTrackingView .data-table tbody td {
height: 46px;
height: 40px;
padding: 8px 11px;
padding: 0 12px;
font-size: var(--font-size-table);
}
#screenerTrackingView .stock-cell {
@@ -4775,7 +4779,7 @@ body[data-active-view="screenerView"] .overview-strip {
#screenerTrackingView .stock-cell small {
color: var(--r2-faint);
font-size: 9px;
font-size: var(--font-size-aux);
}
#screenerTrackingView .table-action.danger {
@@ -5495,7 +5499,7 @@ body[data-active-view="screenerView"] .overview-strip {
flex-direction: column;
border: 1px solid rgb(243, 201, 195);
border: 1px solid var(--market-up);
border-radius: 9px;
@@ -5677,7 +5681,7 @@ body[data-active-view="screenerView"] .overview-strip {
}
#screenerView .screener-tracking-entry:focus-visible {
outline: rgba(37, 99, 235, 0.45) solid 2px;
outline: 2px solid var(--action);
outline-offset: 2px;
}
@@ -5729,11 +5733,11 @@ body[data-active-view="screenerView"] .overview-strip {
padding: 3px 8px;
border: 1px solid rgb(217, 226, 239);
border: 1px solid var(--border-strong);
border-radius: 5px;
background: rgb(247, 249, 252);
background: var(--surface-subtle);
color: var(--r2-sub);
@@ -5866,7 +5870,7 @@ body[data-active-view="screenerView"] .overview-strip {
width: 100%;
min-width: 1180px;
min-width: 1376px;
table-layout: fixed;
}
@@ -5887,12 +5891,12 @@ body[data-active-view="screenerView"] .overview-strip {
width: 78px;
}
#screenerView .screener-result-columns col:nth-child(5) {
width: 104px;
#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(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;
@@ -6563,3 +6567,10 @@ body[data-active-view="screenerView"] .overview-strip {
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;
}
+85 -81
View File
@@ -218,7 +218,11 @@
.sentiment-history-table thead th {
border-bottom-color: rgb(216, 224, 230);
font-size: 11.5px;
height: 36px;
font-size: var(--fs-caption);
font-weight: 600;
}
.sentiment-history-table .sentiment-history-groups th {
@@ -313,24 +317,10 @@
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;
}
@@ -377,15 +367,15 @@
}
.sentiment-phase-badge.phase-repair {
background: rgb(231, 246, 246);
background: var(--accent-soft);
color: rgb(11, 109, 116);
color: var(--accent);
}
.sentiment-phase-badge.phase-fermentation {
background: rgb(237, 246, 234);
background: var(--up-soft);
color: rgb(61, 113, 51);
color: var(--up);
}
.sentiment-phase-badge.phase-climax {
@@ -884,19 +874,21 @@
min-width: 0px;
min-height: 33px;
min-height: 0px;
display: flex;
flex: 0 0 auto;
flex-direction: row;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
align-items: flex-start;
padding: 0px 10px 0px 0px;
gap: 2px;
padding: 0 16px;
border: 0px;
}
@@ -924,11 +916,11 @@
}
.overview-strip .sentiment-text {
color: var(--r2-ink);
color: var(--accent);
font-size: 12px;
font-size: var(--fs-caption);
font-weight: 600;
font-weight: 500;
white-space: nowrap;
}
@@ -1037,25 +1029,31 @@
align-items: flex-start;
gap: 16px;
gap: 14px;
padding: 14px 16px;
padding: 14px 16px 10px;
}
.sentiment-current-phase-badge {
min-width: 94px;
min-width: 92px;
flex: 0 0 auto;
display: block;
display: flex;
padding: 10px 18px;
flex-direction: column;
border: 1px solid rgb(245, 207, 201);
align-items: center;
border-radius: 10px;
gap: 2px;
background: var(--r2-up-soft);
padding: 10px 16px;
border: 1px solid var(--accent);
border-radius: var(--card-radius);
background: var(--accent-soft);
text-align: center;
}
@@ -1063,11 +1061,11 @@
.sentiment-current-phase-badge strong {
display: block;
color: var(--r2-up);
color: var(--accent);
font-size: 19px;
font-weight: 800;
font-weight: 700;
line-height: 1.35;
}
@@ -1075,11 +1073,11 @@
.sentiment-current-phase-badge span {
display: block;
margin-top: 2px;
margin-top: 0;
color: var(--r2-sub);
color: var(--text-3);
font-size: 11px;
font-size: var(--fs-aux);
white-space: nowrap;
}
@@ -1091,15 +1089,17 @@
}
.sentiment-phase-info p {
color: var(--r2-sub);
color: var(--text-2);
font-size: 12.5px;
font-size: var(--fs-label);
line-height: 1.7;
line-height: 1.6;
}
.sentiment-phase-info p b {
font-weight: 700;
color: var(--text-1);
font-weight: 600;
}
.sentiment-phase-info p .down {
@@ -1113,17 +1113,17 @@
.sentiment-phase-advice {
margin-top: 8px;
padding: 5px 9px;
padding: 8px 10px;
border-radius: 6px;
border-radius: var(--radius-md);
background: var(--r2-amber-soft);
background: var(--surface-muted);
color: var(--r2-amber);
color: var(--text-2);
font-size: 12px;
font-size: var(--fs-caption);
line-height: 1.6;
line-height: 1.55;
}
.sentiment-feedback-strip {
@@ -1131,45 +1131,47 @@
grid-template-columns: 1fr 1fr;
border-top: 1px solid var(--r2-line-soft);
border-top: 1px solid var(--border);
}
.sentiment-feedback-strip > span {
min-width: 0px;
display: grid;
display: flex;
grid-template-columns: auto 1fr;
flex-wrap: wrap;
gap: 2px 8px;
align-items: baseline;
padding: 8px 12px;
gap: 2px 6px;
color: var(--r2-faint);
padding: 9px 14px;
font-size: 10.5px;
color: var(--text-3);
font-size: var(--fs-caption);
}
.sentiment-feedback-strip > span + span {
border-left: 1px solid var(--r2-line-soft);
border-left: 1px solid var(--border);
}
.sentiment-feedback-strip strong {
color: var(--r2-ink);
color: var(--text-1);
font-size: 11.5px;
font-size: inherit;
text-align: right;
font-weight: 600;
text-align: left;
}
.sentiment-feedback-strip small {
grid-column: 1 / -1;
overflow: hidden;
color: var(--r2-sub);
color: var(--text-3);
font-size: 10px;
font-size: var(--fs-aux);
text-overflow: ellipsis;
@@ -1309,19 +1311,21 @@
.redesigned-sentiment-view .sentiment-history-table td,
.redesigned-sentiment-view .sentiment-history-table th {
height: auto;
height: 40px;
padding: 8px 10px;
padding: 0 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(--r2-sub);
color: var(--text-2);
font-size: 12px;
font-size: var(--fs-caption);
font-weight: 600;
}
@@ -1399,17 +1403,17 @@
}
.overview-strip .sentiment-block .sentiment-text {
display: block;
display: inline-flex;
margin: 0px;
padding: 0px;
padding: 0 6px;
font-size: 11px;
font-size: var(--fs-caption);
font-weight: 500;
line-height: 1;
line-height: 18px;
letter-spacing: 0px;
@@ -1478,19 +1482,19 @@
}
.overview-strip[data-overview-expanded="true"] .sentiment-block {
min-height: 76px;
min-height: 0;
justify-content: center;
gap: 4px;
gap: 2px;
padding: 10px 16px;
padding: 0 16px;
flex-direction: row;
flex-direction: column;
align-items: center;
align-items: flex-start;
border-right: 1px solid var(--line-soft);
border-right: 0;
background: transparent;
}
@@ -1628,9 +1632,9 @@
}
:root[data-theme="dark"] #sentimentCycleView .sentiment-phase-badge.phase-fermentation {
background: var(--market-down-soft);
background: var(--market-up-soft);
color: var(--market-down);
color: var(--market-up);
}
: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 class="${row.trade_date === latest?.trade_date ? "latest-row" : ""}">
<tr>
<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>
+37 -30
View File
@@ -184,15 +184,15 @@
padding: 9px 13px;
border-bottom-color: rgb(231, 235, 239);
border-bottom-color: var(--border);
}
.theme-directory-item:hover {
background: rgb(241, 246, 251);
background: var(--hover);
}
.theme-directory-item.active {
background: rgb(234, 242, 251);
background: var(--selected);
box-shadow: inset 3px 0 var(--action);
}
@@ -294,6 +294,10 @@
margin: 0px;
color: var(--r2-ink);
font-size: var(--font-size-page-title);
font-weight: var(--font-weight-semibold);
}
.theme-title-v2 > div > span {
@@ -331,7 +335,7 @@
.theme-search-v2 {
width: 250px;
height: 34px;
height: 32px;
display: flex;
@@ -341,9 +345,9 @@
padding: 0px 10px;
border: 1px solid var(--control-border);
border: 1px solid var(--border-strong);
border-radius: 7px;
border-radius: 8px;
background: var(--surface);
@@ -389,11 +393,15 @@
}
.theme-refresh-v2 {
min-height: 34px;
min-height: 32px;
height: 32px;
padding: 0px 12px;
border-radius: 7px;
border-radius: 8px;
font-size: var(--font-size-label);
}
.theme-refresh-v2 .lucide {
@@ -441,17 +449,17 @@
}
.theme-summary-v2 span {
color: var(--r2-sub);
color: var(--text-3);
font-size: 11px;
font-size: var(--font-size-caption);
}
.theme-summary-v2 strong {
color: var(--r2-ink);
font-size: 19px;
font-size: var(--font-size-metric);
font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-semibold);
font-variant-numeric: tabular-nums;
}
@@ -527,9 +535,9 @@
color: var(--r2-ink);
font-size: 14px;
font-size: var(--font-size-card-title);
font-weight: 700;
font-weight: var(--font-weight-semibold);
}
.theme-card-head-v2 span {
@@ -675,11 +683,11 @@
.theme-rank-v2 {
color: var(--r2-faint);
font-size: 10.5px;
font-size: var(--font-size-aux);
font-variant-numeric: tabular-nums;
text-align: center;
text-align: left;
}
.theme-directory-item-v2:nth-child(-n+3) .theme-rank-v2 {
@@ -1028,9 +1036,9 @@
z-index: 2;
height: 34px;
height: 36px;
padding: 6px 11px;
padding: 0 12px;
border-bottom-color: var(--r2-line);
@@ -1038,15 +1046,20 @@
color: var(--r2-sub);
font-size: 10.5px;
font-size: var(--font-size-caption);
}
.theme-members-table-v2 tbody td {
height: 39px;
height: 40px;
padding: 7px 11px;
padding: 0 12px;
font-size: 11.5px;
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;
}
.theme-members-table-v2 tbody tr {
@@ -1076,10 +1089,6 @@
overflow: hidden;
}
body[data-active-view="themeLibraryView"] .overview-strip {
flex: 0 0 auto;
}
body[data-active-view="themeLibraryView"] #themeLibraryView.active-view {
min-height: 0px;
@@ -1184,11 +1193,9 @@
}
.theme-members-table-v2 tbody td {
height: 35px;
height: 40px;
padding-top: 5px;
padding-bottom: 5px;
padding: 0 12px;
}
}
+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="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>
<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>
</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 num muted">${index + 1}</td>
<tr data-code="${escapeHtml(row.code)}"><td class="row-number 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,6 +8,11 @@ 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);
+103 -89
View File
@@ -12,7 +12,7 @@
padding: 20px;
background: rgb(237, 241, 244);
background: var(--canvas);
}
.auth-gate[hidden] {
@@ -26,13 +26,13 @@
padding: 26px;
border: 1px solid var(--line-strong);
border: 1px solid var(--border-strong);
border-radius: 6px;
border-radius: 12px;
background: var(--surface);
box-shadow: var(--shadow);
box-shadow: var(--shadow-raised);
}
.auth-brand {
@@ -46,7 +46,9 @@
.auth-brand h1 {
margin: 0px;
font-size: 22px;
font-size: var(--font-size-page-title);
font-weight: var(--font-weight-semibold);
}
.auth-brand span {
@@ -54,9 +56,9 @@
margin-top: 5px;
color: var(--text-muted);
color: var(--text-3);
font-size: 12px;
font-size: var(--font-size-caption);
}
.auth-tabs {
@@ -88,11 +90,11 @@
}
.auth-tab.active {
border-bottom-color: var(--coral);
border-bottom-color: var(--action);
color: var(--text);
color: var(--text-1);
font-weight: 750;
font-weight: var(--font-weight-semibold);
}
.auth-form {
@@ -106,15 +108,15 @@
.auth-form .button {
width: 100%;
min-height: 40px;
min-height: 32px;
}
.auth-error {
margin: 0px;
color: rgb(185, 54, 39);
color: var(--market-up);
font-size: 12px;
font-size: var(--font-size-caption);
line-height: 1.5;
}
@@ -146,23 +148,23 @@
.admin-section-picker select {
width: 100%;
min-height: 40px;
min-height: 32px;
padding: 0px 11px;
border: 1px solid var(--line-strong);
border: 1px solid var(--border-strong);
border-radius: 5px;
border-radius: 8px;
background: var(--surface);
color: var(--text-primary);
font-size: 13px;
font-size: var(--font-size-label);
}
.admin-section-picker select:focus-visible {
outline: 2px solid var(--blue);
outline: 2px solid var(--action);
outline-offset: 2px;
}
@@ -307,13 +309,13 @@
min-width: 0px;
height: 34px;
height: 32px;
padding: 0px 8px;
border: 1px solid var(--line-strong);
border: 1px solid var(--border-strong);
border-radius: 4px;
border-radius: 8px;
background: var(--surface);
}
@@ -386,11 +388,11 @@
}
.connection-status.connected {
border-color: rgb(167, 222, 201);
border-color: var(--market-down);
background: var(--green-soft);
color: rgb(8, 106, 75);
color: var(--market-down);
}
.settings-dialog form {
@@ -470,11 +472,11 @@
}
.account-button > span {
min-width: 0px;
flex: 0 0 auto;
overflow: hidden;
min-width: max-content;
text-overflow: ellipsis;
overflow: visible;
white-space: nowrap;
}
@@ -594,7 +596,7 @@
.account-dropdown button {
width: 100%;
min-height: 40px;
min-height: 36px;
display: grid;
@@ -636,7 +638,7 @@
outline: none;
box-shadow: inset 0 0 0 2px var(--focus-ring, #1268c4);
box-shadow: inset 0 0 0 2px var(--focus-ring);
}
.account-dropdown button svg {
@@ -662,16 +664,16 @@
}
.account-dropdown .account-menu-danger {
color: rgb(181, 58, 66);
color: var(--market-up);
grid-template-columns: 18px minmax(0px, 1fr);
}
.account-dropdown .account-menu-danger:focus-visible,
.account-dropdown .account-menu-danger:hover {
color: rgb(163, 47, 55);
color: var(--market-up);
background: rgb(255, 242, 243);
background: var(--market-up-soft);
}
button.account-role-badge {
@@ -685,11 +687,11 @@ button.account-role-badge {
button.account-role-badge:hover {
filter: brightness(0.98);
box-shadow: rgba(67, 76, 86, 0.14) 0px 3px 10px;
box-shadow: var(--shadow-card);
}
button.account-role-badge:focus-visible {
outline: 2px solid var(--blue);
outline: 2px solid var(--action);
outline-offset: 2px;
}
@@ -701,9 +703,9 @@ button.account-role-badge:focus-visible {
}
.admin-role-badge {
color: rgb(83, 103, 124);
color: var(--text-secondary);
background: rgb(244, 247, 250);
background: var(--surface-muted);
}
.vip-role-badge {
@@ -719,7 +721,7 @@ button.account-role-badge:focus-visible {
.vip-role-badge.is-nonmember {
color: rgb(105, 117, 128);
background: rgb(244, 246, 248);
background: var(--surface-muted);
border-color: rgb(203, 211, 218);
@@ -825,7 +827,7 @@ button.account-role-badge:focus-visible {
}
.member-gate strong {
color: rgb(60, 70, 80);
color: var(--text-primary);
font-size: 14px;
}
@@ -877,7 +879,7 @@ button.account-role-badge:focus-visible {
margin-bottom: 12px;
color: rgb(83, 103, 124);
color: var(--text-secondary);
}
.privacy-note svg {
@@ -889,7 +891,7 @@ button.account-role-badge:focus-visible {
margin-top: 3px;
color: rgb(52, 116, 95);
color: var(--market-down);
}
.membership-status-grid {
@@ -905,17 +907,17 @@ button.account-role-badge:focus-visible {
.membership-status-grid > div {
padding: 12px 13px;
border: 1px solid var(--line, #e2e8ee);
border: 1px solid var(--border);
border-radius: 10px;
background: rgb(251, 252, 253);
background: var(--surface-subtle);
}
.membership-status-grid span {
display: block;
color: rgb(116, 128, 140);
color: var(--text-tertiary);
font-size: 11px;
}
@@ -925,7 +927,7 @@ button.account-role-badge:focus-visible {
margin-top: 5px;
color: rgb(38, 50, 61);
color: var(--text-primary);
font-size: 15px;
@@ -935,7 +937,7 @@ button.account-role-badge:focus-visible {
.membership-comparison {
overflow: hidden;
border: 1px solid var(--line, #e2e8ee);
border: 1px solid var(--border);
border-radius: 10px;
@@ -953,7 +955,7 @@ button.account-role-badge:focus-visible {
padding: 9px 11px;
border-top: 1px solid var(--line, #e2e8ee);
border-top: 1px solid var(--border);
}
.membership-comparison > div:first-child {
@@ -961,21 +963,21 @@ button.account-role-badge:focus-visible {
}
.membership-comparison-head {
color: rgb(105, 118, 131);
color: var(--text-secondary);
background: rgb(247, 249, 251);
background: var(--surface-subtle);
font-weight: 700;
}
.membership-comparison b {
color: rgb(39, 108, 88);
color: var(--market-down);
font-weight: 600;
}
.membership-comparison b.muted {
color: rgb(154, 164, 173);
color: var(--text-tertiary);
}
.membership-comparison b.available {
@@ -993,7 +995,7 @@ button.account-role-badge:focus-visible {
margin-top: 14px;
color: rgb(93, 105, 116);
color: var(--text-secondary);
font-size: 12px;
}
@@ -1026,11 +1028,15 @@ button.account-role-badge:focus-visible {
display: grid;
grid-template-columns: auto minmax(0px, 1fr);
grid-template-columns: minmax(0px, 1fr);
}
.account-menu-shell .account-button {
width: 100%;
min-width: 0px;
max-width: none;
}
.account-dropdown {
@@ -1085,7 +1091,7 @@ button.account-role-badge:focus-visible {
}
.account-settings-dialog {
border-radius: 9px;
border-radius: 12px;
}
.settings-dialog {
@@ -1095,7 +1101,7 @@ button.account-role-badge:focus-visible {
overflow-x: hidden;
border-radius: 9px;
border-radius: 12px;
}
.account-dropdown {
@@ -1115,17 +1121,17 @@ button.account-role-badge:focus-visible {
padding: 7px;
background: rgba(255, 255, 255, 0.98);
background: var(--surface);
transform-origin: right top;
animation: account-menu-in 180ms var(--ease-out) both;
border-color: var(--border);
border: 1px solid var(--border);
border-radius: 7px;
border-radius: 8px;
box-shadow: rgba(22, 34, 46, 0.15) 0px 14px 35px;
box-shadow: var(--shadow-float);
}
@media (max-width: 767px) {
@@ -1177,29 +1183,37 @@ button.account-role-badge:focus-visible {
gap: 3px;
}
@media (min-width: 1280px) and (max-width: 1510px) {
.account-role-badge span {
@media (min-width: 1280px) and (max-width: 1439px) {
.app-header .header-command-group .account-role-badge span {
display: none;
}
.account-role-badge {
min-width: 27px;
.app-header .header-command-group .account-role-badge {
min-width: 28px;
justify-content: center;
padding-inline: 6px;
}
}
@media (min-width: 1380px) and (max-width: 1510px) {
#settingsButton,
@media (min-width: 1280px) {
.header-command-group .account-button {
flex: 0 0 auto;
width: auto;
padding: 0px 9px;
max-width: none;
overflow: visible;
padding: 0px 8px;
}
#settingsButton > span,
.header-command-group .account-button > span {
display: inline;
overflow: visible;
}
}
@@ -1214,7 +1228,7 @@ button.account-role-badge:focus-visible {
border-radius: 7px;
background: rgb(37, 99, 235);
background: var(--action);
color: var(--text-inverse);
@@ -1262,11 +1276,11 @@ button.account-role-badge:focus-visible {
}
.account-button {
overflow: hidden;
flex: 0 0 auto;
text-overflow: ellipsis;
overflow: visible;
max-width: 108px;
max-width: none;
min-height: 28px;
@@ -1308,17 +1322,17 @@ button.account-role-badge:focus-visible {
.settings-dialog:not(.heaven-reading-dialog) .settings-section-heading h3 {
margin: 0px;
color: rgb(39, 52, 67);
color: var(--text-primary);
font-size: 14px;
font-size: var(--font-size-card-title);
font-weight: 720;
font-weight: var(--font-weight-semibold);
}
.settings-dialog:not(.heaven-reading-dialog) .settings-section-heading > span {
color: rgb(124, 135, 149);
color: var(--text-tertiary);
font-size: 10.5px;
font-size: var(--font-size-aux);
}
.settings-dialog:not(.heaven-reading-dialog) .form-field {
@@ -1327,7 +1341,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: rgb(78, 91, 106);
color: var(--text-secondary);
font-size: 11px;
@@ -1335,7 +1349,7 @@ button.account-role-badge:focus-visible {
}
.settings-dialog:not(.heaven-reading-dialog) :is(input, select, textarea) {
border-color: rgb(213, 220, 229);
border-color: var(--border-strong);
border-radius: 7px;
}
@@ -1375,7 +1389,7 @@ button.account-role-badge:focus-visible {
}
.account-settings-dialog .settings-lead {
color: rgb(96, 109, 124);
color: var(--text-secondary);
font-size: 12px;
@@ -1397,7 +1411,7 @@ button.account-role-badge:focus-visible {
padding: 11px 12px;
border-color: rgb(223, 228, 234);
border-color: var(--border-strong);
border-radius: 7px;
@@ -1425,7 +1439,7 @@ button.account-role-badge:focus-visible {
}
.account-settings-dialog .membership-comparison {
border-color: rgb(223, 228, 234);
border-color: var(--border-strong);
border-radius: 7px;
}
@@ -1435,7 +1449,7 @@ button.account-role-badge:focus-visible {
padding: 8px 11px;
border-color: rgb(230, 234, 239);
border-color: var(--border);
}
.account-settings-dialog .membership-comparison-head {
@@ -1449,11 +1463,11 @@ button.account-role-badge:focus-visible {
.account-settings-dialog .privacy-note {
padding: 10px 11px;
border: 1px solid rgb(220, 232, 226);
border: 1px solid var(--market-down);
border-radius: 7px;
background: rgb(245, 250, 247);
background: var(--market-down-soft);
}
.account-settings-dialog .account-birth-form {
@@ -1475,7 +1489,7 @@ button.account-role-badge:focus-visible {
border-radius: 7px;
color: rgb(102, 115, 132);
color: var(--text-secondary);
font-size: 11px;
}
@@ -1489,7 +1503,7 @@ button.account-role-badge:focus-visible {
border-bottom: 1px solid var(--dialog-line);
background: rgb(251, 252, 253);
background: var(--surface-subtle);
}
.admin-dialog .admin-section-picker label {
@@ -1497,13 +1511,13 @@ button.account-role-badge:focus-visible {
}
.admin-dialog .admin-section-picker select {
min-height: 36px;
min-height: 32px;
border-color: rgb(212, 220, 229);
border-color: var(--border-strong);
border-radius: 7px;
border-radius: 8px;
font-size: 12px;
font-size: var(--font-size-label);
}
.admin-dialog .admin-panel {
@@ -1635,7 +1649,7 @@ button.account-role-badge:focus-visible {
}
:root[data-theme="dark"] :is(.loading-overlay, .auth-gate) {
background: color-mix(in srgb, var(--canvas) 92%, transparent);
background: var(--backdrop);
}
:root[data-theme="dark"] :is(.auth-shell) {
+41 -17
View File
@@ -1,11 +1,15 @@
/* Canonical CSS owner: cards. Historical layers consolidated 2026-08-02. */
.stock-name {
font-weight: 700;
font-size: var(--fs-table);
font-weight: 600;
}
.stock-code {
color: var(--text-muted);
color: var(--text-3);
font-size: var(--fs-aux);
font-variant-numeric: tabular-nums;
}
@@ -49,25 +53,37 @@
}
.streak-pill {
background: var(--coral-soft);
color: var(--coral);
font-weight: 700;
display: inline-flex;
align-items: center;
min-height: 24px;
justify-content: center;
padding: 2px 8px;
min-width: 30px;
border-radius: 4px;
height: 20px;
padding: 0 7px;
border-radius: 999px;
background: var(--up-soft);
color: var(--up);
font-size: var(--fs-caption);
font-weight: 600;
white-space: nowrap;
}
.streak-pill.high {
background: var(--up);
color: #fff;
}
.outcome-tag {
display: inline-flex;
@@ -227,7 +243,7 @@
font-size: var(--font-size-card-title);
font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-semibold);
}
.card-h .sub {
@@ -253,21 +269,29 @@
}
.sname {
font-weight: 700;
font-weight: 600;
font-size: 13px;
font-size: var(--fs-table);
}
.scode {
font-size: 11px;
font-size: var(--fs-aux);
color: var(--faint);
color: var(--text-3);
margin-left: 6px;
margin-left: 0;
font-weight: 400;
}
.stock-cell {
display: flex;
flex-direction: column;
line-height: 1.3;
}
.muted {
color: var(--faint);
}
+6 -4
View File
@@ -635,14 +635,16 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
height: 14px;
}
@media (min-width: 1280px) and (max-width: 1510px) {
.header-command-group .command-button {
width: 31px;
@media (min-width: 1280px) {
.header-command-group .command-button:not(.account-button) {
width: 32px;
min-width: 32px;
padding: 0px;
}
.header-command-group .command-button > span {
.header-command-group .command-button:not(.account-button) > span {
display: none;
}
}
+28 -26
View File
@@ -10,7 +10,9 @@
.dialog-header h2 {
margin: 2px 0px 0px;
font-size: 19px;
font-size: 16px;
font-weight: var(--font-weight-semibold);
letter-spacing: 0px;
}
@@ -36,9 +38,9 @@
}
.admin-dialog .model-row {
border-color: rgb(223, 228, 234);
border-color: var(--border);
border-radius: 7px;
border-radius: 8px;
background: var(--surface-subtle);
}
@@ -240,11 +242,11 @@
}
.alert-button.has-alerts {
border-color: rgb(214, 180, 93);
border-color: var(--warning-color);
background: rgb(255, 249, 233);
background: var(--warning-soft);
color: rgb(138, 97, 0);
color: var(--warning-color);
}
.alert-badge {
@@ -526,17 +528,17 @@
}
.assistant-message.user .assistant-message-content {
border-color: rgb(190, 210, 235);
border-color: var(--color-action-line);
background: var(--action-soft);
}
.assistant-message.is-error .assistant-message-content {
border-color: rgb(239, 197, 200);
border-color: var(--market-up);
background: var(--market-up-soft);
color: rgb(139, 47, 52);
color: var(--market-up);
}
.assistant-message-content p {
@@ -778,7 +780,7 @@
gap: 16px;
padding: 14px 18px;
padding: 20px;
min-height: 62px;
@@ -786,7 +788,7 @@
}
.assistant-dialog {
border-radius: 9px;
border-radius: 12px;
width: min(820px, -32px + 100vw);
@@ -796,9 +798,9 @@
}
.dialog-eyebrow {
color: var(--text-muted);
color: var(--text-3);
font-size: 12px;
font-size: var(--font-size-caption);
}
.global-search-dialog {
@@ -806,7 +808,7 @@
border-radius: 8px;
box-shadow: rgba(26, 37, 47, 0.22) 0px 22px 70px, rgba(26, 37, 47, 0.1) 0px 3px 14px;
box-shadow: var(--shadow-float);
max-width: 700px;
@@ -892,13 +894,13 @@
font: 11px / 1.2 ui-monospace, SFMono-Regular, Consolas, monospace;
border-color: rgb(214, 221, 229);
border-color: var(--border-strong);
border-radius: 5px;
background: rgb(247, 248, 250);
background: var(--surface-subtle);
color: rgb(125, 136, 150);
color: var(--text-tertiary);
}
.global-search-results {
@@ -920,7 +922,7 @@
padding: 9px 10px 5px;
color: rgb(135, 146, 160);
color: var(--text-tertiary);
font-size: 10px;
}
@@ -957,9 +959,9 @@
.global-search-result.is-active,
.global-search-result:hover {
background: rgb(238, 244, 255);
background: var(--action-soft);
color: rgb(31, 85, 165);
color: var(--action);
}
.global-search-result-icon {
@@ -973,7 +975,7 @@
color: var(--text-muted);
border-color: rgb(224, 229, 235);
border-color: var(--border);
border-radius: 7px;
@@ -1003,7 +1005,7 @@
border-color: var(--dialog-line);
background: rgb(251, 252, 253);
background: var(--surface-subtle);
}
.alerts-dialog .alert-form {
@@ -1077,7 +1079,7 @@
padding: 16px 18px;
background: rgb(247, 249, 251);
background: var(--surface-subtle);
}
.assistant-dialog .assistant-message {
@@ -1087,9 +1089,9 @@
}
.assistant-dialog .assistant-message-content {
border-color: rgb(220, 227, 235);
border-color: var(--border);
border-radius: 7px;
border-radius: 8px;
font-size: 12.5px;
@@ -1197,7 +1199,7 @@
z-index: 95;
box-shadow: rgba(0, 0, 0, 0.12) -8px 0px 24px;
box-shadow: var(--shadow-float);
transition: right 0.25s;
@@ -52,21 +52,21 @@
}
.segmented {
height: 34px;
height: var(--control-height);
display: flex;
overflow: hidden;
min-height: 36px;
min-height: var(--control-height);
padding: 2px;
border: 0px;
border-radius: 7px;
border-radius: var(--radius-md);
background: rgb(236, 239, 244);
background: var(--surface-muted);
}
.segment {
@@ -82,11 +82,11 @@
border: 0px;
border-radius: 5px;
border-radius: 6px;
color: var(--text-secondary);
font-size: 11.5px;
font-size: var(--font-size-label);
}
.segment.active {
+55 -20
View File
@@ -14,13 +14,13 @@
.data-table th.sort-asc::after {
content: " ↑";
color: var(--blue);
color: var(--accent);
}
.data-table th.sort-desc::after {
content: " ↓";
color: var(--blue);
color: var(--accent);
}
.data-table tbody tr td {
@@ -48,7 +48,7 @@
color: var(--text-muted);
text-align: center;
text-align: left;
}
.table-action {
@@ -114,9 +114,9 @@
text-align: left;
height: 42px;
height: 40px;
padding: 8px 11px;
padding: 0 12px;
border-right: 0px;
@@ -214,7 +214,9 @@
.data-table .stock-name {
color: var(--text-primary);
font-weight: 700;
font-size: var(--fs-table);
font-weight: 600;
}
.main-grid > .table-frame {
@@ -242,7 +244,7 @@
}
.data-table thead th {
padding: 0px 11px;
padding: 0 12px;
border-bottom: 1px solid var(--border);
@@ -256,23 +258,31 @@
height: 36px;
font-size: var(--font-size-label);
font-size: var(--font-size-caption);
}
.data-table tbody td {
padding: 6px 11px;
border-bottom: 1px solid var(--border-subtle);
padding: 0 12px;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
height: 40px;
}
height: 41px;
.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;
}
.main-grid .data-table thead th {
height: 34px;
height: 36px;
padding: 6px 10px;
padding: 0 12px;
}
.main-grid .data-table tbody td {
@@ -300,11 +310,11 @@
font-weight: 600;
font-size: 12px;
font-size: var(--font-size-caption);
text-align: left;
padding: 8px 12px;
padding: 0 12px;
border-bottom: 1px solid var(--line);
@@ -331,18 +341,30 @@
margin-left: 3px;
}
.tbl thead th.sorted .arr {
color: var(--blue);
.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 tbody td {
padding: 9px 12px;
padding: 0 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 {
@@ -415,6 +437,19 @@
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);
+28 -2
View File
@@ -89,7 +89,10 @@ 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));
setText("tapeLimit", `${overview.limit_up_count} / 跌停 ${overview.limit_down_count}`);
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)}%`);
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)}`);
@@ -98,7 +101,30 @@ function renderDashboard() {
animateMetric("amountMetric", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`);
setText("dataDateMetric", dashboardDataTimestamp(meta));
animateMetric("sentimentScore", overview.sentiment_score, (value) => Math.round(value));
setText("sentimentText", sentimentLabel(overview.sentiment_score));
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}`;
}
}
updateSentimentGauge(overview.sentiment_score);
setText("updatedAt", `${dashboardSourceLabel(meta)} · 更新 ${formatTimestamp(meta.updated_at)}`);
+612 -79
View File
@@ -187,12 +187,6 @@ 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) {
@@ -228,10 +222,6 @@ body.sidebar-collapsed .nav-group + .nav-group {
grid-template-columns: repeat(3, 1fr);
}
.metric-wide {
grid-column: auto;
}
.metric {
padding: 10px;
}
@@ -262,10 +252,6 @@ 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;
@@ -311,10 +297,6 @@ 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) {
@@ -644,10 +626,6 @@ body.sidebar-collapsed .app-main {
padding: 0px 8px;
}
.overview-strip .metric-wide {
display: none;
}
.overview-strip .metric:nth-of-type(n+4) {
display: none;
}
@@ -662,14 +640,6 @@ 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;
@@ -770,10 +740,6 @@ body.sidebar-collapsed .app-main {
display: flex;
}
.overview-strip .metric-wide {
display: none;
}
.overview-strip .metric:nth-of-type(n+5) {
display: none;
}
@@ -892,7 +858,7 @@ body.sidebar-collapsed .app-main {
padding: 0px 10px;
border-radius: 7px;
border-radius: var(--radius-md);
font-size: 12.5px;
@@ -1024,8 +990,8 @@ body.sidebar-collapsed .app-main {
grid-template-columns: 176px minmax(0px, 1fr) auto;
}
.market-tape {
display: none;
.app-header .market-tape {
display: flex;
}
.module-nav {
@@ -1424,19 +1390,19 @@ body.sidebar-collapsed .module-nav .nav-group + .nav-group {
padding: 12px 10px 4px;
color: var(--r2-faint);
color: var(--text-3);
font-size: 11px;
font-size: var(--fs-aux);
font-weight: 400;
font-weight: 600;
letter-spacing: 0px;
letter-spacing: .04em;
}
.module-nav .module-tab {
width: 100%;
min-height: 34px;
min-height: 36px;
display: flex;
@@ -1452,13 +1418,13 @@ body.sidebar-collapsed .module-nav .nav-group + .nav-group {
border: 0px;
border-radius: 7px;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-secondary);
color: var(--text-2);
font-size: 13px;
font-size: var(--fs-table);
font-weight: 400;
}
@@ -1476,23 +1442,23 @@ body.sidebar-collapsed .module-nav .nav-group + .nav-group {
}
.module-nav .module-tab:hover {
background: var(--surface-hover);
background: var(--hover);
color: var(--r2-ink);
color: var(--text-1);
}
.module-nav .module-tab.active {
background: var(--r2-blue-soft);
background: var(--selected);
color: var(--r2-blue);
color: var(--accent);
font-weight: 600;
}
.module-nav .module-tab.mobile-active {
background: var(--r2-blue-soft);
background: var(--selected);
color: var(--r2-blue);
color: var(--accent);
font-weight: 600;
}
@@ -1756,7 +1722,11 @@ body.sidebar-collapsed .module-nav .module-tab {
padding: 0px 16px;
overflow: hidden;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
border-top: 0px;
@@ -1775,6 +1745,10 @@ body.sidebar-collapsed .module-nav .module-tab {
box-shadow: none;
}
.overview-strip::-webkit-scrollbar {
display: none;
}
.overview-strip .row {
display: flex;
@@ -1784,6 +1758,8 @@ body.sidebar-collapsed .module-nav .module-tab {
padding: 7px var(--page-pad-x);
flex-shrink: 0;
font-size: 12px;
color: var(--sub);
@@ -1998,7 +1974,7 @@ body.sidebar-collapsed .module-nav .module-tab {
width: auto;
min-height: 30px;
min-height: var(--statusbar-height);
height: var(--statusbar-height);
@@ -2012,11 +1988,11 @@ body.sidebar-collapsed .module-nav .module-tab {
border-top: 1px solid var(--line);
background: var(--surface);
background: var(--header-bg);
color: var(--faint);
color: var(--text-3);
font-size: 11.5px;
font-size: var(--fs-aux);
}
body.sidebar-collapsed .status-bar {
@@ -2158,7 +2134,7 @@ body.sidebar-collapsed .status-bar {
width: var(--sidebar-width);
background: var(--surface);
background: var(--header-bg);
border-right: 1px solid var(--line);
@@ -2266,7 +2242,15 @@ body.sidebar-collapsed .status-bar {
.main {
margin-left: var(--sidebar-width);
min-width: 1080px;
min-width: 0;
overflow-x: hidden;
}
@media (min-width: 1280px) {
.main {
min-width: 1080px;
}
}
.app-header {
@@ -2276,7 +2260,7 @@ body.sidebar-collapsed .status-bar {
width: auto;
min-height: 46px;
min-height: var(--topbar-height);
box-shadow: none;
@@ -2288,17 +2272,17 @@ body.sidebar-collapsed .status-bar {
z-index: 50;
background: var(--surface);
background: var(--header-bg);
border-bottom: 1px solid var(--line);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 14px;
gap: var(--header-cluster-gap);
padding: 0 var(--page-pad-x);
padding: 0 var(--header-pad-x);
height: var(--topbar-height);
}
@@ -2412,7 +2396,7 @@ body.sidebar-collapsed .status-bar {
overflow: hidden;
}
body:is([data-active-view="sentimentCycleView"], [data-active-view="yesterdayView"]) .overview-strip {
body:is([data-active-view="sentimentCycleView"], [data-active-view="yesterdayView"]) .app-main .overview-strip {
flex: 0 0 auto;
}
@@ -2428,7 +2412,7 @@ body.sidebar-collapsed .status-bar {
overflow: hidden;
}
body:is([data-active-view="auctionView"], [data-active-view="themeLibraryView"], [data-active-view="popularityView"], [data-active-view="mentorView"], [data-active-view="rotationView"]) .overview-strip {
body:is([data-active-view="auctionView"], [data-active-view="themeLibraryView"], [data-active-view="popularityView"], [data-active-view="mentorView"], [data-active-view="rotationView"]) .app-main .overview-strip {
flex: 0 0 auto;
}
@@ -2478,8 +2462,8 @@ body.sidebar-collapsed .status-bar {
padding: var(--mobile-shell-pad);
}
.market-tape {
display: none;
.app-header .market-tape {
display: flex;
}
.header-actions {
@@ -2701,10 +2685,6 @@ body.sidebar-collapsed .status-bar {
padding: 0px;
}
.overview-strip .metric-wide {
display: none;
}
.overview-strip .metric:nth-of-type(n+4) {
display: none;
}
@@ -2730,10 +2710,20 @@ body.sidebar-collapsed .status-bar {
color: var(--text-primary);
}
:root[data-theme="dark"] :is(.module-nav, .app-header, .overview-strip, .status-bar) {
:root[data-theme="dark"] :is(.module-nav, .app-header, .status-bar) {
border-color: var(--border);
background: var(--surface);
background: var(--header-bg);
color: var(--text-primary);
box-shadow: none;
}
:root[data-theme="dark"] .overview-strip {
border-color: var(--border);
background: transparent;
color: var(--text-primary);
@@ -2741,7 +2731,7 @@ body.sidebar-collapsed .status-bar {
}
:root[data-theme="dark"] .app-header {
background: color-mix(in srgb, var(--surface) 96%, transparent);
background: var(--header-bg);
}
:root[data-theme="dark"] :is(.sidebar-brand, .module-nav .sidebar-brand, .nav-group, .sidebar-collapse-button, .header-date-group) {
@@ -3019,10 +3009,16 @@ body.sidebar-collapsed .status-bar {
body.mobile-shell .header-command-group > .command-button,
body.mobile-shell .header-command-group .account-button {
width: 100%;
max-width: none;
min-height: var(--mobile-touch-size);
justify-content: flex-start;
}
body.mobile-shell .header-command-group .account-button > span,
body.mobile-shell .header-command-group .account-role-badge span {
display: inline;
}
body.mobile-shell .header-command-group .account-dropdown {
position: static;
grid-column: 1 / -1;
@@ -3188,11 +3184,6 @@ 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 {
@@ -3222,3 +3213,545 @@ body.sidebar-collapsed .status-bar {
display: none;
}
}
/* Generic page title slot in the shell header. Kept hidden so each page owns
its own in-view title header (mentor renders one inside #mentorView). */
.app-page-context {
min-width: var(--header-page-context-min);
max-width: var(--header-page-context-max);
flex: 0 1 auto;
flex-direction: column;
justify-content: center;
gap: 2px;
overflow: hidden;
display: flex;
}
.app-header .app-page-context,
.main .app-header .app-page-context {
min-width: var(--header-page-context-min);
max-width: var(--header-page-context-max);
overflow: hidden;
}
.main .app-header .app-page-context span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
#themeModeText {
display: none;
}
.app-page-context strong {
overflow: hidden;
color: var(--text-1);
font-size: var(--fs-page-title);
font-weight: 600;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-page-context span {
overflow: hidden;
color: var(--text-3);
font-size: var(--fs-caption);
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Canonical B-147 merged header. */
.app-header .overview-strip,
.app-header .overview-strip[data-overview-expanded="true"] {
position: static;
display: flex;
flex: 1 0 auto;
align-items: stretch;
width: auto;
height: auto;
min-width: max-content;
min-height: 0;
margin: 0;
padding: 0;
overflow: visible;
border: 0;
background: transparent;
box-shadow: none;
}
.app-header .market-tape {
display: flex;
flex: 1 0 auto;
align-items: center;
min-width: max-content;
gap: 0;
overflow: visible;
white-space: nowrap;
}
.tape-metric {
position: relative;
display: flex;
flex: 0 0 auto;
flex-direction: column;
justify-content: center;
gap: 2px;
min-width: max-content;
padding: 0 var(--header-tape-metric-pad-x);
}
.app-header .tape-metric,
.app-header .tape-sentiment.sentiment-block {
flex: 0 0 auto;
min-width: max-content;
}
.tape-metric + .tape-metric::before,
.tape-metric + .overview-toggle::before {
content: "";
position: absolute;
top: 50%;
left: 0;
width: 1px;
height: 26px;
background: var(--border);
transform: translateY(-50%);
}
.tape-metric .metric-label {
color: var(--text-3);
font-size: var(--fs-aux);
}
.tape-metric-value,
.tape-metric .metric-value {
display: flex;
align-items: center;
gap: 5px;
overflow: visible;
color: var(--text-1);
font-size: 15px;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1.2;
white-space: nowrap;
}
.tape-metric .metric-value.up,
.tape-detail-item .up {
color: var(--up);
}
.tape-metric .metric-value.down,
.tape-detail-item .down {
color: var(--down);
}
.tape-metric .metric-value.warning,
.tape-detail-item .warning {
color: var(--warn);
}
@media (min-width: 1440px) and (max-width: 1599px) {
.app-header .overview-strip .tape-sentiment.sentiment-block {
flex-direction: row;
align-items: center;
gap: 8px;
}
}
.app-header .overview-strip .tape-sentiment .metric-label,
.app-header .overview-strip .tape-sentiment .sentiment-text {
font-size: var(--fs-caption);
font-weight: 500;
line-height: 18px;
}
.app-header .overview-strip .tape-sentiment > .sentiment-gauge {
position: absolute;
display: none;
width: 0;
height: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.tape-sentiment .sentiment-text,
.tape-detail-item .sentiment-text {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: var(--radius-sm);
background: var(--accent-soft);
color: var(--accent);
font-size: var(--fs-caption);
font-style: normal;
font-weight: 500;
line-height: 18px;
}
.tape-sentiment .sentiment-text.is-hot,
.tape-detail-item .sentiment-text.is-hot {
background: var(--up-soft);
color: var(--up);
}
.tape-sentiment .sentiment-text.is-strong,
.tape-detail-item .sentiment-text.is-strong {
background: var(--accent-soft);
color: var(--accent);
}
.tape-sentiment .sentiment-text.is-cold,
.tape-detail-item .sentiment-text.is-cold {
background: var(--down-soft);
color: var(--down);
}
.app-header .overview-toggle {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
min-height: 28px;
margin-left: var(--header-tape-toggle-gap);
padding: 0 10px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-2);
font-size: var(--fs-label);
}
.app-header .overview-toggle:hover {
background: var(--hover);
color: var(--text-1);
}
.app-header .overview-toggle .lucide {
width: 14px;
height: 14px;
}
.tape-detail {
position: absolute;
top: calc(var(--topbar-height) + 1px);
right: 0;
left: 0;
z-index: 45;
display: none;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0 16px;
padding: 14px 20px 16px;
border-bottom: 1px solid var(--border);
background: var(--header-bg);
box-shadow: var(--shadow-raised);
}
.overview-strip[data-overview-expanded="true"] .tape-detail {
display: grid;
}
.tape-detail-item {
display: flex;
flex-direction: column;
gap: 3px;
padding: 8px 14px;
border-radius: var(--radius-md);
}
.tape-detail-item:hover {
background: var(--hover);
}
.tape-detail-date,
.overview-strip[data-overview-expanded="true"] .tape-detail-date {
display: flex;
flex-direction: column;
}
.tape-detail-item span {
color: var(--text-3);
font-size: var(--fs-aux);
}
.tape-detail-item strong {
color: var(--text-1);
font-size: 16px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.app-header .header-actions {
position: relative;
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--header-action-gap);
margin-left: 0;
overflow: visible;
}
.app-header .header-date-group {
height: 32px;
min-height: 32px;
gap: 2px;
margin-right: 6px;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.app-header .header-date-group .icon-button,
.app-header .header-actions > .icon-button {
width: 32px;
min-width: 32px;
height: 32px;
min-height: 32px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-2);
box-shadow: none;
}
.app-header .header-actions > .icon-button:hover,
.app-header .header-date-group .icon-button:hover {
background: var(--hover);
color: var(--text-1);
}
.app-header .header-date-group .date-input {
width: auto;
height: 32px;
padding: 0 10px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text-1);
font-size: var(--fs-label);
font-variant-numeric: tabular-nums;
}
.app-header .header-actions > .icon-button .lucide,
.app-header .header-date-group .lucide {
width: 16px;
height: 16px;
}
.app-header .alert-button {
position: relative;
}
.app-header .alert-badge {
position: absolute;
top: 5px;
right: 5px;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 7px;
background: var(--up);
color: #fff;
font-size: 10px;
font-weight: 600;
line-height: 14px;
text-align: center;
}
#heavenView.workspace-view,
#heavenReadingDialog {
--color-action-base: #2563eb;
--color-action: #2563eb;
--color-action-base-hover: #1d4ed8;
--color-action-hover: #1d4ed8;
--color-action-base-soft: #eff4ff;
--color-action-soft: #eff4ff;
--action: #2563eb;
--action-hover: #1d4ed8;
--action-soft: #eff4ff;
--accent: #2563eb;
--primary: #2563eb;
--blue: #2563eb;
--r2-blue: #2563eb;
--focus-ring: rgba(37, 99, 235, .16);
--text-primary: #1f2937;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--border: #e5e7eb;
--border-strong: #d1d5db;
--surface-hover: #f8faff;
--surface-selected: #eff4ff;
--size-radius-sm: 5px;
--size-radius-md: 7px;
}
:root[data-theme="dark"] #heavenView.workspace-view,
:root[data-theme="dark"] #heavenReadingDialog {
--action: #6ca8e8;
--action-hover: #8bbcf0;
--action-soft: #23364a;
--accent: #6ca8e8;
--primary: #6ca8e8;
--blue: #6ca8e8;
--r2-blue: #6ca8e8;
--canvas: #121416;
--surface: #1b1e21;
--surface-muted: #202428;
--border: #343a40;
--border-strong: #474f57;
--text-primary: #e8eaed;
--text-secondary: #adb5bd;
--text-tertiary: #7f8993;
--market-up: #f06d73;
--market-up-soft: #40262a;
--focus-ring: rgba(108, 168, 232, .22);
}
@media (min-width: 1024px) and (max-width: 1599px) {
.tape-optional {
display: none;
}
}
@media (max-width: 1440px) {
.app-page-context {
display: none;
}
}
@media (max-width: 1023px) {
.app-header {
gap: 8px;
height: var(--mobile-header-height);
min-height: var(--mobile-header-height);
padding: 0 10px;
}
.app-header .overview-strip,
.app-header .overview-strip[data-overview-expanded="true"] {
flex: 0 0 auto;
width: auto;
}
.app-header .market-tape .tape-metric {
display: none;
}
.app-header .overview-toggle {
width: var(--mobile-touch-size);
min-width: var(--mobile-touch-size);
height: var(--mobile-touch-size);
min-height: var(--mobile-touch-size);
margin-left: 0;
justify-content: center;
}
.app-header .overview-toggle span {
display: none;
}
.tape-detail,
.overview-strip[data-overview-expanded="true"] .tape-detail {
top: calc(var(--mobile-header-height) + 1px);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tape-detail-date,
.overview-strip[data-overview-expanded="true"] .tape-detail-date {
display: flex;
flex-direction: column;
}
}
@media (max-width: 767px) {
body.mobile-shell .app-header .overview-strip,
body.mobile-shell .app-header .overview-strip[data-overview-expanded="true"] {
width: auto;
height: auto;
min-height: 0;
display: flex;
margin: 0;
padding: 0;
overflow: visible;
border: 0;
background: transparent;
}
body.mobile-shell .app-header .market-tape {
display: flex;
overflow: visible;
}
body.mobile-shell .app-header .overview-toggle {
display: inline-flex;
}
}
@media (min-width: 768px) {
.app-header .header-menu-button {
display: none;
}
.app-header .header-command-group {
position: static;
display: flex;
flex: 0 0 auto;
flex-direction: row;
align-items: center;
gap: var(--header-command-gap);
width: auto;
max-width: none;
padding: 0;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.app-header .header-command-group .command-button,
.app-header .header-command-group .account-button {
width: auto;
justify-content: center;
}
.app-header .header-command-group .account-menu-shell {
display: inline-flex;
flex-direction: row;
align-items: center;
width: auto;
}
.app-header .header-command-group .account-role-badges {
display: inline-flex;
flex-wrap: nowrap;
min-height: 32px;
}
.app-header .header-command-group .account-dropdown {
left: auto;
right: 0;
}
}
body[data-active-view="heavenView"] .app-header .overview-strip,
body[data-active-view="heavenView"] .app-header .overview-strip[data-overview-expanded="true"],
body.mobile-shell[data-active-view="heavenView"] .app-header .overview-strip,
body.mobile-shell[data-active-view="heavenView"] .app-header .overview-strip[data-overview-expanded="true"] {
display: none;
flex: 0 0 0;
width: 0;
min-width: 0;
height: 0;
min-height: 0;
overflow: hidden;
}
+56 -15
View File
@@ -22,12 +22,19 @@
const button = document.querySelector("#headerMenuButton");
const backdrop = document.querySelector("#mobileCommandBackdrop");
if (!menu || !button) return;
if (!isMobileViewport()) {
menu.classList.remove("is-open");
button.setAttribute("aria-expanded", "false");
document.body.classList.remove("mobile-command-open");
if (backdrop) backdrop.hidden = true;
return;
}
const open = typeof force === "boolean" ? force : !menu.classList.contains("is-open");
menu.classList.toggle("is-open", open);
button.setAttribute("aria-expanded", String(open));
document.body.classList.toggle("mobile-command-open", open && isMobileViewport());
if (backdrop) backdrop.hidden = !(open && isMobileViewport());
if (open && isMobileViewport()) {
document.body.classList.toggle("mobile-command-open", open);
if (backdrop) backdrop.hidden = !open;
if (open) {
global.requestAnimationFrame(() => menu.querySelector("button:not([hidden])")?.focus({ preventScroll: true }));
} else if (force === false && document.activeElement && menu.contains(document.activeElement)) {
button.focus({ preventScroll: true });
@@ -56,6 +63,28 @@
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;
@@ -69,6 +98,9 @@
: 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(
@@ -184,26 +216,35 @@
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";
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?.();
setOverviewExpanded(expanded);
if (expanded) toggleHeaderCommandMenu(false);
});
document.addEventListener("click", (event) => {
if (!event.target.closest(".header-actions")) toggleHeaderCommandMenu(false);
if (!event.target.closest("#headerCommandGroup, #headerMenuButton")) toggleHeaderCommandMenu(false);
if (!event.target.closest(".overview-strip")) setOverviewExpanded(false);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") toggleHeaderCommandMenu(false);
if (event.key !== "Escape") return;
toggleHeaderCommandMenu(false);
setOverviewExpanded(false);
});
global.addEventListener("resize", () => {
toggleHeaderCommandMenu(false);
+2
View File
@@ -13,6 +13,8 @@ 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() {
+138 -67
View File
@@ -22,19 +22,20 @@
--color-gray-900: #1f2937;
--color-shell-canvas: #f4f5f7;
--color-page-canvas: #f4f5f7;
--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-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-action-line: #c7d8fb;
--color-market-up-base: #e04536;
--color-market-up-base-soft: #fdecea;
@@ -49,24 +50,25 @@
--color-warning: #b45309;
--color-warning-soft: #fdf3e3;
--size-radius-sm: 5px;
--size-radius-md: 7px;
--size-radius-sm: 4px;
--size-radius-md: 8px;
--size-radius-lg: 10px;
--size-radius-dialog: 12px;
--size-control: 32px;
--size-sidebar: 200px;
--size-topbar: 46px;
--size-summary: 36px;
--size-statusbar: 30px;
--size-topbar: 64px;
--size-summary: 0px;
--size-statusbar: 28px;
--size-page-pad-y: 14px;
--size-page-pad-x: 16px;
--size-card-gap: 12px;
--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);
--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);
--motion-instant: 100ms;
--motion-fast: 140ms;
--motion-fast: 120ms;
--motion-medium: 200ms;
--motion-deliberate: 260ms;
--motion-slow: 560ms;
@@ -80,8 +82,11 @@
--surface-canvas: var(--color-page-canvas);
--surface-raised: var(--color-white);
--surface-sunken: #eef0f3;
--surface-hover: #f8faff;
--surface-selected: #eff4ff;
--header-bg: var(--color-white);
--surface-hover: #f2f3f5;
--surface-selected: #eaf1fe;
--hover: var(--surface-hover);
--selected: var(--surface-selected);
--surface-overlay: var(--color-white);
--border: var(--color-border);
--border-strong: var(--color-border-strong);
@@ -93,7 +98,12 @@
--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);
@@ -106,7 +116,12 @@
--table-header: var(--surface-subtle);
--table-hover: #f8faff;
--table-selected: var(--surface-selected);
--focus-ring: rgba(37, 99, 235, .16);
--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);
--backdrop: rgba(17, 24, 39, .48);
--primary: var(--color-action);
--primary-hover: var(--color-action-hover);
@@ -126,18 +141,32 @@
--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: 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-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-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
@@ -147,11 +176,20 @@
--space-8: 8px;
--space-12: 12px;
--space-16: 16px;
--space-20: 20px;
--space-24: 24px;
--sidebar-width: var(--size-sidebar);
--sidebar-compact-width: 64px;
--topbar-height: var(--size-topbar);
--header-pad-x: 20px;
--header-cluster-gap: 24px;
--header-action-gap: 6px;
--header-command-gap: 6px;
--header-tape-metric-pad-x: 16px;
--header-tape-toggle-gap: 14px;
--header-page-context-min: 148px;
--header-page-context-max: 280px;
--summary-height: var(--size-summary);
--statusbar-height: var(--size-statusbar);
--page-pad-y: var(--size-page-pad-y);
@@ -219,13 +257,14 @@
--dragon-profile-weight-strong: 750;
--dragon-profile-weight-semibold: 600;
--mentor-directory-width: 280px;
--mentor-profile-width: 272px;
--mentor-pane-header-height: 58px;
--mentor-avatar-size: 38px;
--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: 94px;
--mentor-message-max-width: 82%;
--mentor-composer-min-height: 85px;
--mentor-message-max-width: 60%;
--chart-background: #fbfcfd;
--chart-grid: #e2e8ec;
@@ -313,45 +352,64 @@
--r2-radius: var(--size-radius-lg);
--r2-shadow: var(--elevation-card);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", "PingFang SC", "Microsoft YaHei UI", sans-serif;
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
font-size: 14px;
}
:root[data-theme="dark"] {
color-scheme: dark;
--canvas: #121416;
--surface: #1b1e21;
--surface-muted: #202428;
--surface-subtle: #24282d;
--canvas: #141519;
--header-bg: #1c1e23;
--surface: #232529;
--surface-muted: #2a2d33;
--surface-subtle: #202329;
--surface-canvas: var(--canvas);
--surface-raised: var(--surface);
--surface-sunken: #16191c;
--surface-hover: #24282d;
--surface-selected: #23364a;
--surface-overlay: #24282d;
--border: #343a40;
--border-strong: #474f57;
--border-subtle: #2a2f34;
--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;
--text-primary: #e8eaed;
--text-secondary: #adb5bd;
--text-tertiary: #7f8993;
--text-secondary: #a9adb3;
--text-tertiary: #7c828a;
--text-1: var(--text-primary);
--text-2: var(--text-secondary);
--text-3: var(--text-tertiary);
--text-inverse: #ffffff;
--action: #6ca8e8;
--action-hover: #8bbcf0;
--action-soft: #23364a;
--market-up: #f06d73;
--market-up-soft: #40262a;
--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;
--market-down: #43bc8a;
--market-down-soft: #1d382f;
--market-down-soft: #22362c;
--warning-color: #e2ad58;
--warning-soft: #3d3220;
--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);
--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);
--backdrop: rgba(0, 0, 0, .66);
--primary: var(--action);
--primary-hover: var(--action-hover);
@@ -467,3 +525,16 @@
--shadow-soft: 0 1px 2px rgba(0, 0, 0, .28), 0 8px 24px rgba(0, 0, 0, .16);
--shadow: 0 18px 50px rgba(0, 0, 0, .46);
}
@media (min-width: 1280px) and (max-width: 1919px) {
:root {
--header-pad-x: 12px;
--header-cluster-gap: 8px;
--header-action-gap: 4px;
--header-command-gap: 4px;
--header-tape-metric-pad-x: 6px;
--header-tape-toggle-gap: 4px;
--header-page-context-min: 96px;
--header-page-context-max: 148px;
}
}
+783 -29
View File
@@ -483,7 +483,7 @@ async function mockApplication(page, authSession = session(), options = {}) {
}],
};
} else if (url.pathname === "/api/mentors/setup") {
payload = { trade_date: "20260722", mentors: mentorDirectory(authSession.user.role) };
payload = { trade_date: "20260722", mentors: options.mentors || mentorDirectory(authSession.user.role) };
} else if (url.pathname === "/api/mentors/chat") {
await route.fulfill({
status: 200,
@@ -510,12 +510,64 @@ 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();
}
function namedSession(username, role = "admin", subscribed = true) {
const auth = session(role, subscribed);
auth.user.username = username;
return auth;
}
async function setColorTheme(page, theme) {
const wanted = theme === "night" ? "dark" : "light";
const current = await page.locator("html").getAttribute("data-theme");
if (current === wanted) return;
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", wanted);
}
function measureAccountName() {
const name = document.querySelector("#accountName");
const button = document.querySelector("#accountButton");
const badges = document.querySelector("#accountRoleBadges");
const adminLabel = document.querySelector("#accountAdminBadge > span");
const vipLabel = document.querySelector("#accountVipLabel");
const box = (node) => node ? node.getBoundingClientRect() : null;
const visible = (node) => {
if (!node || node.hidden) return false;
const style = getComputedStyle(node);
if (style.display === "none" || style.visibility === "hidden") return false;
const rect = node.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const buttonBox = box(button);
const badgeBox = box(badges);
return {
text: name ? String(name.textContent || "") : "",
clientWidth: name ? name.clientWidth : 0,
scrollWidth: name ? name.scrollWidth : 0,
fits: name ? name.clientWidth >= name.scrollWidth : false,
buttonFits: button ? button.clientWidth >= button.scrollWidth : false,
stacked: Boolean(badgeBox && buttonBox && buttonBox.top >= badgeBox.bottom - 1),
adminLabelVisible: visible(adminLabel),
vipLabelVisible: visible(vipLabel),
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
};
}
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();
@@ -526,7 +578,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",
"mentorView", "heavenView", "reviewWorkspaceView",
"heavenView", "reviewWorkspaceView", "mentorView",
];
for (const view of views) {
await page.locator(`[data-view="${view}"]`).first().click();
@@ -567,7 +619,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", "mentorView", "heavenView", "reviewWorkspaceView",
"screenerView", "heavenView", "reviewWorkspaceView",
];
let reference = null;
for (const view of views) {
@@ -589,6 +641,7 @@ 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();
@@ -2805,54 +2858,122 @@ 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-evidence-filters")).toBeVisible();
await expect(page.locator("#mentorView .mentor-sidebar .mentor-directory-tools")).toBeVisible();
await expect(page.locator("#mentorFilterToggle")).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("#activeMentorBadges")).toHaveText("B");
await expect(page.locator("#activeMentorEvidence")).toHaveText("公开访谈与多源材料");
await expect(page.locator("#activeMentorStatus")).toContainText("确认之后再行动");
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();
expect(Math.abs(mentorLibrary.width - 280)).toBeLessThanOrEqual(1);
expect(Math.abs(mentorProfile.width - 272)).toBeLessThanOrEqual(1);
const mentorLayout = await page.locator("#mentorView .mentor-layout").boundingBox();
expect(Math.abs(mentorLibrary.width - 300)).toBeLessThanOrEqual(1);
expect(Math.abs(mentorChat.x - (mentorLibrary.x + mentorLibrary.width))).toBeLessThanOrEqual(1);
expect(Math.abs(mentorProfile.x - (mentorChat.x + mentorChat.width))).toBeLessThanOrEqual(1);
expect(mentorInput.height).toBeGreaterThanOrEqual(48);
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(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 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);
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);
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-pin="source-c"]').click();
await page.locator('[data-mentor-id="source-c"]').click();
await page.locator("#mentorPinButton").click();
await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-c");
await page.locator('[data-mentor-pin="source-b"]').click();
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 expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-b");
await page.locator("#mentorSortToggle").click();
@@ -2869,10 +2990,16 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
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();
const userMessage = page.locator("#mentorMessages .mentor-message.user");
const darkUserMessageStyle = await userMessage.evaluate((element) => ({
@@ -2883,14 +3010,13 @@ 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(35, 54, 74)");
expect(darkUserMessageStyle.contentColor).toBe("rgb(232, 234, 237)");
expect(darkUserMessageStyle.contentBackground).toBe("rgb(53, 89, 140)");
expect(darkUserMessageStyle.contentColor).toBe("rgb(234, 241, 251)");
const darkMessageStyle = await answer.evaluate((element) => {
const style = getComputedStyle(element);
const content = element.querySelector(".mentor-message-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,
@@ -2898,7 +3024,6 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
contentColor: getComputedStyle(content).color,
headingColor: getComputedStyle(heading).color,
labelColor: getComputedStyle(label).color,
metaColor: getComputedStyle(meta).color,
};
});
expect(darkMessageStyle.background).not.toBe("rgb(255, 255, 255)");
@@ -2906,8 +3031,119 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
expect(darkMessageStyle.shadow).toBe("none");
expect(darkMessageStyle.contentColor).toBe("rgb(232, 234, 237)");
expect(darkMessageStyle.headingColor).toBe("rgb(232, 234, 237)");
expect(darkMessageStyle.labelColor).toBe("rgb(127, 137, 147)");
expect(darkMessageStyle.metaColor).toBe("rgb(127, 137, 147)");
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);
});
test("global dialogs share the stage 18 geometry without changing account or admin access", async ({ page }) => {
@@ -2951,6 +3187,7 @@ 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");
@@ -2961,6 +3198,7 @@ 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();
@@ -2980,3 +3218,519 @@ 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")).toBeHidden();
await expect(page.locator("#headerCommandGroup")).toBeVisible();
await expect(page.locator("#themeToggle")).toBeVisible();
await expect(page.locator("#refreshButton")).toBeVisible();
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([]);
});
test("desktop header keeps refresh, admin commands and account identity visible", async ({ page }) => {
const fs = require("node:fs");
const path = require("node:path");
const shotDir = path.join(__dirname, "../../runtime/b214-shots");
fs.mkdirSync(shotDir, { recursive: true });
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(String(error)));
await mockApplication(page, session("admin", true));
await page.goto("/index.html");
const shot = (name) => page.locator(".app-header").screenshot({ path: path.join(shotDir, `${name}.png`) });
const shotPage = (name) => page.screenshot({ path: path.join(shotDir, `${name}.png`) });
const measureHeader = () => page.evaluate(() => {
const header = document.querySelector(".app-header");
const tape = document.querySelector(".market-tape");
const actions = document.querySelector(".header-actions");
const refresh = document.querySelector("#refreshButton");
const account = document.querySelector("#accountButton");
const name = document.querySelector("#accountName");
const box = (node) => node ? node.getBoundingClientRect() : null;
const visible = (node) => {
if (!node || node.hidden) return false;
const style = getComputedStyle(node);
if (style.display === "none" || style.visibility === "hidden") return false;
const rect = node.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const tapeBox = box(tape);
const actionsBox = box(actions);
return {
headerOverflow: header ? header.scrollWidth - header.clientWidth : 0,
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
tapeHeight: tapeBox ? tapeBox.height : 0,
overlap: tapeBox && actionsBox
? Math.max(0, Math.min(tapeBox.right, actionsBox.right) - Math.max(tapeBox.left, actionsBox.left))
* Math.max(0, Math.min(tapeBox.bottom, actionsBox.bottom) - Math.max(tapeBox.top, actionsBox.top))
: 0,
refreshVisible: visible(refresh),
accountVisible: visible(account),
nameVisible: visible(name),
nameFits: name ? name.clientWidth >= name.scrollWidth : false,
};
});
for (const viewport of [
{ width: 1600, height: 1000 },
{ width: 1440, height: 900 },
{ width: 1280, height: 800 },
]) {
await page.setViewportSize(viewport);
await expect(page.locator("#headerMenuButton")).toBeHidden();
await expect(page.locator("#headerCommandGroup")).toBeVisible();
await expect(page.locator("#refreshButton")).toBeVisible();
await expect(page.locator("#syncButton")).toBeVisible();
await expect(page.locator("#settingsButton")).toBeVisible();
await expect(page.locator("#accountAdminBadge")).toBeVisible();
await expect(page.locator("#accountVipBadge")).toBeVisible();
await expect(page.locator("#accountButton")).toBeVisible();
await expect(page.locator("#accountName")).toBeVisible();
const geometry = await measureHeader();
expect(geometry.headerOverflow, `${viewport.width} header overflow`).toBeLessThanOrEqual(1);
expect(geometry.documentOverflow, `${viewport.width} document overflow`).toBeLessThanOrEqual(1);
expect(geometry.overlap, `${viewport.width} tape overlap`).toBe(0);
expect(geometry.tapeHeight, `${viewport.width} tape height`).toBeGreaterThan(20);
expect(geometry.nameFits, `${viewport.width} account name truncated`).toBe(true);
await page.locator("#refreshButton").click();
await expect(page.locator("#loadingOverlay")).toBeHidden();
await page.locator("#settingsButton").click();
await expect(page.locator("#adminDialog")).toBeVisible();
await page.locator("#closeAdminDialog").click();
await page.locator("#accountButton").click();
await expect(page.locator("#accountDropdown")).toBeVisible();
await page.keyboard.press("Escape");
await shot(`${viewport.width}-day`);
}
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await page.setViewportSize({ width: 1600, height: 1000 });
await shot("1600-night");
await page.locator("#themeToggle").click();
await page.locator('[data-view="heavenView"]').first().click();
await expect(page.locator("#heavenView")).toHaveClass(/active-view/);
await expect(page.locator("#refreshButton")).toBeVisible();
await expect(page.locator("#accountButton")).toBeVisible();
await expect(page.locator("#headerMenuButton")).toBeHidden();
await shot("heaven-1600-day");
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator("#headerMenuButton")).toBeVisible();
await expect(page.locator("#refreshButton")).toBeHidden();
await page.locator("#headerMenuButton").click();
await expect(page.locator("#headerCommandGroup")).toBeVisible();
await expect(page.locator("#refreshButton")).toBeVisible();
await expect(page.locator("#syncButton")).toBeVisible();
await expect(page.locator("#settingsButton")).toBeVisible();
await expect(page.locator("#accountAdminBadge")).toBeVisible();
await expect(page.locator("#accountVipBadge")).toBeVisible();
await expect(page.locator("#accountButton")).toBeVisible();
await expect(page.locator('[data-mobile-command-target="themeToggle"]')).toBeVisible();
const mobileName = await page.evaluate(measureAccountName);
expect(mobileName.fits, "390 account name truncated").toBe(true);
expect(mobileName.stacked, "390 identity and account should stack").toBe(true);
await shotPage("390-menu-open");
await page.keyboard.press("Escape");
await expect(page.locator("#headerCommandGroup")).toBeHidden();
await mockApplication(page, session("user", false));
await page.setViewportSize({ width: 1600, height: 1000 });
await page.goto("/index.html");
await expect(page.locator("#refreshButton")).toBeVisible();
await expect(page.locator("#syncButton")).toBeHidden();
await expect(page.locator("#settingsButton")).toBeHidden();
await expect(page.locator("#accountAdminBadge")).toBeHidden();
await expect(page.locator("#accountVipBadge")).toBeVisible();
await expect(page.locator("#accountName")).toHaveText("normal_user");
await shot("1600-user-day");
expect(pageErrors).toEqual([]);
});
test("header username stays untruncated on desktop and in the mobile menu", async ({ page }) => {
test.setTimeout(120_000);
const fs = require("node:fs");
const path = require("node:path");
const shotDir = path.join(__dirname, "../../runtime/b217-shots");
fs.mkdirSync(shotDir, { recursive: true });
const measurements = [];
const viewports = [
{ width: 1600, height: 1000 },
{ width: 1440, height: 900 },
{ width: 1280, height: 800 },
{ width: 390, height: 844 },
];
const accounts = [
{ label: "admin", username: "review_admin", auth: namedSession("review_admin", "admin", true) },
{ label: "user", username: "normal_user", auth: namedSession("normal_user", "user", false) },
];
for (const account of accounts) {
await mockApplication(page, account.auth);
await page.goto("/index.html");
await expect(page.locator("#accountName")).toHaveText(account.username);
for (const theme of ["day", "night"]) {
await page.setViewportSize({ width: 1600, height: 1000 });
await setColorTheme(page, theme);
for (const viewport of viewports) {
await page.setViewportSize(viewport);
if (viewport.width === 390) {
await page.locator("#headerMenuButton").click();
await expect(page.locator("#headerCommandGroup")).toBeVisible();
} else {
await expect(page.locator("#headerMenuButton")).toBeHidden();
await expect(page.locator("#accountName")).toBeVisible();
}
const geometry = await page.evaluate(measureAccountName);
const label = `${account.label}-${viewport.width}-${theme}`;
expect(geometry.text, `${label} username`).toBe(account.username);
expect(geometry.fits, `${label} accountName truncated ${geometry.clientWidth}<${geometry.scrollWidth}`).toBe(true);
expect(geometry.documentOverflow, `${label} document overflow`).toBeLessThanOrEqual(1);
if (viewport.width === 390) {
expect(geometry.stacked, `${label} identity/account not stacked`).toBe(true);
}
if (viewport.width >= 1440 && account.label === "admin") {
expect(geometry.adminLabelVisible, `${label} admin badge text hidden`).toBe(true);
expect(geometry.vipLabelVisible, `${label} vip badge text hidden`).toBe(true);
}
measurements.push({
account: account.username,
theme,
width: viewport.width,
height: viewport.height,
...geometry,
});
const shotName = `${account.label}-${viewport.width}-${theme}.png`;
if (viewport.width === 390) {
await page.screenshot({ path: path.join(shotDir, shotName) });
await page.keyboard.press("Escape");
await expect(page.locator("#headerCommandGroup")).toBeHidden();
} else {
await page.locator(".app-header").screenshot({ path: path.join(shotDir, shotName) });
}
}
}
}
fs.writeFileSync(path.join(shotDir, "measurements.json"), `${JSON.stringify(measurements, null, 2)}\n`);
});
function measureHeaderProbe() {
const name = document.querySelector("#accountName");
const group = document.querySelector("#headerCommandGroup");
const strip = document.querySelector(".app-header .overview-strip");
const stripBox = strip ? strip.getBoundingClientRect() : null;
const stripShown = Boolean(stripBox && stripBox.width > 0 && stripBox.height > 0);
const tapeClip = stripShown
? [...strip.querySelectorAll("*")].filter((el) => (
el.children.length === 0 && el.scrollWidth > el.clientWidth + 1
)).map((el) => (el.textContent || "").trim()).filter(Boolean)
: [];
const groupBox = group ? group.getBoundingClientRect() : null;
const nameBox = name ? name.getBoundingClientRect() : null;
return {
groupRight: groupBox ? groupBox.right : 0,
nameRight: nameBox ? nameBox.right : 0,
nameFits: name ? name.clientWidth >= name.scrollWidth : false,
nameText: name ? String(name.textContent || "") : "",
tapeClip,
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
viewport: window.innerWidth,
};
}
async function applyColorTheme(page, theme) {
const wanted = theme === "night" ? "dark" : "light";
await page.evaluate((value) => {
document.documentElement.dataset.theme = value;
document.documentElement.style.colorScheme = value;
try { localStorage.setItem("xiaobaiTheme", value); } catch (_error) {}
}, wanted);
await expect(page.locator("html")).toHaveAttribute("data-theme", wanted);
}
async function probeHeaderWorkspaces(page, { account, theme, viewport, views, measurements }) {
await page.setViewportSize(viewport);
await applyColorTheme(page, theme);
for (const view of views) {
await page.locator(`[data-view="${view}"]`).first().click();
await expect(page.locator(`#${view}`)).toHaveClass(/active-view/);
const geometry = await page.evaluate(measureHeaderProbe);
const label = `${account.label}-${view}-${viewport.width}-${theme}`;
expect(geometry.nameText, `${label} username`).toBe(account.username);
expect(geometry.nameFits, `${label} accountName truncated`).toBe(true);
expect(geometry.groupRight, `${label} command group overflow ${geometry.groupRight}>${geometry.viewport}`).toBeLessThanOrEqual(geometry.viewport + 0.5);
expect(geometry.nameRight, `${label} accountName overflow ${geometry.nameRight}>${geometry.viewport}`).toBeLessThanOrEqual(geometry.viewport + 0.5);
expect(geometry.documentOverflow, `${label} document overflow`).toBeLessThanOrEqual(1);
expect(geometry.tapeClip, `${label} tape clipped ${geometry.tapeClip.join("|")}`).toEqual([]);
measurements.push({ account: account.username, view, theme, width: viewport.width, ...geometry });
}
}
test("desktop header keeps commands in view and tape text unclipped across workspaces", async ({ page }) => {
test.setTimeout(120_000);
const fs = require("node:fs");
const path = require("node:path");
const shotDir = path.join(__dirname, "../../runtime/b221-shots");
fs.mkdirSync(shotDir, { recursive: true });
const measurements = [];
const views = [
"sentimentCycleView",
"auctionView",
"themeLibraryView",
"popularityView",
"rotationView",
"mentorView",
"screenerView",
"reviewWorkspaceView",
"heavenView",
];
const viewports = [
{ width: 1600, height: 1000 },
{ width: 1440, height: 900 },
{ width: 1280, height: 800 },
];
const accounts = [
{ label: "admin", username: "review_admin", auth: namedSession("review_admin", "admin", true) },
{ label: "user", username: "normal_user", auth: namedSession("normal_user", "user", false) },
];
for (const account of accounts) {
await mockApplication(page, account.auth);
await page.goto("/index.html");
await expect(page.locator("#accountName")).toHaveText(account.username);
for (const viewport of viewports) {
await probeHeaderWorkspaces(page, { account, theme: "day", viewport, views, measurements });
await page.locator('[data-view="themeLibraryView"]').first().click();
await page.locator(".app-header").screenshot({
path: path.join(shotDir, `${account.label}-${viewport.width}-day.png`),
});
}
await probeHeaderWorkspaces(page, {
account,
theme: "night",
viewport: { width: 1600, height: 1000 },
views,
measurements,
});
await page.locator('[data-view="sentimentCycleView"]').first().click();
await page.locator(".app-header").screenshot({
path: path.join(shotDir, `${account.label}-1600-night.png`),
});
}
await mockApplication(page, accounts[0].auth);
await page.goto("/index.html");
await page.setViewportSize({ width: 390, height: 844 });
await applyColorTheme(page, "day");
await page.locator("#headerMenuButton").click();
await expect(page.locator("#headerCommandGroup")).toBeVisible();
const mobileDay = await page.evaluate(measureAccountName);
expect(mobileDay.fits, "390 day account name truncated").toBe(true);
expect(mobileDay.stacked, "390 day identity and account should stack").toBe(true);
await page.screenshot({ path: path.join(shotDir, "admin-390-day.png") });
await page.keyboard.press("Escape");
await applyColorTheme(page, "night");
await page.locator("#headerMenuButton").click();
const mobileNight = await page.evaluate(measureAccountName);
expect(mobileNight.fits, "390 night account name truncated").toBe(true);
expect(mobileNight.stacked, "390 night identity and account should stack").toBe(true);
await page.screenshot({ path: path.join(shotDir, "admin-390-night.png") });
fs.writeFileSync(path.join(shotDir, "measurements.json"), `${JSON.stringify(measurements, null, 2)}\n`);
});
+81 -9
View File
@@ -18,20 +18,92 @@ 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"),
],
)
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)
ReviewDatabase(path)
with database.connect() as connection:
count = connection.execute(
"SELECT COUNT(*) AS count FROM schema_migrations"
).fetchone()["count"]
self.assertEqual(count, 3)
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)
def test_connection_factory_enables_required_pragmas(self) -> None:
with tempfile.TemporaryDirectory() as root:
+94 -12
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import re
import subprocess
import unittest
from html.parser import HTMLParser
from pathlib import Path
@@ -8,12 +9,35 @@ 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__()
@@ -50,11 +74,23 @@ class FrontendContractTests(unittest.TestCase):
self.assertEqual(duplicates, [])
def test_literal_id_selectors_exist_in_html(self):
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, [])
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, [])
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))
@@ -130,6 +166,15 @@ 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)
@@ -280,13 +325,18 @@ class FrontendContractTests(unittest.TestCase):
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 {", 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("border-color: var(--line-soft);", self.mentor_styles)
self.assertIn("background: var(--surface-subtle);", self.mentor_styles)
self.assertIn("box-shadow: none;", self.mentor_styles)
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)
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)
@@ -294,6 +344,38 @@ 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)