Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e8ad242d | ||
|
|
38f307d591 | ||
|
|
a92caa4f3f | ||
|
|
7752648e4b | ||
|
|
6174efaee5 | ||
|
|
574a375292 | ||
|
|
7bf5d8c2b6 | ||
|
|
6144a480c7 | ||
|
|
b213b4362a | ||
|
|
1b333b6b93 | ||
|
|
224e2a7f21 | ||
|
|
ed9858e330 |
@@ -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:
|
||||
|
||||
@@ -471,8 +471,8 @@
|
||||
},
|
||||
{
|
||||
"path": "frontend/pages/auction/foundation.css",
|
||||
"bytes": 35329,
|
||||
"lines": 2420
|
||||
"bytes": 35247,
|
||||
"lines": 2416
|
||||
},
|
||||
{
|
||||
"path": "database.py",
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
> ⚠️ **本文档已过时,仅留档备查,请勿删除。**
|
||||
> 本交接说明核实于 2026-08-06,其中「当前提交」「当前状态」「正在处理的事项」「验证记录」等已与代码现状不符(当时的未提交改动现已合并,项目已推进到全站视觉统一收尾阶段)。
|
||||
> 最新内容请看 `docs/项目需求.md`、`docs/最新进度.md`、`docs/任务清单.md` 和 `docs/README.md`。
|
||||
> 架构与维护规矩仍以根目录 `AGENTS.md`、`ARCHITECTURE.md` 为准;本文第 2、6 节(架构与决策)仍可作参考。
|
||||
|
||||
# 小白复盘项目交接说明
|
||||
|
||||
> 核实日期:2026-08-06(Asia/Shanghai)
|
||||
|
||||
+33
-9
@@ -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. 更新完提交并推送进仓库(保存并上传到放代码的网站),不能只留在自己电脑里。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 旧文档不能删:被替代的旧文档开头要加一行「⚠️ 本文档已过时,仅留档备查,请勿删除」,再写新版。
|
||||
- 用中文大白话写,专业词要带通俗解释,让不懂代码的人也能看懂。
|
||||
- 「问天」板块是冻结区,任何改动都不许碰;写文档时别误导后来人去改它。
|
||||
|
||||
@@ -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` 已过时,仅留档备查。
|
||||
|
||||
@@ -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)。
|
||||
@@ -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`。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 项目需求
|
||||
|
||||
> 大白话版。逐页、逐字段的完整需求请看 `product/小白复盘-完整产品规格说明书.md`(那是最权威的“说明书”,从零重建项目都用它)。
|
||||
|
||||
## 这个项目是干什么的
|
||||
|
||||
「小白复盘」是一个给老板个人用的股票复盘工具网站,放在内网访问(地址 `192.168.200.36:8765`)。
|
||||
|
||||
它不是炒股下单软件,而是「收盘后和开盘前」用来复盘、观察市场的工具:
|
||||
|
||||
- 把当天(或最近)的真实行情、涨停跌停、板块轮动、集合竞价等信息整理清楚,帮老板复盘。
|
||||
- 提供智能选股、问师(跟“游资思维”老师对话)等辅助分析。
|
||||
- 记录自己的交易和复盘。
|
||||
|
||||
一句话:它帮你“看清市场、想清楚思路、记下来”,但不替你做买卖决定。
|
||||
|
||||
## 要解决什么问题
|
||||
|
||||
1. **市场信息太散**:涨停池、炸板池、跌停板、龙虎榜、人气榜、板块轮动这些信息本来分散在各处,这个网站把它们集中到一处,还配了日间/夜间两套配色,看起来统一。
|
||||
2. **复盘靠脑子记不住**:提供“我的复盘”和交易日志,把每天的判断、操作、情绪记录下来。
|
||||
3. **选股没思路**:智能选股用规则和因子(影响股价的数据指标)帮你筛出候选股票。
|
||||
4. **想听“高手”怎么看**:问师模块可以按不同的游资思维(比如佛山无影脚、北京炒家等)跟老师对话。
|
||||
|
||||
## 主要功能(板块)
|
||||
|
||||
登录后侧栏有 16 个主工作区,默认进入「情绪周期」:
|
||||
|
||||
- **市场类(12 个)**:情绪周期、涨停池、炸板池、跌停板、昨日涨停、涨停表现、市场天梯、板块轮动、集合竞价、题材库、人气热榜、龙虎榜。
|
||||
- **智能工具类(3 个)**:智能选股、问师、问天。
|
||||
- **个人类(1 个)**:我的复盘。
|
||||
|
||||
其中「问天」是冻结区(见下面的硬规矩)。
|
||||
|
||||
## 几条硬规矩(不能破坏的边界)
|
||||
|
||||
- 「问天」板块是**冻结区**,任何改动都不许碰它。
|
||||
- **不用假数据冒充真行情**;数据缺失就明说“没有/不可用”,不能编。
|
||||
- **每个用户自己的数据互相隔离**(自选、复盘、对话、问天历史等),看不到别人的。
|
||||
- **计算由程序确定性完成**(情绪周期、智能选股、问天排盘等),AI 大模型(LLM,就是会聊天的那个 AI)只负责解释或编译自然语言条件,不能改计算结果。
|
||||
- **不接券商、不自动下单**,不承诺收益。
|
||||
|
||||
## 权限
|
||||
|
||||
- **普通用户**:能用大部分市场页面和「我的复盘」。
|
||||
- **会员**:额外能用智能选股、问师、问天(需要管理员开通)。
|
||||
- **管理员**:管理公共行情密钥、会员额度和系统配置等。
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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);
|
||||
@@ -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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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);
|
||||
@@ -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);
|
||||
-4
@@ -2102,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;
|
||||
|
||||
|
||||
@@ -619,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;
|
||||
|
||||
|
||||
-4
@@ -1089,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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user