Compare commits

...
Author SHA1 Message Date
施工员andmultica-agent b30ce409ea fix(HEL-105): P3b总工返工四条整改——提醒详情已读/删除与关联股票失效、编辑交易动作情绪被重置、自选股跟踪备注编辑
- 提醒详情:委派先匹配 data-review-alert-read/delete,列表行收窄为 .m-alert-row[data-alert-id]
- 提醒详情关联股票:按钮补 data-alert-stock 属性,接通 openDetailSheet
- 编辑交易:openForm 从 opts.trade 带入 action/emotion,不再强制 buy/calm
- 自选股:双图详情补「跟踪备注」展示+编辑抽屉(≤240),保存走 /api/watchlist upsert 并刷新列表

Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 02:11:46 +08:00
施工员andmultica-agent 291a61f6ac 施工(HEL-105): 手机端第四批 P3b 我的复盘五页——自选股/交易日志/每日复盘/个股笔记/提醒中心
- 新增 review 五页路由与渲染(COMPLEX_PAGES/LOADERS 注册 setupReviewPage/loadReview)
- 自选股:颜色圆点+关注分列表、无排序例外、点行双图详情、移出自选本地同步
- 交易日志:五格汇总条、动作徽标、盈亏红绿、详情 Sheet 编辑/删除、全屏表单(13 字段)
- 每日复盘:查看态三卡+历史复盘分组+日期切换、全屏编辑表单(三 textarea)
- 个股笔记:按股票分组列表、详情抽屉、新增笔记页(股票搜索选择器)
- 提醒中心:类型徽标/未读/到期规则、全部已读、新增提醒抽屉、关联股票详情
- 表单控件族(text/textarea/number/date/胶囊组/股票搜索)、字数统计、必填校验、提交条贴键盘
- 图标补充 check/check-check/star/scroll-text/calendar-check/sticky-note/bell
- 全屏表单返回拦截(capture 阶段)与放弃编辑确认;零新依赖、仅动 m/ 前端

Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 01:54:18 +08:00
施工员andmultica-agent 72141098b7 fix(HEL-114): P3a验收三条整改——选股抽屉分类胶囊横排+策略列表辨识度/聊天输入区键盘避让/追问与预置胶囊渲染
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 01:03:27 +08:00
施工员andmultica-agent afd05cecda fix(HEL-104): 选股持续有效/入选历史改从 recent_results 按当前策略组装
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 00:16:18 +08:00
施工员andmultica-agent a0d2d65cc8 fix(HEL-104): 问师导师胶囊初始隐藏,避免加载骨架期顶部空档
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 23:54:59 +08:00
施工员andmultica-agent c466014862 fix(HEL-104): 修复第四批P3a八处返工——引导胶囊入消息区/导师胶囊置顶/问师返回与空态/选股三视图按策略过滤/入选日列/空态切策略按钮
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 23:54:19 +08:00
施工员andmultica-agent 20b05b1b78 施工:手机端第四批 P3a(智能选股/策略跟踪/问师/复盘助手)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 22:58:46 +08:00
施工员andmultica-agent ff7c128023 fix(HEL-99): 手机端 fetch 出口收敛到 shared/api.js + 架构清单刷新
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 21:45:47 +08:00
施工员andmultica-agent d7aadcb02c fix(HEL-95): 复验四条整改——周期选项位置/池页默认排序/三处列表详情
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 21:05:52 +08:00
施工员andmultica-agent 8237a9e6db feat(HEL-90): P2b 验收五条整改 + 夜间黑字排查
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 20:13:39 +08:00
施工员andmultica-agent 0e6c102ab2 fix(HEL-80): 接通复杂页表格排序(情绪历史/竞价/轮动/题材/龙虎内嵌表)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 18:01:29 +08:00
施工员andmultica-agent db4e9ef57b feat(HEL-80): 手机端 P2b 六个行情复杂页(情绪周期/天梯/轮动/竞价/题材库/龙虎榜)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 17:41:53 +08:00
施工员andmultica-agent dcd155b410 feat(HEL-84): 手机端列表排序 + 详情页双图/自选 + 顶栏七项数据栏
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 17:05:58 +08:00
施工员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
20 changed files with 10080 additions and 14 deletions
+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:
+2 -2
View File
@@ -461,8 +461,8 @@
},
{
"path": "frontend/index.html",
"bytes": 47131,
"lines": 646
"bytes": 47871,
"lines": 661
},
{
"path": "backend/features/screener/catalog.py",
+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)只负责解释或编译自然语言条件,不能改计算结果。
- **不接券商、不自动下单**,不承诺收益。
## 权限
- **普通用户**:能用大部分市场页面和「我的复盘」。
- **会员**:额外能用智能选股、问师、问天(需要管理员开通)。
- **管理员**:管理公共行情密钥、会员额度和系统配置等。
+15
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";
+302
View File
@@ -0,0 +1,302 @@
(function (global) {
"use strict";
const nav = {
entries: [
{ key: "market", title: "行情数据", subtitle: "情绪 · 梯队 · 题材 · 龙虎榜", icon: "bar-chart-3" },
{ key: "tools", title: "智能工具", subtitle: "智能选股 · 策略跟踪 · 问师", icon: "wand-2" },
{ key: "review", title: "我的复盘", subtitle: "自选 · 交易 · 复盘 · 笔记 · 提醒", icon: "notebook-pen" },
{ key: "assistant", title: "复盘助手", subtitle: "AI 对话复盘", icon: "message-square" },
{ key: "system", title: "系统管理", subtitle: "账号 · 密码 · 会员 · 设置", icon: "settings" }
],
hubs: {
market: {
title: "行情数据",
items: [
{ key: "market/sentiment", label: "情绪周期", icon: "activity" },
{ key: "market/limit-up", label: "涨停池", icon: "trending-up" },
{ key: "market/broken", label: "炸板池", icon: "zap" },
{ key: "market/limit-down", label: "跌停池", icon: "trending-down" },
{ key: "market/yesterday", label: "昨日涨停", icon: "history" },
{ key: "market/performance", label: "涨停表现", icon: "chart-line" },
{ key: "market/ladder", label: "市场天梯", icon: "layers" },
{ key: "market/rotation", label: "主题轮动", icon: "refresh-cw" },
{ key: "market/auction", label: "竞价", icon: "gavel" },
{ key: "market/themes", label: "题材库", icon: "book-open" },
{ key: "market/popularity", label: "人气榜", icon: "flame" },
{ key: "market/dragon", label: "龙虎榜", icon: "crown" }
]
},
tools: {
title: "智能工具",
items: [
{ key: "tools/screener", label: "智能选股", icon: "filter" },
{ key: "tools/tracking", label: "策略跟踪", icon: "target" },
{ key: "tools/mentor", label: "问师", icon: "bot" }
]
},
review: {
title: "我的复盘",
items: [
{ key: "review/watchlist", label: "自选股", icon: "star" },
{ key: "review/trades", label: "交易日志", icon: "scroll-text" },
{ key: "review/daily", label: "每日复盘", icon: "calendar-check" },
{ key: "review/notes", label: "个股笔记", icon: "sticky-note" },
{ key: "review/alerts", label: "提醒中心", icon: "bell" }
]
},
system: {
title: "系统管理",
items: [
{ key: "system/profile", label: "账号资料", icon: "user" },
{ key: "system/password", label: "修改密码", icon: "lock" },
{ key: "system/membership", label: "会员状态", icon: "gem" },
{ key: "system/admin", label: "系统设置", icon: "sliders-horizontal", adminOnly: true },
{ key: "system/members", label: "会员管理", icon: "users", adminOnly: true }
]
}
},
tableDefaults: {
frozenColumns: ["name", "code"],
primaryColumns: [],
scrollColumns: []
},
tableColumns: {
"market/limit-up": {
summary: "limit",
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "streak", label: "连板", type: "streak" },
{ key: "change", label: "涨幅%", type: "change" },
{ key: "price", label: "价格", type: "price" }
],
scrollColumns: [
{ key: "sector", label: "所属板块", type: "text" },
{ key: "first_time", label: "首封", type: "text" },
{ key: "last_time", label: "最后封板", type: "text" },
{ key: "open_times", label: "开板", type: "int" },
{ key: "turnover_rate", label: "换手%", type: "rate" },
{ key: "amount_billion", label: "成交额亿", type: "money" },
{ key: "seal_amount_million", label: "封单额万", type: "int", hideZero: true },
{ key: "reason", label: "涨停原因", type: "text", wide: true }
]
},
"market/broken": {
summary: "broken",
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "change", label: "现价涨幅%", type: "change" },
{ key: "limitGap", label: "距涨停%", type: "gap" },
{ key: "price", label: "价格", type: "price" }
],
scrollColumns: [
{ key: "sector", label: "所属板块", type: "text" },
{ key: "first_time", label: "首次触板", type: "text" },
{ key: "open_times", label: "开板", type: "int" },
{ key: "turnover_rate", label: "换手%", type: "rate" },
{ key: "amount_billion", label: "成交额亿", type: "money" },
{ key: "reason", label: "炸板原因", type: "text", wide: true }
]
},
"market/limit-down": {
summary: "down",
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "change", label: "跌幅%", type: "change" },
{ key: "price", label: "价格", type: "price" }
],
scrollColumns: [
{ key: "sector", label: "所属板块", type: "text" },
{ key: "turnover_rate", label: "换手%", type: "rate" },
{ key: "amount_billion", label: "成交额亿", type: "money" },
{ key: "streak", label: "连续跌停", type: "int", hideZero: true },
{ key: "reason", label: "风险线索", type: "text", wide: true }
]
},
"market/yesterday": {
summary: "yesterday",
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "current_change", label: "今日涨幅%", type: "change" },
{ key: "outcome", label: "今日结果", type: "outcome" },
{ key: "prior_streak", label: "昨日高度", type: "int" }
],
scrollColumns: [
{ key: "current_streak", label: "当前高度", type: "height" },
{ key: "sector", label: "所属板块", type: "text" },
{ key: "reason", label: "涨停逻辑", type: "text", wide: true }
]
},
"market/performance": {
summary: "performance",
frozenColumns: [
{ key: "label", label: "梯队", type: "text", width: 96 }
],
primaryColumns: [
{ key: "advance_rate", label: "晋级率%", type: "advance" },
{ key: "advanced", label: "晋级", type: "int" },
{ key: "positive_rate", label: "收红率%", type: "rate" }
],
scrollColumns: [
{ key: "count", label: "样本", type: "int" },
{ key: "average_change", label: "平均涨幅%", type: "change" }
]
},
"market/popularity": {
summary: "popularity",
sources: ["combined", "ths", "dc"],
columns: {
combined: {
frozenColumns: [
{ key: "rank", label: "#", type: "rank", width: 40 },
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "price", label: "最新价", type: "price" },
{ key: "change", label: "涨跌幅%", type: "change" }
],
scrollColumns: [
{ key: "ths_rank", label: "同花顺", type: "int", hideZero: true },
{ key: "dc_rank", label: "东方财富", type: "int", hideZero: true },
{ key: "rank_change", label: "排名变化", type: "move" },
{ key: "concepts", label: "热门概念", type: "concepts" }
]
},
ths: {
frozenColumns: [
{ key: "rank", label: "#", type: "rank", width: 40 },
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "price", label: "最新价", type: "price" },
{ key: "change", label: "涨跌幅%", type: "change" }
],
scrollColumns: [
{ key: "dc_rank", label: "东方财富", type: "int", hideZero: true },
{ key: "rank_change", label: "排名变化", type: "move" },
{ key: "concepts", label: "热门概念", type: "concepts" },
{ key: "dual_source", label: "榜单状态", type: "dual" }
]
},
dc: {
frozenColumns: [
{ key: "rank", label: "#", type: "rank", width: 40 },
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "price", label: "最新价", type: "price" },
{ key: "change", label: "涨跌幅%", type: "change" }
],
scrollColumns: [
{ key: "ths_rank", label: "同花顺", type: "int", hideZero: true },
{ key: "rank_change", label: "排名变化", type: "move" },
{ key: "concepts", label: "热门概念", type: "concepts" },
{ key: "dual_source", label: "榜单状态", type: "dual" }
]
}
}
},
"market/sentiment/history": {
frozenColumns: [
{ key: "trade_date", label: "日期", type: "text", width: 88 }
],
primaryColumns: [
{ key: "score", label: "温度", type: "int" },
{ key: "phase", label: "阶段", type: "text" },
{ key: "direction", label: "方向", type: "text" }
],
scrollColumns: [
{ key: "limit_up_count", label: "涨停", type: "int" },
{ key: "first_board_count", label: "首板", type: "int" },
{ key: "second_board_count", label: "二板", type: "int" },
{ key: "three_plus_count", label: "三板+", type: "int" },
{ key: "max_height", label: "高度", type: "int" },
{ key: "broken_count", label: "炸板", type: "int" },
{ key: "limit_down_count", label: "跌停", type: "int" },
{ key: "seal_rate", label: "封板率%", type: "rate" },
{ key: "previous_limit_count", label: "昨涨停", type: "int" },
{ key: "previous_positive_rate", label: "昨红率%", type: "rate" }
]
},
"market/auction": {
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "attention_score", label: "关注分", type: "score" },
{ key: "change", label: "竞价涨幅%", type: "change" },
{ key: "expectation", label: "预期", type: "expectation" }
],
scrollColumns: [
{ key: "sector", label: "方向", type: "text" },
{ key: "amount_million", label: "竞价额百万", type: "money" },
{ key: "volume_ratio", label: "量比", type: "rate" }
]
},
"market/rotation/members": {
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "change", label: "涨跌幅%", type: "change" },
{ key: "close", label: "收盘", type: "price" }
],
scrollColumns: [
{ key: "open", label: "开盘", type: "price" },
{ key: "amount_billion", label: "成交额亿", type: "money" }
]
},
"market/themes/members": {
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "change", label: "涨跌幅%", type: "change" },
{ key: "price", label: "现价", type: "price" }
],
scrollColumns: [
{ key: "amount_billion", label: "成交额亿", type: "money" }
]
},
"market/dragon/operations": {
frozenColumns: [
{ key: "stock", label: "股票", type: "stock", width: 96 }
],
primaryColumns: [
{ key: "direction", label: "方向", type: "direction" },
{ key: "change", label: "涨幅%", type: "change" }
],
scrollColumns: [
{ key: "buy_million", label: "买入百万", type: "money" },
{ key: "sell_million", label: "卖出百万", type: "money" },
{ key: "net_buy_million", label: "净额百万", type: "change" },
{ key: "seat_name", label: "席位", type: "text", wide: true },
{ key: "reason", label: "上榜原因", type: "text", wide: true }
]
}
}
};
global.MobileNav = nav;
})(window);
File diff suppressed because it is too large Load Diff
+559
View File
@@ -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;
}
}
+101
View File
@@ -0,0 +1,101 @@
#m-app {
color-scheme: light;
--canvas: #f4f5f7;
--surface: #ffffff;
--surface-muted: #f2f3f5;
--surface-subtle: #f8f9fb;
--surface-raised: #ffffff;
--surface-hover: #f2f3f5;
--surface-selected: #eaf1fe;
--border: #eceded;
--border-strong: #dee0e3;
--border-subtle: #eef0f3;
--text-primary: #1f2329;
--text-secondary: #646a73;
--text-tertiary: #8f959e;
--text-inverse: #ffffff;
--action: #3370ff;
--action-hover: #2b5fd9;
--action-soft: #eaf1fe;
--market-up: #e04536;
--market-up-soft: #fdecea;
--market-down: #16a34a;
--market-down-soft: #e9f7ee;
--warning: #b45309;
--warning-soft: #fdf3e3;
--chart-up: #c93f45;
--chart-down: #087a55;
--elevation-card: 0 1px 2px rgba(31, 35, 41, .04);
--elevation-float: 0 12px 32px rgba(0, 0, 0, .14);
--backdrop: rgba(17, 24, 39, .48);
--motion-press: 100ms;
--motion-press-release: 160ms;
--motion-fade: 120ms;
--motion-enter: 260ms;
--motion-exit: 200ms;
--motion-pop: 240ms;
--ease-enter: cubic-bezier(0.22, 1, 0.36, 1);
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
--ease-press: ease-out;
--mobile-header-height: 52px;
--mobile-touch-size: 44px;
--mobile-safe-bottom: env(safe-area-inset-bottom, 0px);
--mobile-page-padding: 12px;
--mobile-chart-height: 240px;
--mobile-chart-height-compact: 168px;
--mobile-table-row-height: 44px;
--mobile-table-header-height: 40px;
--mobile-icon-cell: 78px;
--mobile-sheet-radius: 12px;
--mobile-min-width: 320px;
--mobile-tabbar-height: 56px;
--mobile-tabbar-label-size: 11px;
--m-keyboard-inset: 0px;
--font-size-aux: 11.5px;
--font-size-caption: 12.5px;
--font-size-label: 13px;
--font-size-table: 13.5px;
--font-size-body: 14px;
--font-size-card-title: 15px;
--font-size-page-title: 18px;
--font-size-metric: 24px;
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
font-size: 14px;
}
#m-app[data-theme="dark"] {
color-scheme: dark;
--canvas: #141519;
--surface: #232529;
--surface-muted: #2a2d33;
--surface-subtle: #202329;
--surface-raised: #232529;
--surface-hover: #2a2d33;
--surface-selected: #2b3b58;
--border: #2b2e34;
--border-strong: #3a3e47;
--border-subtle: #2b2e34;
--text-primary: #e8eaed;
--text-secondary: #a9adb3;
--text-tertiary: #7c828a;
--text-inverse: #ffffff;
--action: #5b8def;
--action-hover: #7ba5f5;
--action-soft: #2b3b58;
--market-up: #f26762;
--market-up-soft: #3d2829;
--market-down: #43bc8a;
--market-down-soft: #22362c;
--warning: #e2ad58;
--warning-soft: #3d3220;
--chart-up: #f06d73;
--chart-down: #43bc8a;
--elevation-card: 0 1px 2px rgba(0, 0, 0, .28);
--elevation-float: 0 12px 32px rgba(0, 0, 0, .46);
--backdrop: rgba(0, 0, 0, .66);
}
+46
View File
@@ -0,0 +1,46 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>小白复盘</title>
<link rel="stylesheet" href="css/tokens.css">
<link rel="stylesheet" href="css/shell.css">
<link rel="stylesheet" href="css/features.css">
</head>
<body>
<div id="m-app" data-theme="light">
<script>
(() => {
let theme = "light";
try {
theme = localStorage.getItem("xiaobaiTheme") === "dark" ? "dark" : "light";
} catch (_error) {
theme = "light";
}
document.getElementById("m-app").setAttribute("data-theme", theme);
})();
</script>
<div id="m-boot-splash" aria-hidden="true">
<span class="m-brand-mark"></span>
</div>
<header id="m-header" class="m-header">
<button id="m-back" class="m-header-btn" type="button" aria-label="返回" hidden>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
</button>
<h1 id="m-title" class="m-title">小白复盘</h1>
<div id="m-actions" class="m-actions"></div>
</header>
<main id="m-view" class="m-view"></main>
<nav id="m-tabbar" class="m-tabbar" aria-label="主导航" hidden></nav>
</div>
<script src="config/nav.config.js"></script>
<script src="../shared/api.js"></script>
<script src="js/api.js"></script>
<script src="js/session.js"></script>
<script src="js/router.js"></script>
<script src="js/pages.js"></script>
<script src="js/boot.js"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
(function (global) {
"use strict";
// 手机端 API 薄封装:唯一 fetch 出口收敛到 shared/api.jsXiaobaiAPI)。
// 本文件不再直接调用 fetch,仅负责把 CSRF Token 配置进共享出口并转发请求,
// 保持既有 MobileAPI.request(url[, method, body]) 调用面不变。
let csrfToken = "";
function setCsrfToken(token) {
csrfToken = token || "";
global.XiaobaiAPI.configure({
csrfToken: function () {
return csrfToken;
}
});
}
async function request(url, method, body) {
return global.XiaobaiAPI.request(url, method, body);
}
// 流式聊天复用共享层的 streamNdjson:每解析一行 NDJSON 就回调 onEvent
// 调用方通过 { signal } 传入 AbortController 以支持聊天页的「停止」按钮。
function streamNdjson(url, options) {
return global.XiaobaiAPI.streamNdjson(url, options || {});
}
global.MobileAPI = { setCsrfToken: setCsrfToken, request: request, streamNdjson: streamNdjson };
})(window);
+86
View File
@@ -0,0 +1,86 @@
(function (global) {
"use strict";
const THEME_KEY = "xiaobaiTheme";
function readTheme() {
try {
return global.localStorage.getItem(THEME_KEY) === "dark" ? "dark" : "light";
} catch (_error) {
return "light";
}
}
function applyTheme(theme) {
const normalized = theme === "dark" ? "dark" : "light";
const app = document.getElementById("m-app");
app.dataset.theme = normalized;
app.style.colorScheme = normalized;
document.body.style.backgroundColor = normalized === "dark" ? "#141519" : "#f4f5f7";
return normalized;
}
function toggle() {
const next = readTheme() === "dark" ? "light" : "dark";
try {
global.localStorage.setItem(THEME_KEY, next);
} catch (_error) {
// Theme still applies for the current page when storage is unavailable.
}
applyTheme(next);
}
global.MobileTheme = { readTheme: readTheme, applyTheme: applyTheme, toggle: toggle };
function syncKeyboardInset() {
const app = document.getElementById("m-app");
const visual = global.visualViewport;
if (!visual) {
app.style.setProperty("--m-keyboard-inset", "0px");
return;
}
// 键盘高度 = 布局视口高度 - 可视视口高度。iOS 弹键盘时会同时抬升 offsetTop,
// 若再扣 offsetTop 会把 inset 算成 0,导致输入区/底栏不避让,故只按高度差计算。
const inset = Math.max(0, global.innerHeight - visual.height);
app.style.setProperty("--m-keyboard-inset", inset + "px");
}
function bindViewport() {
const visual = global.visualViewport;
if (visual) {
visual.addEventListener("resize", syncKeyboardInset);
visual.addEventListener("scroll", syncKeyboardInset);
}
syncKeyboardInset();
}
async function boot() {
applyTheme(readTheme());
bindViewport();
let session = { authenticated: false };
try {
session = await global.MobileSession.me();
} catch (_error) {
session = { authenticated: false };
}
const hash = global.location.hash || "";
if (!session.authenticated) {
if (!/^#\/?auth/.test(hash)) global.location.replace("#/auth");
} else if (/^#\/?auth/.test(hash)) {
global.location.replace("#/hub/market");
}
removeBootSplash();
global.MobileRouter.init();
}
function removeBootSplash() {
const splash = document.getElementById("m-boot-splash");
if (splash) splash.remove();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
})(window);
File diff suppressed because it is too large Load Diff
+427
View File
@@ -0,0 +1,427 @@
(function (global) {
"use strict";
const ICONS = {
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
"sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
"moon": '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
"bar-chart-3": '<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
"wand-2": '<path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72Z"/><path d="m14 7 3 3"/><path d="M5 6v4"/><path d="M19 14v4"/><path d="M10 2v2"/><path d="M7 8H3"/><path d="M21 16h-4"/><path d="M11 3H9"/>',
"notebook-pen": '<path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4"/><path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/><path d="m21.38 5.63-3-3a1 1 0 0 0-1.42 0l-5.01 5.01a2 2 0 0 0-.5.85l-.84 2.87a.5.5 0 0 0 .62.62l2.87-.84a2 2 0 0 0 .85-.5z"/>',
"message-square": '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
"settings": '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
"inbox": '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
"activity": '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
"trending-up": '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
"trending-down": '<polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/>',
"zap": '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
"history": '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
"chart-line": '<path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/>',
"layers": '<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
"refresh-cw": '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
"gavel": '<path d="m14 13-7.5 7.5c-.83.83-2.17.83-3 0 0 0 0 0 0 0a2.12 2.12 0 0 1 0-3L11 10"/><path d="m16 16 6-6"/><path d="m8 8 6-6"/><path d="m9 7 8 8"/><path d="m21 11-8-8"/>',
"book-open": '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
"flame": '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
"crown": '<path d="M11.562 3.266a.5.5 0 0 1 .876 0L15.39 8.87a1 1 0 0 0 1.516.294L21.183 5.5a.5.5 0 0 1 .798.519l-2.834 10.246a1 1 0 0 1-.956.735H5.81a1 1 0 0 1-.957-.735L2.02 6.02a.5.5 0 0 1 .798-.519l4.276 3.664a1 1 0 0 0 1.516-.294z"/><path d="M5 21h14"/>',
"filter": '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
"target": '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
"bot": '<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>',
"star": '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
"scroll-text": '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
"calendar-check": '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="m9 16 2 2 4-4"/>',
"sticky-note": '<path d="M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8Z"/><path d="M15 3v4a2 2 0 0 0 2 2h4"/>',
"bell": '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
"user": '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
"lock": '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
"gem": '<path d="M6 3h12l4 6-10 13L2 9Z"/><path d="M11 3 8 9l4 13 4-13-3-6"/><path d="M2 9h20"/>',
"sliders-horizontal": '<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>',
"users": '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'
};
function icon(name, size) {
const body = ICONS[name] || "";
return '<svg width="' + (size || 22) + '" height="' + (size || 22) + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + body + "</svg>";
}
function escapeHtml(value) {
return String(value == null ? "" : value).replace(/[&<>"']/g, function (ch) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch];
});
}
const DEFAULT_HASH = "#/hub/market";
const stack = [];
let internalNav = 0;
let authMode = "login";
const VIEW_MOTION_CLASSES = ["m-motion-fade-in", "m-motion-push-in", "m-motion-pop-in", "m-motion-boot-in"];
function applyViewMotion(motion) {
const view = document.getElementById("m-view");
VIEW_MOTION_CLASSES.forEach(function (cls) {
view.classList.remove(cls);
});
void view.offsetWidth;
view.classList.add(motion || "m-motion-fade-in");
}
function currentHash() {
let hash = window.location.hash || "";
if (!hash || hash === "#" || hash === "#/") return DEFAULT_HASH;
if (hash.charAt(0) !== "#") hash = "#" + hash;
return hash;
}
function routeOf(hash) {
const path = hash.replace(/^#\/?/, "");
const parts = path.split("/").filter(Boolean);
return { name: parts[0] || "home", params: parts.slice(1) };
}
function currentRoute() {
return routeOf(stack.length ? stack[stack.length - 1] : currentHash());
}
function entryKeyOfRoute(route) {
if (route.name === "hub") return route.params[0];
if (route.name === "feature") return route.params[0];
if (route.name === "assistant") return "assistant";
return null;
}
function entryRoute(key) {
return key === "assistant" ? "#/assistant/chat" : "#/hub/" + key;
}
function setHash(hash) {
internalNav++;
window.location.hash = hash;
}
function navigate(hash) {
stack.push(hash);
setHash(hash);
render("m-motion-push-in");
}
function replace(hash, motion) {
if (stack.length) stack[stack.length - 1] = hash;
else stack.push(hash);
setHash(hash);
render(motion);
}
function resetStack(hash) {
stack.length = 0;
stack.push(hash);
setHash(hash);
render();
}
function parentRoute(route) {
if (route.name === "feature") return "#/hub/" + route.params[0];
return null;
}
function back() {
if (stack.length > 1) {
stack.pop();
setHash(stack[stack.length - 1]);
render("m-motion-pop-in");
return;
}
const parent = parentRoute(currentRoute());
if (parent) replace(parent, "m-motion-pop-in");
}
function switchEntry(key) {
const route = currentRoute();
const current = entryKeyOfRoute(route);
if (current === key) {
if (route.name === "feature") {
resetStack(entryRoute(key));
}
return;
}
resetStack(entryRoute(key));
}
function updateHeader(options) {
document.getElementById("m-title").textContent = options.title || "小白复盘";
document.getElementById("m-back").hidden = !options.back;
document.getElementById("m-actions").innerHTML = options.actions || "";
}
function placeholderHtml(title, subtitle) {
return '<div class="m-placeholder m-motion-rise-in">' +
'<span class="m-placeholder-icon">' + icon("inbox", 26) + "</span>" +
"<h2>" + escapeHtml(title) + "</h2>" +
"<p>" + escapeHtml(subtitle || "该页面将在后续批次实现,返回即可继续浏览。") + "</p>" +
"</div>";
}
function findFeatureLabel(key) {
const hubs = global.MobileNav.hubs;
for (const hubKey in hubs) {
const items = hubs[hubKey].items || [];
for (const item of items) {
if (item.key === key) return item.label;
}
}
return null;
}
function themeToggleSection() {
const dark = document.getElementById("m-app").dataset.theme === "dark";
return '<div class="m-theme-section">' +
'<div class="m-theme-row">' +
'<span class="m-theme-row-icon">' + icon(dark ? "moon" : "sun") + "</span>" +
'<span class="m-theme-row-body"><strong>外观主题</strong><small>' + (dark ? "当前:夜间模式" : "当前:日间模式") + "</small></span>" +
'<button class="m-theme-switch" type="button" data-theme-toggle role="switch" aria-checked="' + (dark ? "true" : "false") + '" aria-label="切换日间/夜间模式"><span class="m-theme-switch-thumb"></span></button>' +
"</div></div>";
}
function syncThemeToggleUI() {
const dark = document.getElementById("m-app").dataset.theme === "dark";
const toggle = document.querySelector("[data-theme-toggle]");
if (toggle) toggle.setAttribute("aria-checked", dark ? "true" : "false");
const rowIcon = document.querySelector(".m-theme-row-icon");
if (rowIcon) rowIcon.innerHTML = icon(dark ? "moon" : "sun");
const rowBodySmall = document.querySelector(".m-theme-row-body small");
if (rowBodySmall) rowBodySmall.textContent = dark ? "当前:夜间模式" : "当前:日间模式";
}
function visibleHubItems(hub) {
const items = hub && hub.items ? hub.items : [];
return items.filter(function (item) {
return !item.adminOnly || global.MobileSession.isAdmin();
});
}
function renderHub(key) {
const hub = global.MobileNav.hubs[key];
if (!hub) {
replace(DEFAULT_HASH);
return;
}
const items = visibleHubItems(hub);
updateHeader({ title: hub.title, back: false });
const section = key === "system" ? themeToggleSection() : "";
document.getElementById("m-view").innerHTML =
section +
'<ul class="m-hub-grid">' +
items.map(function (item) {
return '<li>' +
'<button class="m-grid-item" type="button" data-route="#/feature/' + item.key + '">' +
'<span class="m-grid-icon">' + icon(item.icon) + "</span>" +
'<span class="m-grid-label">' + escapeHtml(item.label) + "</span>" +
"</button></li>";
}).join("") +
"</ul>";
}
function renderFeature(key) {
if (global.MobilePages && global.MobilePages.has(key)) {
global.MobilePages.render(key);
return;
}
const title = findFeatureLabel(key) || key;
updateHeader({ title: title, back: true });
document.getElementById("m-view").innerHTML = placeholderHtml(title, "该功能页将在后续批次实现。");
}
function renderAssistant() {
if (global.MobilePages && global.MobilePages.has("assistant/chat")) {
global.MobilePages.render("assistant/chat");
return;
}
updateHeader({ title: "复盘助手", back: false });
document.getElementById("m-view").innerHTML = placeholderHtml("复盘助手", "聊天工作台将在后续批次实现。");
}
function renderAuth() {
updateHeader({ title: "小白复盘", back: false, actions: "" });
document.getElementById("m-view").innerHTML = [
'<div class="m-auth">',
'<div class="m-auth-brand">',
'<span class="m-brand-mark">复</span>',
'<h2>小白复盘</h2>',
'<p>登录后进入你的复盘空间</p>',
"</div>",
'<div class="m-auth-tabs">',
'<button class="m-auth-tab active" type="button" data-auth-mode="login">登录</button>',
'<button class="m-auth-tab" type="button" data-auth-mode="register">注册</button>',
"</div>",
'<form id="m-auth-form">',
'<label class="m-form-field"><span>账号名</span><input id="m-auth-username" type="text" minlength="3" maxlength="30" autocomplete="username" required></label>',
'<label class="m-form-field"><span>密码</span><input id="m-auth-password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required></label>',
'<label class="m-form-field" id="m-auth-confirm-field" hidden><span>确认密码</span><input id="m-auth-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"></label>',
'<p class="m-auth-error" id="m-auth-error" hidden></p>',
'<button class="m-btn-primary" id="m-auth-submit" type="submit"><span class="m-btn-spinner" aria-hidden="true" hidden></span><span class="m-btn-label">登录</span></button>',
"</form>",
"</div>"
].join("");
authMode = "login";
bindAuth();
}
function setAuthMode(mode) {
authMode = mode === "register" ? "register" : "login";
document.querySelectorAll("[data-auth-mode]").forEach(function (button) {
button.classList.toggle("active", button.dataset.authMode === authMode);
});
document.getElementById("m-auth-confirm-field").hidden = authMode !== "register";
document.getElementById("m-auth-confirm").required = authMode === "register";
document.getElementById("m-auth-password").autocomplete = authMode === "register" ? "new-password" : "current-password";
const submitLabel = document.querySelector("#m-auth-submit .m-btn-label");
if (submitLabel) submitLabel.textContent = authMode === "register" ? "注册并进入" : "登录";
document.getElementById("m-auth-error").hidden = true;
}
function bindAuth() {
document.querySelectorAll("[data-auth-mode]").forEach(function (button) {
button.addEventListener("click", function () { setAuthMode(button.dataset.authMode); });
});
document.getElementById("m-auth-form").addEventListener("submit", submitAuth);
}
function showAuthError(element, message) {
element.textContent = message;
element.classList.remove("m-motion-fade-in");
void element.offsetWidth;
element.classList.add("m-motion-fade-in");
element.hidden = false;
}
async function submitAuth(event) {
event.preventDefault();
const username = document.getElementById("m-auth-username").value.trim();
const password = document.getElementById("m-auth-password").value;
const errorElement = document.getElementById("m-auth-error");
if (authMode === "register" && password !== document.getElementById("m-auth-confirm").value) {
showAuthError(errorElement, "两次输入的密码不一致。");
return;
}
const submit = document.getElementById("m-auth-submit");
const spinner = submit.querySelector(".m-btn-spinner");
const label = submit.querySelector(".m-btn-label");
submit.disabled = true;
spinner.hidden = false;
label.textContent = authMode === "register" ? "注册中…" : "登录中…";
try {
if (authMode === "register") {
await global.MobileSession.register(username, password);
} else {
await global.MobileSession.login(username, password);
}
replace(DEFAULT_HASH);
} catch (error) {
showAuthError(errorElement, error.message || "账号操作失败");
} finally {
submit.disabled = false;
spinner.hidden = true;
label.textContent = authMode === "register" ? "注册并进入" : "登录";
}
}
function buildTabbar() {
document.getElementById("m-tabbar").innerHTML = global.MobileNav.entries.map(function (entry) {
return '<button class="m-tabbar-item" type="button" data-tab="' + entry.key + '">' +
'<span class="m-tabbar-icon">' + icon(entry.icon, 24) + "</span>" +
'<span class="m-tabbar-label">' + escapeHtml(entry.title) + "</span>" +
"</button>";
}).join("");
}
function updateTabbar(activeKey) {
document.querySelectorAll("#m-tabbar .m-tabbar-item").forEach(function (button) {
const active = button.dataset.tab === activeKey;
button.classList.toggle("active", active);
if (active) button.setAttribute("aria-current", "page");
else button.removeAttribute("aria-current");
});
}
function render(motion) {
const route = currentRoute();
document.getElementById("m-view").classList.remove("m-view-feature");
if (route.name === "home") {
replace(DEFAULT_HASH);
return;
}
const tabbar = document.getElementById("m-tabbar");
if (route.name === "auth") {
tabbar.hidden = true;
document.getElementById("m-app").dataset.tabbar = "false";
} else {
tabbar.hidden = false;
document.getElementById("m-app").dataset.tabbar = "true";
updateTabbar(entryKeyOfRoute(route));
}
if (route.name === "hub") renderHub(route.params[0]);
else if (route.name === "feature") renderFeature(route.params.join("/"));
else if (route.name === "assistant") renderAssistant();
else if (route.name === "auth") renderAuth();
applyViewMotion(motion);
}
function bindGlobalEvents() {
document.getElementById("m-view").addEventListener("click", function (event) {
const entry = event.target.closest("[data-route]");
if (entry) {
navigate(entry.dataset.route);
}
});
document.getElementById("m-tabbar").addEventListener("click", function (event) {
const item = event.target.closest("[data-tab]");
if (item) switchEntry(item.dataset.tab);
});
document.getElementById("m-back").addEventListener("click", back);
document.addEventListener("click", function (event) {
const toggle = event.target.closest("[data-theme-toggle]");
if (!toggle) return;
global.MobileTheme.toggle();
syncThemeToggleUI();
});
window.addEventListener("hashchange", function () {
if (internalNav > 0) {
internalNav--;
return;
}
const hash = currentHash();
if (stack.length > 1 && hash === stack[stack.length - 2]) {
stack.pop();
render("m-motion-pop-in");
} else if (hash !== stack[stack.length - 1]) {
stack.push(hash);
render("m-motion-push-in");
}
});
}
function init() {
bindGlobalEvents();
buildTabbar();
stack.length = 0;
const raw = window.location.hash || "";
if (!raw || raw === "#" || raw === "#/") {
stack.push(DEFAULT_HASH);
setHash(DEFAULT_HASH);
} else {
stack.push(currentHash());
}
render("m-motion-boot-in");
}
global.MobileRouter = {
init: init,
navigate: navigate,
replace: replace,
back: back,
render: render,
currentRoute: currentRoute,
updateHeader: updateHeader
};
})(window);
+51
View File
@@ -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);
+1 -2
View File
@@ -33,7 +33,6 @@ function renderLadderBoard(ladders) {
return groupMap.get(level) || { level, label: level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}`, count: 0, stocks: [] };
});
const total = ordered.reduce((sum, group) => sum + number(group.count), 0);
const spaceStocks = ordered.find((group) => number(group.level) === maxLevel)?.stocks || [];
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
setText("ladderDateRange", `数据日期 ${currentDate}`);
@@ -77,7 +76,7 @@ function renderLadderBoard(ladders) {
const spaceNote = maxLevel >= 5 ? "高位梯队仍有辨识度,重点观察承接而非单看高度。" : maxLevel >= 3 ? "空间位于中段,梯队延续性比绝对高度更重要。" : "高度受到压缩,先观察首板向二板的结构修复。";
const strongestGroup = structureRows.reduce((best, group) => number(group.count) > number(best?.count) ? group : best, structureRows[0]);
insights.innerHTML = `
<section class="market-ladder-insight-card market-ladder-apex-card"><header><h3>空间板</h3><span>市场高度</span></header><div class="market-ladder-apex"><div><strong>${maxLevel ? `${maxLevel}` : "--"}</strong><em>${escapeHtml(spaceChange)}</em></div><p>${spaceStocks.length ? spaceStocks.map((stock) => `<b>${escapeHtml(stock.name)}</b>${escapeHtml(stock.sector || "其他")}`).join(" · ") : "暂无空间板"}</p></div><p>${spaceNote}</p></section>
<section class="market-ladder-insight-card market-ladder-apex-card"><header><h3>空间板</h3><span>市场高度</span></header><div class="market-ladder-apex"><div><strong>${maxLevel ? `${maxLevel}` : "--"}</strong><em>${escapeHtml(spaceChange)}</em></div></div><p>${spaceNote}</p></section>
<section class="market-ladder-insight-card"><header><h3>梯队结构</h3><span>完整度</span></header><div class="market-ladder-pyramid">${structureRows.map((group) => `<div class="market-ladder-pyramid-row ${number(group.count) ? "" : "is-gap"}"><span>${escapeHtml(group.label || `${number(group.level)}`)}</span><i><b style="width:${Math.max(number(group.count) ? 8 : 100, number(group.count) / maxCount * 100)}%"></b></i><strong>${number(group.count) ? `${number(group.count)}` : "断层"}</strong></div>`).join("")}</div><p>断层越少,梯队从低位向高位传导越连贯。当前腰部为 <b>${escapeHtml(strongestGroup?.label || "--")}</b>。</p></section>
<section class="market-ladder-insight-card"><header><h3>晋级率参考</h3><span>昨日梯队 → 今日</span></header><div class="market-ladder-rate-list">${rateRows.length ? rateRows.map((row) => `<div><span>${escapeHtml(row.label)}</span><i><b class="${row.value === 0 ? "is-zero" : row.value < 20 ? "is-low" : ""}" style="width:${Math.max(row.value, row.value > 0 ? 2 : 0)}%"></b></i><strong class="${row.value === 0 ? "is-zero" : row.value < 20 ? "is-low" : ""}">${formatNumber(row.value, 1)}%</strong></div>`).join("") : '<div class="empty-state">暂无可比梯队</div>'}</div><small class="market-ladder-source">数据来自“涨停表现”页 · 昨日梯队样本</small></section>`;
container.querySelectorAll("[data-ladder-level]").forEach((button) => {