Rockz-Home 1 月之前
父節點
當前提交
d370803bc4
共有 30 個文件被更改,包括 4904 次插入0 次删除
  1. 51 0
      card/pages/tpl/style3/index_analysis.md
  2. 29 0
      card/pages/tpl/style3/new/AGENTS.md
  3. 148 0
      card/pages/tpl/style3/new/API.md
  4. 556 0
      card/pages/tpl/style3/new/API_SERVER.md
  5. 378 0
      card/pages/tpl/style3/new/HTML_MIGRATION_STRATEGY.md
  6. 93 0
      card/pages/tpl/style3/new/PROJECT_INSIGHTS.md
  7. 375 0
      card/pages/tpl/style3/new/api.js
  8. 二進制
      card/pages/tpl/style3/new/bd.png
  9. 360 0
      card/pages/tpl/style3/new/bridge.js
  10. 5 0
      card/pages/tpl/style3/new/css/all.min.css
  11. 二進制
      card/pages/tpl/style3/new/gd.png
  12. 77 0
      card/pages/tpl/style3/new/index.html
  13. 68 0
      card/pages/tpl/style3/new/js/api.js
  14. 401 0
      card/pages/tpl/style3/new/js/cardfunc.js
  15. 75 0
      card/pages/tpl/style3/new/js/define.js
  16. 207 0
      card/pages/tpl/style3/new/js/logic-index.js
  17. 463 0
      card/pages/tpl/style3/new/js/logic-ranklist.js
  18. 264 0
      card/pages/tpl/style3/new/js/logic-signup.js
  19. 0 0
      card/pages/tpl/style3/new/js/multiavatar.min.js
  20. 205 0
      card/pages/tpl/style3/new/js/tools.js
  21. 187 0
      card/pages/tpl/style3/new/js/uni-compat.js
  22. 146 0
      card/pages/tpl/style3/new/mock_flutter.js
  23. 275 0
      card/pages/tpl/style3/new/ranklist.html
  24. 304 0
      card/pages/tpl/style3/new/signup.html
  25. 二進制
      card/pages/tpl/style3/new/webfonts/fa-brands-400.woff2
  26. 二進制
      card/pages/tpl/style3/new/webfonts/fa-regular-400.woff2
  27. 二進制
      card/pages/tpl/style3/new/webfonts/fa-solid-900.woff2
  28. 86 0
      card/pages/tpl/style3/rankList_analysis.md
  29. 78 0
      card/pages/tpl/style3/rankOverview_analysis.md
  30. 73 0
      card/pages/tpl/style3/signup_analysis.md

+ 51 - 0
card/pages/tpl/style3/index_analysis.md

@@ -0,0 +1,51 @@
+# `pages/tpl/style3/index.vue` 交互流程与 API 分析
+
+`pages/tpl/style3/index.vue` 是一个用于展示赛事卡片概览的页面。以下是其详细的交互流程和 API 调用分析。
+
+### 1. 程序流程分析
+
+1.  **页面初始化 (`onLoad`)**:
+    *   **参数解析**: 页面加载时,会从 URL 参数中解析出 `token` (用户令牌)、`id` (卡片ID,即 `ecId`)、`type` (卡片类型,例如“锦标赛”) 和 `btnText` (按钮文本,例如“开始比赛”) 等信息。
+    *   **初始化 `cardfunc`**: 调用 `cardfunc.init()` 方法,对卡片功能相关的模块进行初始化。
+    *   **配置获取**: 通过 `cardfunc.getCardConfig()` 获取卡片页面所需的配置数据。这些配置可能包括页面的样式规则和通用设置。配置成功加载后,`pageReady` 状态会被设为 `true`,页面内容将显示给用户。
+    *   **数据获取**:
+        *   调用 `getCardBaseQuery()` 方法来获取卡片的基本信息。
+        *   调用 `matchRsDetailQuery()` 方法获取赛事的详细数据,这部分数据主要用于检测更新和显示“小红点”通知。
+
+2.  **页面逻辑处理**:
+    *   **倒计时显示**: `getCardBaseQuery` 成功返回数据后,页面会启动一个定时器 (`setInterval`)。这个定时器每分钟会调用 `getCountdown()` 方法来更新页面上显示的倒计时。当赛事结束时,`isFinished` 状态会被设为 `true`。
+    *   **通知检查 (`dealNotice`)**: `matchRsDetailQuery` 返回的数据会被转换为 JSON 字符串,然后与存储在本地缓存 (`uni.getStorage`) 中的旧数据进行对比。如果数据有差异,意味着有新的更新,页面会设置 `notice = true` 来显示一个“小红点”通知图标。
+    *   **点击交互 (`btnClick`)**: 用户点击卡片主体时,会根据当前赛事的报名状态和结束状态执行不同的跳转逻辑:
+        *   **已报名**: 直接跳转到排行榜页面 (`/pages/tpl/style3/rankList`)。
+        *   **未报名**:
+            *   **赛事未结束**: 根据 `secondCardName` 的配置,跳转到报名页 (`/pages/tpl/style3/signup`) 或排行榜页。
+            *   **赛事已结束**: 跳转到排行榜页面。
+        *   所有的页面跳转都通过 `tools.appAction(url)` 方法完成。
+
+3.  **页面卸载 (`onUnload`)**:
+    *   为了防止内存泄漏,当页面被卸载时,会调用 `clear()` 方法来清除之前设置的倒计时定时器。
+
+### 2. 调用的 API
+
+该页面通过 `uni.request` 发起以下后端 API 请求,这些 API 的定义通常在 `common/api.js` 中:
+
+1.  **`apiCardBaseQuery`**:
+    *   **用途**: 查询卡片的基本信息,例如名称、描述、开始和结束时间等。
+    *   **参数**: `ecId` (卡片ID), `pageName` (当前页面名称,此处为 "index")。
+    *   **返回数据示例**: 包含 `ecName` (卡片名称), `ecDesc` (卡片简介), `beginSecond` (开始时间戳), `endSecond` (结束时间戳), `secondCardName` (次级卡片名称,用于跳转逻辑)。
+    *   **后续动作**: 获取数据后,页面会设置卡片名称、时间,并启动倒计时逻辑。
+
+2.  **`apiUserJoinCardQuery`**:
+    *   **用途**: 查询当前用户是否已经报名了该赛事。
+    *   **参数**: `ecId` (卡片ID)。
+    *   **返回数据示例**: 包含 `isJoin` (布尔值,表示用户是否已报名)。
+    *   **后续动作**: 根据 `isJoin` 的值,页面会更新按钮文本或决定 `btnClick` 后的跳转行为。
+
+3.  **`apiMatchRsDetailQuery`**:
+    *   **用途**: 查询卡片关联的线上赛的详细活动数据(如榜单数据)。
+    *   **参数**: `ecId` (卡片ID)。
+    *   **在此页面中的作用**: 页面会将此 API 返回的数据转换为 JSON 字符串,然后与本地存储中的旧数据进行对比 (`dealNotice` 方法)。如果数据有更新,则会在页面上显示一个通知图标(“小红点”)。
+
+### 3. 本地存储 (`uni.getStorage`, `uni.setStorage`)
+
+*   **`rank-tpl-style3-{ecId}`**: 用于存储 `apiMatchRsDetailQuery` 返回的数据的 JSON 字符串。这个值用于在用户返回页面时,与最新的数据进行比较,以判断是否有新的榜单信息需要提醒用户。

+ 29 - 0
card/pages/tpl/style3/new/AGENTS.md

@@ -0,0 +1,29 @@
+# Repository Guidelines
+
+## 项目结构与模块组织
+- 核心库:`bridge.js` 负责原生桥接,`api.js` 封装服务器请求;每个页面都需优先加载。`mock_flutter.js` 仅限桌面调试使用。
+- 根目录的 `index.html` 与 `demo.html` 展示接入方式;接口文档位于 `API.md`、`API_SERVER.md`。
+- `demo_project/` 提供完整示例流程(`index.html`、`rank.html`、`signup.html`、`detail.html`),复用同一 SDK;新增页面可按其目录布局,页面私有资源放在对应 HTML 同目录。
+
+## 构建、测试与开发命令
+- 静态项目;直接在浏览器打开 `index.html` 或 `demo_project/index.html` 即可快速查看。
+- 推荐本地起服务避免 CORS:`python -m http.server 8000`,访问 `http://localhost:8000/demo_project/index.html`。
+- 交付前:移除 `<script src="./mock_flutter.js"></script>`,并在 API 初始化时填入真实 token 后再打包压缩。
+
+## 编码风格与命名约定
+- JavaScript 保持 ES5 兼容:使用 IIFE、`var`、4 空格缩进、分号,文件顶部写 `'use strict'`。
+- 函数/变量用 lowerCamelCase,常量用 UPPER_SNAKE_CASE,HTML/CSS 类名用 kebab-case。
+- 网络与桥接工具函数集中在现有文件内;未经约定不要引入打包器或模块加载器。
+
+## 测试指南
+- 以手动验证为主:开启 `mock_flutter.js` 先跑 Mock 流程,再关闭它并连真实 App Bridge/API 复测。
+- 重点检查 token 处理(URL 参数与 `Bridge.getToken()` 兜底)、`demo_project/rank.html` 与 `signup.html` 的排行榜/报名流程、以及通过 `API.getOssUrl` 拼接的 OSS 链接。
+- 如新增自动化,请在文档中写明运行方式,并与上述手动步骤并行保留。
+
+## 提交与 Pull Request 指南
+- 当前无历史记录,建议沿用 Conventional Commit 前缀(`feat:`、`fix:`、`chore:`),范围简洁。
+- PR 内容需包含:摘要、影响的页面/文件、手动测试记录(浏览器 + mock/真实环境)、UI 变更的截图或录屏,以及关联的文档更新。
+
+## 安全与配置提示
+- 不要在控制台或仓库中暴露 token 和用户数据;本地服务时优先用环境变量注入配置。
+- 交付前确认 `Config.useMock` 设为 `false`,并检查生产接口地址配置正确。

+ 148 - 0
card/pages/tpl/style3/new/API.md

@@ -0,0 +1,148 @@
+# ColorMapRun H5 交互 SDK 开发文档 (Bridge)
+
+本文档描述了 H5 页面如何与彩图奔跑 App (Flutter) 进行交互。所有的交互方法都封装在 `bridge.js` 中。
+
+## 1. 快速开始
+
+### 1.1 文件引入
+
+在您的 `index.html` 或项目入口文件中,引入 `bridge.js`。
+
+```html
+<!-- 引入通信桥梁 -->
+<script src="./bridge.js"></script>
+```
+
+**注意**:本 SDK 内部会自动处理兼容性(优先检测 `uni.webView`,自动降级适配旧版 App URL 拦截),因此您**不需要**关心 App 是新版还是旧版,直接调用 API 即可。
+
+### 1.2 本地调试 (Mock)
+
+在开发阶段(浏览器环境),App 环境不可用。请在 `<head>` 中引入 `mock_flutter.js` 来模拟 App 行为。
+
+```html
+<head>
+    <!-- ... 其他标签 ... -->
+    
+    <!-- 【仅开发环境引入】 模拟 App 通信环境 -->
+    <script src="./mock_flutter.js"></script>
+    
+    <script src="./bridge.js"></script>
+</head>
+```
+
+**⚠️ 重要**:在打包交付给 App 端集成前,**请务必注释或删除对 `mock_flutter.js` 的引用**。
+
+---
+
+## 2. API 列表
+
+所有的 API 都挂载在全局对象 `window.Bridge` 上。
+
+### 2.1 基础导航
+
+#### 返回上一页
+通知 App 用户点击了返回按钮 (相当于浏览器的后退)。
+```javascript
+Bridge.back();
+```
+
+#### 返回 App 首页
+强制关闭当前 Webview,返回 App 的原生首页。
+```javascript
+Bridge.toHome();
+```
+
+#### 跳转登录
+当 API 返回 401 未授权时,调用此方法唤起 App 原生登录页。
+```javascript
+Bridge.toLogin();
+```
+
+#### 设置标题
+修改 App 顶部导航栏的标题。
+```javascript
+Bridge.setTitle('我的页面标题');
+```
+
+### 2.2 业务跳转
+
+#### 打开地图导航
+调起 App 原生导航(支持高德、百度、腾讯地图)。
+```javascript
+/**
+ * @param {number} latitude 纬度
+ * @param {number} longitude 经度
+ * @param {string} name 地点名称
+ */
+Bridge.openMap(36.666, 117.123, '济南奥体中心');
+```
+
+#### 打开比赛详情 (原生页)
+跳转到 App 内的原生比赛详情页。
+```javascript
+/**
+ * @param {string|number} id 活动ID (mcId)
+ * @param {string} type 活动类型 (默认 1)
+ */
+Bridge.openMatch(101, 1);
+```
+
+#### 打开活动列表 (原生页)
+跳转到 App 内的原生多地图/活动列表页。
+```javascript
+/**
+ * @param {string|number} id 关联ID (mapId)
+ * @param {string} mapName 地图名称
+ */
+Bridge.openActivityList(202, '奥体公园定向');
+```
+
+### 2.3 功能调用
+
+#### 微信分享
+调起 App 的微信分享面板。
+```javascript
+Bridge.shareWx({
+    title: '快来参加彩图奔跑!',
+    url: 'https://colormaprun.com/activity/123',
+    image: 'https://colormaprun.com/logo.png',
+    scene: 'session' // session: 好友, timeline: 朋友圈
+});
+```
+
+#### 跳转微信小程序
+```javascript
+Bridge.launchWxMini('gh_bea09156da8d', 'pages/index/index');
+```
+
+#### 保存图片
+将 Base64 格式的图片保存到手机相册。
+```javascript
+Bridge.saveImage('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...');
+```
+
+### 2.4 获取数据
+
+#### 获取用户 Token (异步)
+**注意**: 通常 H5 页面加载时 URL 会携带 Token。此方法主要用于特殊情况下的主动获取。
+
+```javascript
+// 1. 注册回调
+Bridge.onToken(function(token) {
+    console.log('收到 Token:', token);
+    // 使用 Token 发起 API 请求...
+});
+
+// 2. 发起获取请求
+Bridge.getToken();
+```
+
+---
+
+## 3. 常见问题
+
+**Q: 为什么 `Bridge.back()` 没有反应?**
+A: 请检查是否在浏览器环境中。如果在浏览器中,确保引入了 `mock_flutter.js`。如果在 App 中,请确认 App 的 WebView 能够响应 `javascript:history.back()` 或 `uni.postMessage`。
+
+**Q: 怎么判断当前是在 App 内?**
+A: 推荐通过 UserAgent 判断,或者尝试调用 `Bridge.getToken()` 看是否有回调。但在使用本 SDK 时,建议假设处于 App 环境编写代码,并在本地开启 Mock 进行调试。

+ 556 - 0
card/pages/tpl/style3/new/API_SERVER.md

@@ -0,0 +1,556 @@
+# ColorMapRun 后端 API 接口文档
+
+本文档描述了 H5 页面需要调用的后端接口。
+
+## 1. 基础说明
+
+*   **接口域名**:
+    *   开发环境: `https://t-mapi.colormaprun.com/api/card/`
+    *   生产环境: `https://colormaprun.com/api/card/`
+*   **OSS 资源域名**: `http://oss-card.colormaprun.com/card/`
+*   **请求方式**: `POST`
+*   **Content-Type**: `application/x-www-form-urlencoded`
+*   **鉴权**: 所有接口需要在 Header 中携带 `token`。
+
+### 1.1 请求头 (Headers)
+
+| 参数名 | 必选 | 说明 | 示例 |
+| :--- | :--- | :--- | :--- |
+| `Content-Type` | 是 | 固定值 | `application/x-www-form-urlencoded` |
+| `token` | 是 | 用户身份令牌 | `96ba3c924394934f7d30fa869a94ce0d` |
+
+### 1.2 响应结构 (Response)
+
+所有接口返回 JSON 格式数据:
+
+```json
+{
+  "code": 0,          // 0 表示成功,非 0 表示失败
+  "message": "成功",   // 提示信息
+  "data": { ... }     // 业务数据,下文中的“返回数据”均指此 data 字段的内容
+}
+```
+
+## 2. API 封装方法 (调用 `window.API` 对象)
+
+### 2.1 获取 OSS 资源基础地址
+
+*   **API 方法**: `API.getOssUrl()`
+*   **说明**: 获取图片、视频等静态资源的访问基础 URL。第三方开发者可自行拼接完整资源路径。
+*   **参数**: 无
+*   **返回**: `string` (OSS 基础 URL)
+
+---
+
+### 2.2 接口列表
+
+#### 1. 卡片与配置
+
+*   **1.1 卡片基本信息查询**
+    *   **API 方法**: `API.getCardBase(ecId, pageName)`
+    *   **参数**: `ecId` (int), `pageName` (string)
+    *   **返回数据**:
+        ```json
+        {
+            "ecName": "活动名称",
+            "ecDesc": "活动描述",
+            "beginSecond": 1700000000, // 开始时间戳
+            "endSecond": 1700090000,   // 结束时间戳
+            "secondCardName": "跳转页面名称"
+        }
+        ```
+
+*   **1.2 卡片通用配置查询**
+    *   **API 方法**: `API.getCardConfig(ecId, pageName)`
+    *   **参数**:
+        *   `ecId` (int): 卡片/活动 ID
+        *   `pageName` (string): 页面名称 (如 "all", "rankList", "index")
+    *   **返回数据**:
+        ```json
+        {
+            "configJson": "{\"css\": \"string\", \"tabActiveColor\": \"string\", \"popupRuleConfig\": {...}, \"popupRuleList\": [...]}"
+        }
+        ```
+    *   **configJson 字段说明 (示例)**:
+        *   `css`: `string`,动态注入到页面的 CSS 样式。
+        *   `tabActiveColor`: `string`,Tab 栏选中颜色,如 "#FF5733"。
+        *   `teamType`: `int`,队伍类型 (0:默认, 1:学生/家长)。
+        *   `popupRuleConfig`: `object`,规则弹窗组件的样式配置,如 `{ "height": "500px", "theme": "light" }`。
+        *   `popupMessageConfig`: `object`,消息弹窗组件的样式配置。
+        *   `popupHelpConfig`, `popupWarnConfig`, `popupExchgConfig`: `object`,其他类型弹窗的配置。
+        *   `popupRuleList`: `array<object|string>`,规则弹窗的内容列表,元素可以是 `{ "type": 1, "data": { "title": "标题", "content": "HTML 内容", "logo": {"src": "...", "width": "..."} } }`,也可以是字符串 "default" 或 "default2" (表示加载预设内容)。
+        *   `popupExchgList`, `popupHelpList`: `array<object>`,其他类型弹窗的内容列表。
+        *   `popupDataList`: `array<object|string>`,通用弹窗内容列表,用于显示自定义弹窗信息,支持 `default` / `default2` 关键字加载预设内容。
+        *   *注意:`configJson` 结构灵活,字段是动态的,取决于后台配置,开发者应做好判空处理。*
+
+*   **1.3 用户个性化配置查询**
+    *   **API 方法**: `API.getUserConfig(ecId, pageName)`
+    *   **参数**: `ecId` (int), `pageName` (string)
+    *   **返回数据**:
+        ```json
+        {
+            "configJson": "{\"tplInfo\": {...}, \"mapInfo\": [...], \"popupRuleList\": [...]}"
+        }
+        ```
+    *   **configJson 字段说明 (示例)**:
+        *   `tplInfo`: `object`,模板信息,如 `{ "tplTypeId": 1, "ssctId": 1 }`。
+        *   `mapInfo`: `array<object>`,地图相关信息,如 `[{"activityList": [{"showName": "路线名称", "point": {"longitude": 117, "latitude": 36}}]}]`。
+        *   `popupRuleList`: `array<object>`,用户专属规则弹窗内容。
+        *   *注意:结构灵活,字段是动态的,取决于后台配置,开发者应做好判空处理。*
+
+#### 2. 活动与报名
+
+*   **2.1 卡片对应活动或赛事详情查询**
+    *   **API 方法**: `API.getCardDetail(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**:
+        ```json
+        {
+            "mcId": 101,
+            "mcName": "赛事名称",
+            "mcType": 1, // 1:普通 2:线下 3:线上
+            "beginSecond": 1700000000,
+            "endSecond": 1700090000,
+            "teamNum": 0, // 0:个人, >0:团队
+            "coiId": 1, // 已报名单位ID
+            "coiName": "已报名单位名称",
+            "ocaId": 201 // 关联活动详情ID (用于跳转)
+        }
+        ```
+
+*   **2.2 用户是否已报名卡片对应赛事查询**
+    *   **API 方法**: `API.getUserJoinStatus(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**:
+        ```json
+        {
+            "isJoin": true // true: 已报名, false: 未报名
+        }
+        ```
+
+*   **2.3 线上赛报名 (重新分组)**
+    *   **API 方法**: `API.signUpOnline(mcId, coiId, selectTeam, nickName)`
+    *   **参数**: `mcId` (int), `coiId` (opt), `selectTeam` (opt), `nickName` (opt)
+    *   **返回数据**: `{}` (空对象表示成功)
+
+*   **2.4 是否允许重新分组(报名)**
+    *   **API 方法**: `API.isAllowMcSignUp(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**:
+        ```json
+        {
+            "allowSignUp": true
+        }
+        ```
+
+*   **2.5 线上赛报名页面信息详情**
+    *   **API 方法**: `API.getOnlineMcSignUpDetail(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**:
+        ```json
+        {
+            "teamList": [ // 可选的团队列表
+                { "teamId": 1, "teamName": "红队" },
+                { "teamId": 2, "teamName": "蓝队" }
+            ],
+            "signupFields": [ // 报名所需字段,示例
+                { "name": "phone", "label": "手机号", "type": "text", "required": true }
+            ]
+        }
+
+
+*   **2.6 卡片对应线上赛多个活动查询 (用户赛事结果详情)**
+    *   **API 方法**: `API.getMatchRsDetail(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**: (Array) - 返回用户在卡片关联的各个赛事中的具体成绩详情
+        ```json
+        [
+            {
+                "mcId": 101,
+                "mcName": "赛事名称",
+                "mcType": 1,
+                "beginSecond": 1700000000,
+                "endSecond": 1700090000,
+                "status": 1, // 状态
+                "nickName": "用户昵称", // 用户在赛事中的昵称
+                "totalNum": 10, // 总场次/次数
+                "totalDistanct": 5000, // 总距离 (米)
+                "totalDistanctRankNum": 5, // 总距离排名
+                "totalCp": 20, // 总打点数
+                "totalCpRankNum": 3, // 总打点数排名
+                "totalSysPoint": 100, // 总积分/百味豆
+                "totalSysPointRankNum": 10, // 总积分排名
+                "fastPace": 300, // 最快配速 (秒/公里)
+                "fastPaceRankNum": 8 // 最快配速排名
+            }
+        }
+        ```
+
+*   **2.7 用户在卡片对应赛事是否新用户**
+    *   **API 方法**: `API.isNewUserInCardComp(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**:
+        ```json
+        {
+            "isNew": true // true: 是新用户, false: 不是
+        }
+        ```
+
+#### 3. 排名与成就
+
+*   **3.1 排名查询**
+    *   **API 方法**: `API.getRankDetail(mcIdListStr, mcType, dispArrStr)`
+    *   **参数**: `mcIdListStr` (string), `mcType` (int), `dispArrStr` (string)
+    *   **返回数据**:
+        ```json
+        {
+            "totalRankRs": [ // 总榜
+                {
+                    "nickName": "张三",
+                    "score": 10000,
+                    "headUrl": "http://...",
+                    "rankNum": 1
+                }
+            ],
+            "teamRankRs": [], // 队伍榜
+              "inTeamRs": []    // 队内榜
+          }
+          ```
+
+*   **3.1.1 月挑战排名查询 (月榜)**
+    *   **API 方法**: `API.getMonthRankDetail({ year, month, dispArrStr })`
+    *   **参数**:
+        *   `year` (int): 年份,如 2025
+        *   `month` (int): 月份,1-12
+        *   `dispArrStr` (string): 固定传 `grad,mapNum`(分别返回个人榜、场地榜)
+    *   **示例**:
+        ```bash
+        curl -X POST -H "Content-Type: application/x-www-form-urlencoded" \
+          -d "year=2025&month=11&dispArrStr=grad,mapNum" \
+          https://colormaprun.com/api/card/MonthRankDetailQuery
+        ```
+    *   **返回数据**:
+        ```json
+        {
+          "gradRs": [ { "userName": "张三", "rankNum": 1, "inRankNum": 264, "isSelf": 0, "isInGame": 0 }, ... ],
+          "mapNumRs": [ { "userName": "Q", "rankNum": 1, "inRankNum": 1, "isSelf": 0, "isInGame": 0 }, ... ]
+        }
+        ```
+        *说明*: 部分环境允许不带 token 访问月榜;若需鉴权同样在 Header 携带 `token`。
+
+*   **3.2 卡片用户当前排名查询**
+    *   **API 方法**: `API.getUserCurrentRank(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**:
+        ```json
+        {
+            "rankNum": 5 // 当前排名,0表示未上榜
+        }
+        ```
+
+*   **3.3 玩家所有月挑战记录查询**
+    *   **API 方法**: `API.getMonthlyChallenge()`
+    *   **参数**: 无
+    *   **返回数据**: (Array)
+        ```json
+        [
+            {
+                "year": "2023",
+                "monthRs": [
+                    {
+                        "month": 10,
+                        "realNum": 5, // 实际完成
+                        "targetNum": 10 // 目标
+                    }
+                ]
+            }
+        ]
+        ```
+
+*   **3.4 玩家活动成就查询**
+    *   **API 方法**: `API.getAchievement()`
+    *   **参数**: 无
+    *   **返回数据**: (Array)
+        ```json
+        [
+            {
+                "year": "2023",
+                "aiRs": [
+                    {
+                        "aiName": "完赛奖牌",
+                        "aiTime": 1700000000,
+                        "iconUrl": "http://..."
+                    }
+                ]
+            }
+        ]
+        ```
+
+*   **3.5 赛事总成绩统计查询**
+    *   **API 方法**: `API.getCompStatistic(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**:
+        ```json
+        {
+            "totalDistance": 123.45, // 总里程
+            "totalPeople": 1000, // 总参与人数
+            "totalRightAnswerNum": 500, // 总正确答题数
+            "totalAnswerNum": 800, // 总答题数
+            "totalCp": 2000, // 总打点数
+            "totalSysPoint": 5000 // 总积分/百味豆
+        }
+        ```
+
+*   **3.6 玩家当前月挑战记录查询**
+    *   **API 方法**: `API.getCurrentMonthlyChallenge(year, month)`
+    *   **参数**: 
+        *   `year` (int): 可选,默认当年
+        *   `month` (int): 可选,默认当月
+    *   **返回数据**:
+        ```json
+        {
+            "month": 11,
+            "realNum": 10, // 实际完成次数
+            "targetNum": 20 // 目标次数
+        }
+        ```
+
+#### 4. 积分与兑换
+
+*   **4.1 卡片内可用积分查询**
+    *   **API 方法**: `API.getScore(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**:
+        ```json
+        {
+            "score": 880,
+            "extTime": 1700000000 // 过期时间
+        }
+        ```
+
+*   **4.2 积分可兑换商品列表查询**
+    *   **API 方法**: `API.getGoodsList(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**: (Array)
+        ```json
+        [
+            {
+                "goodsId": 1,
+                "goodsName": "商品A",
+                "goodsPic": "/static/...", // 需拼接 OSS Url
+                "goodsLeftNum": 99,
+                "corrScore": 100
+            }
+        ]
+        ```
+
+*   **4.3 积分兑换商品**
+    *   **API 方法**: `API.exchangeGoods(ecId, goodsId, exchNum)`
+    *   **参数**: `ecId` (int), `goodsId` (int), `exchNum` (int, 可选, 默认1)
+    *   **返回数据**: `{}` (成功)
+
+*   **4.4 玩家兑换记录查询**
+    *   **API 方法**: `API.getExchangeList(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**: (Array)
+        ```json
+        [
+            {
+                "exchangeId": 123,
+                "goodsName": "商品A",
+                "createTime": 1700000000,
+                "status": 1 // 1:成功
+            }
+        ]
+        ```
+
+*   **4.5 玩家兑换详情查询**
+    *   **API 方法**: `API.getExchangeDetail(ecId, exchangeId)`
+    *   **参数**: `ecId` (int), `exchangeId` (int)
+    *   **返回数据**:
+        ```json
+        {
+            "exchangeId": 1,
+            "goodsName": "商品名称",
+            "createTime": 1700000000,
+            "status": 1,
+            "address": "收货地址",
+            "receiver": "收件人",
+            "phone": "联系电话"
+        }
+        ```
+
+*   **4.6 积分可兑换商品详情**
+    *   **API 方法**: `API.getGoodsDetail(ecId, goodsId)`
+    *   **参数**: `ecId` (int), `goodsId` (int)
+    *   **返回数据**:
+        ```json
+        {
+            "goodsId": 1, 
+            "goodsName": "商品名称", 
+            "goodsPic": "http://...", 
+            "corrScore": 500, // 所需积分
+            "goodsLeftNum": 99, // 剩余库存
+            "goodsUnit": "个", // 单位
+            "goodsDesc": "HTML描述内容", 
+            "exchDesc": "HTML兑换说明", 
+            "exchLimit": 3 // 个人兑换上限
+        }
+        ```
+
+#### 5. 消息与通知
+
+*   **5.1 未读消息列表查询**
+    *   **API 方法**: `API.getUnReadMessages(relationId, relationType)`
+    *   **参数**: `relationId` (int), `relationType` (int)
+    *   **返回数据**: (Array)
+        ```json
+        [
+            {
+                "mqId": 1,
+                "mqType": 1, // 1:成就, 3:系统
+                "mqTitle": "标题",
+                "mqMessage": "内容",
+                "iconUrl": "http://..."
+            }
+        ]
+        ```
+
+*   **5.2 警告列表查询**
+    *   **API 方法**: `API.getWarnMessage(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**: (Array)
+        ```json
+        [
+            {
+                "warnType": 1,
+                "warnTitle": "黄牌警告",
+                "warnMessage": "您违规了...",
+                "iconUrl": "http://..."
+            }
+        ]
+        ```
+
+*   **5.3 标记消息已读**
+    *   **API 方法**: `API.readMessage(mqIdListStr)`
+    *   **参数**: `mqIdListStr` (string) - 消息ID列表字符串,如 "1,2,3"
+    *   **返回数据**: `{}` (成功)
+
+#### 6. 网格游戏
+
+*   **6.1 网格卡片信息查询**
+    *   **API 方法**: `API.getGrids(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**:
+        ```json
+        {
+            "compId": 101,
+            "compName": "网格赛",
+            "widthNum": 3,
+            "heightNum": 3,
+            "maskImgPic": "http://...",
+            "actualImgPic": "http://...",
+            "state": 2, // 1:未开始 2:进行中 3:已结束
+            "detailRs": [
+                {
+                    "orderNum": 1, // 格子序号
+                    "isComplete": 1, // 1:点亮 0:未点亮
+                    "showName": "格子1",
+                    "relationType": 1, // 1:活动详情 2:活动列表 3:地图列表
+                    "ocaId": 201, // 关联活动ID (relationType=1时有效)
+                    "mapId": 0, // 关联地图ID (relationType=2时有效)
+                    "description": "描述信息",
+                    "popupImg": "http://...", // 弹窗图片
+                    "longitude": 117.0,
+                    "latitude": 36.0
+                }
+            ]
+        }
+        ```
+
+#### 7. 电子证书
+
+*   **7.1 查询电子证书样式**
+    *   **API 方法**: `API.getCertStyle(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**:
+        ```json
+        {
+            "styleId": 1,
+            "styleName": "默认样式",
+            "templateUrl": "http://...", // 证书模板图片 URL
+            "elements": [
+                { "type": "text", "field": "userName", "x": 100, "y": 200, "fontSize": 20, "color": "#000" }
+                // 更多元素配置,如位置、字体、颜色
+            ]
+        }
+        ```
+
+*   **7.2 查询电子证书成就对应用户基本信息**
+    *   **API 方法**: `API.getUserBaseInCertificate(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**:
+        ```json
+        {
+            "userName": "用户名称",
+            "activityName": "活动名称",
+            "completionTime": 1700000000 // 完成时间戳
+        }
+        ```
+
+*   **7.3 根据成就信息确认生成电子证书**
+    *   **API 方法**: `API.createCertificate(data)`
+    *   **参数**:
+        *   `data` (object): 包含生成证书所需的所有参数,通常包括 `ecId`, `certStyleId`, `userName`, `activityName`, `completionTime` 等。具体结构请参考后端文档或 `api.js` 源码中的请求示例。
+    *   **返回数据**:
+        ```json
+        {
+            "certUrl": "http://example.com/generated_cert.png" // 生成的证书图片 URL
+        }
+        ```
+
+#### 8. 其他辅助接口
+
+*   **8.1 用户基本信息查询**
+    *   **API 方法**: `API.getUserInfo()`
+    *   **参数**: 无
+    *   **返回数据**:
+        ```json
+        {
+            "nickName": "用户昵称",
+            "headUrl": "http://..."
+        }
+        ```
+*   **8.2 卡片对应地图列表详情查询**
+    *   **API 方法**: `API.getMapList(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**: (Array)
+        ```json
+        [
+            {
+                "mapId": 1,
+                "mapName": "奥体中心",
+                "latitude": 36.666,
+                "longitude": 117.123,
+                "activityList": [
+                    { "ocaId": 101, "showName": "迷你跑" }
+                ]
+            }
+        ]
+        ```
+
+*   **8.3 卡片URI查询**
+    *   **API 方法**: `API.getCardUri(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**: `{}` (待定)
+
+*   **8.4 赛事完赛信息查询**
+    *   **API 方法**: `API.getMatchFinishInfo(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**: `{}` (待定)
+
+*   **8.5 Redis 重建 (管理接口)**
+    *   **API 方法**: `API.redisRebuild(ecId)`
+    *   **参数**: `ecId` (int)
+    *   **返回数据**: `{}` (待定)
+

+ 378 - 0
card/pages/tpl/style3/new/HTML_MIGRATION_STRATEGY.md

@@ -0,0 +1,378 @@
+# 纯 HTML 迁移方案分析报告 (更新版)
+
+## 1. 总体评估
+
+用户希望将基于 UniApp/Vue.js 的现有逻辑(主要涉及 `pages/tpl/style3/index.vue`, `pages/tpl/style3/signup.vue`, `pages/tpl/style3/rankList.vue`, `pages/tpl/style3/rankOverview.vue`)迁移到一套新的、纯 HTML/JS/CSS 的 UI 设计中。新的 UI 文件位于 `pages/tpl/style3/new/` 目录下,包括 `index.html` (入口页), `signup.html` (报名页), `ranklist.html` (排行榜/总览页)。
+
+**核心挑战**:
+1.  **UniApp API 依赖**: 原有业务逻辑文件 (`common/api.js`, `common/tools.js`, `common/cardfunc.js`) 大量使用了 `uni.request`、`uni.showToast` 等 UniApp 特有的 API,这些在标准浏览器环境中不可用。
+2.  **Vue 响应式与指令**: 原有 UI 通过 Vue 的数据绑定、指令(`v-if`, `v-for`, `v-model`)实现动态渲染和交互。纯 HTML/JS 环境需要手动操作 DOM。
+3.  **模块化**: 原有 JS 文件使用 ES Module (`export`, `import`) 语法。在纯 HTML 环境中直接使用需要 `<script type="module">` 并且要解决模块路径和依赖顺序问题。
+
+**结论**:迁移完全可行,但需要构建一个“UniApp 兼容层”来适配旧有逻辑,并采用原生 JavaScript 来实现数据与 UI 的绑定及交互。
+
+## 2. 目录结构规划
+
+为了更好地管理代码,建议在 `pages/tpl/style3/new/` 下创建 `js/` 目录来存放所有 JavaScript 文件。
+
+```text
+pages/tpl/style3/new/
+├── index.html          (新UI入口页)
+├── signup.html         (新UI报名页)
+├── ranklist.html       (新UI排行榜/总览页)
+├── bd.png              (新UI图片资源)
+├── gd.png              (新UI图片资源)
+├── css/                (可选:如果需要将 Tailwind/自定义样式提取到单独文件)
+└── js/
+    ├── uni-compat.js   (新建:核心兼容层,模拟 uni.xyz API)
+    ├── define.js       (移植:常量定义,如 defaultPopUpDataList)
+    ├── api.js          (移植:API 接口地址定义)
+    ├── tools.js        (移植:工具函数)
+    ├── cardfunc.js     (移植:配置加载与处理逻辑)
+    ├── logic-index.js  (新建:针对 index.html 的业务逻辑)
+    ├── logic-signup.js (新建:针对 signup.html 的业务逻辑)
+    └── logic-ranklist.js (新建:针对 ranklist.html 的业务逻辑)
+```
+
+## 3. 详细实施方案
+
+### 3.1 构建 UniApp 兼容层 (`js/uni-compat.js`)
+
+这是一个关键的适配器,用于在标准浏览器环境中模拟 UniApp 的部分 API。
+
+**草稿示例 (`js/uni-compat.js`)**:
+
+```javascript
+// pages/tpl/style3/new/js/uni-compat.js
+(function() {
+    window.uni = window.uni || {}; // 确保全局uni对象存在
+
+    // 1. 模拟网络请求 (核心)
+    uni.request = function(options) {
+        let headers = options.header || {};
+        if (options.method === 'POST' && !headers['Content-Type']) {
+            headers['Content-Type'] = 'application/json'; // 默认JSON
+            // 如果是 x-www-form-urlencoded,需要转换data
+            if (options.header && options.header['Content-Type'] === 'application/x-www-form-urlencoded') {
+                headers['Content-Type'] = 'application/x-www-form-urlencoded';
+                options.body = Object.keys(options.data).map(key => `${key}=${encodeURIComponent(options.data[key])}`).join('&');
+            } else if (options.data && typeof options.data === 'object') {
+                options.body = JSON.stringify(options.data);
+            }
+        } else if (options.method === 'GET' && options.data) {
+            const queryString = Object.keys(options.data).map(key => `${key}=${encodeURIComponent(options.data[key])}`).join('&');
+            options.url = `${options.url}?${queryString}`;
+        }
+        
+        fetch(options.url, {
+            method: options.method || 'GET',
+            headers: headers,
+            body: options.body || undefined
+        })
+        .then(response => response.json())
+        .then(data => {
+            if (options.success) {
+                options.success({ statusCode: 200, data: data }); // 适配 uni.request 的回调格式
+            }
+        })
+        .catch(err => {
+            console.error('uni.request error:', err);
+            if (options.fail) options.fail(err);
+        });
+    };
+
+    // 2. 模拟本地存储
+    uni.setStorageSync = function(key, data) {
+        localStorage.setItem(key, JSON.stringify(data));
+    };
+    uni.getStorageSync = function(key) {
+        const val = localStorage.getItem(key);
+        try { return JSON.parse(val); } catch(e) { return val; }
+    };
+    uni.getStorage = function(obj) { // 模拟异步版本
+        try {
+            const data = uni.getStorageSync(obj.key);
+            if(obj.success) obj.success({ data: data });
+        } catch (e) {
+            if(obj.fail) obj.fail(e);
+        }
+    };
+    uni.setStorage = function(obj) { // 模拟异步版本
+        try {
+            uni.setStorageSync(obj.key, obj.data);
+            if(obj.success) obj.success();
+        } catch (e) {
+            if(obj.fail) obj.fail(e);
+        }
+    };
+
+    // 3. 模拟交互反馈
+    uni.showToast = function(options) {
+        // 可替换为更美观的自定义 Toast 实现
+        alert(`Toast: ${options.title}`); // 简单实现
+        // console.log('Toast:', options.title);
+    };
+    
+    // 4. 模拟系统信息
+    uni.getSystemInfoSync = function() {
+        return { appVersion: '1.0.0' }; // 默认值
+    };
+
+    // 5. 模拟导航
+    uni.navigateTo = function(options) {
+        window.location.href = options.url;
+    };
+
+    // 6. 模拟 getApp() 全局对象 (用于 cardfunc.js)
+    window.getApp = function() {
+        return {
+            globalData: { defaultMatchLogo: '' }, // 需根据实际情况填充
+            $cardconfigType: 'remote' // 'remote' 或 'local',根据需要调整
+        };
+    };
+
+    // 7. 模拟原生AppAction (由 tools.js 调用)
+    window.appAction = function(url, actType = "") {
+        if (url.startsWith('action://')) {
+            console.log(`Simulating native app action: ${url}`);
+            // 在纯HTML中,action://通常无法直接处理。
+            // 可以在这里做一些兼容性处理,比如弹出提示或者跳转到特定H5页面。
+            // 例如,如果 action://to_login/,可以跳到H5登录页
+            if (url.includes('to_login/')) {
+                // window.location.href = '/login.html'; // 假设有H5登录页
+                alert('请登录');
+            } else if (url.includes('to_home/')) {
+                window.location.href = 'index.html'; // 假设首页
+            } else if (url.includes('to_detail/')) {
+                // window.location.href = '/game_detail.html?id=' + url.split('id=')[1].split('&')[0];
+                alert(`进入比赛详情: ${url}`);
+            }
+        } else if (url.startsWith('http') || url.startsWith('/')) {
+            window.location.href = url;
+        } else {
+            console.warn(`Unknown app action URL: ${url}`);
+        }
+    };
+
+})();
+```
+
+### 3.2 移植公共模块 (`js/`)
+
+将原 `common/` 目录下的 JS 文件内容复制到 `pages/tpl/style3/new/js/` 相应文件,并进行改造以适应浏览器环境。
+
+*   **`js/define.js`**:
+    *   移除 `export const`。
+    *   直接定义全局变量或使用 `window.define = {...}` 挂载。
+
+    ```javascript
+    // pages/tpl/style3/new/js/define.js
+    window.tplStyleList = [];
+    tplStyleList[0] = 'blue';
+    // ... 其他 tplStyleList 的定义
+
+    window.teamName = [];
+    teamName[0] = [];
+    // ... 其他 teamName 的定义
+
+    window.defaultPopUpDataList = [...];
+    window.defaultPopUpDataList2 = [...];
+    window.defaultPopUpDataList3 = [...];
+    ```
+
+*   **`js/api.js`**:
+    *   移除 `export const`。
+    *   `process.env.OSS_URL` 和 `process.env.API_BASE_URL` 需硬编码。
+    *   `token` 变量可以设为初始空字符串,或从 URL 参数获取。
+    *   `checkResCode`, `checkToken` 函数可以直接定义在全局。
+
+    ```javascript
+    // pages/tpl/style3/new/js/api.js
+    const API_BASE_URL = 'YOUR_ACTUAL_API_SERVER_URL/'; // !!! 替换为实际的 API 地址
+    const OSS_URL = 'YOUR_ACTUAL_OSS_URL/'; // !!! 替换为实际的 OSS 地址
+
+    window.ossUrl = OSS_URL;
+    window.apiServer = API_BASE_URL;
+
+    window.token = ''; // 初始为空,会从 URL 参数中获取或动态设置
+
+    // 所有 API 接口路径
+    window.apiCardBaseQuery = API_BASE_URL + 'CardBaseQuery';
+    // ... 其他 apiXXXQuery 定义
+
+    // 辅助函数直接定义
+    window.checkResCode = function(res, failLabel='') {
+        // ... 原 common/api.js 中的 checkResCode 逻辑,将 uni.showToast 替换为 window.uni.showToast
+        if (res.data.code == 0) {
+            return true;
+        } else if (res.statusCode == 401) {
+            window.uni.showToast({ title: `您尚未登录`, icon: 'none' });
+            window.appAction(`action://to_login/`);
+            return false;
+        } else {
+            window.uni.showToast({ title: `${failLabel}${res.data.message}`, icon: 'none' });
+            return false;
+        }
+    };
+
+    window.checkToken = function(token) {
+        // ... 原 common/api.js 中的 checkToken 逻辑
+        const regex = /^[0-9A-Za-z]{32}$/;
+        if (regex.test(token)) {
+            return true;
+        } else {
+            window.uni.showToast({ title: `您尚未登录`, icon: 'none' });
+            window.appAction(`action://to_login/`);
+            return false;
+        }
+    };
+    ```
+
+*   **`js/tools.js`**:
+    *   移除 `import tools from '/common/tools';` 和 `export default tools;`。
+    *   将 `tools` 对象直接定义在 `window` 上。
+    *   内部对 `uni` 的调用会通过 `uni-compat.js` 提供的 `window.uni` 对象。
+    *   内部对 `appAction` 的调用需要改为 `window.appAction` (或者确保 `appAction` 在全局)。
+
+    ```javascript
+    // pages/tpl/style3/new/js/tools.js
+    // 假设 window.uni 和 window.appAction 已经存在 (由 uni-compat.js 提供)
+    window.tools = {
+        // ... 原 common/tools.js 中的所有方法
+        // 注意:内部对 uni.xyz 的调用会自动映射到 window.uni.xyz
+        // appAction 方法会调用到 window.appAction (由 uni-compat.js 提供模拟)
+    };
+    ```
+
+*   **`js/cardfunc.js`**:
+    *   移除 `import ...` 语句。
+    *   将 `cardfunc` 对象直接定义在 `window` 上。
+    *   确保 `window.tools`, `window.uni`, `window.checkResCode`, `window.apiXXX` 等变量在加载 `cardfunc.js` 之前已定义。
+
+    ```javascript
+    // pages/tpl/style3/new/js/cardfunc.js
+    // 假设 window.tools, window.uni, window.apiXXX, window.checkResCode, window.defaultPopUpDataList 等都已定义
+    window.cardfunc = {
+        caller: null, // 在纯HTML中,caller的概念可能不再适用,或者需要重新定义
+        token: "",
+        ecId: 0,
+        isNewUser: false,
+        cardConfigData: { /* ... */ },
+        userConfigData: { /* ... */ },
+
+        init(token, ecId) { // 调整 init 参数,去除 caller
+            this.token = token;
+            this.ecId = ecId;
+            // this.removeCss(); // 如果纯HTML没有uni.css,则可能不需要
+        },
+        // ... 其他 cardfunc 中的所有方法
+        // 内部对 uni.request 会使用 window.uni.request
+        // 内部对 tools.xxx 会使用 window.tools.xxx
+        // 内部对 apiXXX 会使用 window.apiXXX
+        // 内部对 checkResCode 会使用 window.checkResCode
+        // 内部对 getApp().xxx 会使用 window.getApp().xxx
+    };
+    ```
+
+### 3.3 针对每个页面的业务逻辑 (`js/logic-*.js`)
+
+每个 HTML 页面将有一个专属的 `logic-*.js` 文件来处理其特定的业务逻辑。
+
+#### 3.3.1 `pages/tpl/style3/new/js/logic-index.js` (对应 `index.html`)
+
+**逻辑来源**: `pages/tpl/style3/index.vue`
+
+1.  **全局状态**: 定义页面所需变量 (`token`, `ecId`, `beginSecond`, `endSecond`, `mcState`, `isJoin` 等)。
+2.  **`initPage()` 函数**: 页面加载时执行。
+    *   **解析 URL 参数**: 从 `window.location.search` 获取 `token`, `id` (即 `ecId`)。
+    *   **初始化 `cardfunc`**: `window.cardfunc.init(state.token, state.ecId);`
+    *   **加载配置**: `window.cardfunc.getCardConfig(loadConfigCallback);`
+    *   **数据请求**:
+        *   调用 `apiCardBaseQuery` 获取赛事基本信息(`beginSecond`, `endSecond`)。
+        *   调用 `apiUserJoinCardQuery` 获取用户报名状态 (`isJoin`)。
+    *   **启动倒计时/更新 UI**: 根据 `beginSecond`, `endSecond`, `isJoin` 更新 `action-btn` 的文本、样式,以及倒计时显示。
+    *   **绑定事件**: 为 `action-btn` 绑定点击事件。
+3.  **`updateUI()` 函数**: 根据 `mcState` 和 `isJoin` 动态修改 `action-btn` 和 `timer-container` 的内容和样式。
+4.  **`btnClick()` 事件处理**:
+    *   根据 `mcState` 和 `isJoin` 决定跳转到 `signup.html` 或 `ranklist.html`。
+    *   使用 `window.uni.navigateTo({ url: '...' });` 或 `window.appAction('...');`。
+
+#### 3.3.2 `pages/tpl/style3/new/js/logic-signup.js` (对应 `signup.html`)
+
+**逻辑来源**: `pages/tpl/style3/signup.vue`
+
+1.  **全局状态**: `token`, `ecId`, `mcName`, `beginSecond`, `endSecond`, `coiRs` (组织列表), `nickName`, `coiId` 等。
+2.  **`initPage()` 函数**:
+    *   **解析 URL 参数**: `token`, `id`, `from`。
+    *   **初始化 `cardfunc`**: `window.cardfunc.init(state.token, state.ecId);`
+    *   **加载配置**: `window.cardfunc.getCardConfig(loadConfigCallback);`
+    *   **数据请求**:
+        *   `apiCardDetailQuery` 获取赛事详情和用户已填信息。
+        *   `apiOnlineMcSignUpDetail` 获取组织列表 (`coiRs`),填充到“选择战队”的下拉菜单。
+    *   **更新 UI**: 填充“昵称”输入框,更新“比赛时间”显示,动态生成下拉菜单选项。
+    *   **绑定事件**: 为“立即报名”按钮、下拉菜单、返回按钮、说明按钮等绑定事件。
+3.  **`btnSignup()` 函数**: 验证输入,弹出确认框,然后调用 `apiOnlineMcSignUp`。
+4.  **`selectOption()` 函数**: 更新下拉菜单选中项的显示和隐藏 `coiId` 值。
+5.  **导航**: 报名成功后跳转到 `ranklist.html`。
+
+#### 3.3.3 `pages/tpl/style3/new/js/logic-ranklist.js` (对应 `ranklist.html`)
+
+**逻辑来源**: `pages/tpl/style3/rankList.vue` 和 `pages/tpl/style3/rankOverview.vue`
+
+1.  **全局状态**: `token`, `ecId`, `mcId`, `mcName`, `currentTab`, `currentMetric`, `rankList` (所有榜单数据), `userInfo` (个人信息), `mapList` (地图列表), `ocaId` (当前地图ID) 等。
+2.  **`initPage()` 函数**:
+    *   **解析 URL 参数**: `token`, `id`。
+    *   **初始化 `cardfunc`**: `window.cardfunc.init(state.token, state.ecId);`
+    *   **加载配置**: `window.cardfunc.getCardConfig(loadConfigCallback);`
+    *   **数据请求**:
+        *   `apiMatchRsDetailQuery` 获取赛事和个人概览数据。
+        *   `apiCompStatisticQuery` 获取全局统计数据(用于跑马灯)。
+        *   `apiMapListQuery` 获取地图列表。
+        *   `apiCardRankDetailQuery` 获取榜单数据。
+    *   **更新 UI**: 填充个人信息卡片、跑马灯、默认榜单列表。
+    *   **绑定事件**: Tabs 切换、维度切换、底部“进入比赛”按钮、编辑资料、说明模态框等。
+3.  **`renderLeaderboard()` 函数**: 负责根据 `currentTab`, `currentMetric` 和 `rankList` 数据渲染排行榜列表。
+4.  **`switchMainTab()`, `switchMetric()` 函数**: 切换 Tab 时更新 `state.currentTab`/`state.currentMetric`,然后调用 `renderLeaderboard()`。
+5.  **`openDrawer()`, `closeDrawer()` 函数**: 控制底部抽屉的显示与隐藏。
+6.  **`editProfile()`, `saveProfile()` 函数**: 弹出/关闭编辑资料模态框,保存时调用 `apiOnlineMcSignUp` 更新。
+7.  **导航**: 底部“进入比赛”按钮跳转到 `action://to_detail` 或其他 H5 页面。返回按钮跳转到 `index.html`。
+
+### 3.4 HTML 文件集成
+
+每个 HTML 文件的底部需要引入其所需的 JS 文件,注意顺序:
+
+```html
+<!-- 在所有 HTML 文件中 (例如 index.html, signup.html, ranklist.html) -->
+<!-- 1. Tailwind CSS 和 FontAwesome (已存在) -->
+<!-- 2. 兼容层 -->
+<script src="js/uni-compat.js"></script>
+<!-- 3. 基础定义 -->
+<script src="js/define.js"></script>
+<script src="js/api.js"></script>
+<script src="js/tools.js"></script>
+<!-- 4. 业务模块 -->
+<script src="js/cardfunc.js"></script>
+<!-- 5. 页面特定逻辑 (每个页面引入自己的) -->
+<!-- index.html --> <script src="js/logic-index.js"></script>
+<!-- signup.html --> <script src="js/logic-signup.js"></script>
+<!-- ranklist.html --> <script src="js/logic-ranklist.js"></script>
+
+<script>
+    // 在每个页面的 script 标签底部,调用各自的初始化函数
+    // 例如对于 index.html:
+    document.addEventListener('DOMContentLoaded', function() {
+        initIndexPage(); // logic-index.js 中定义的入口函数
+    });
+</script>
+```
+
+## 4. 潜在风险与注意事项
+
+1.  **样式冲突与 Tailwind**: 新 UI 使用 Tailwind CSS。如果未来与 UniApp 项目的其他部分集成,可能存在样式冲突。建议将新 UI 的 Tailwind 类转换为静态 CSS 文件,或使用 Tailwind 的 `@apply` 规则来集成。
+2.  **API 域名**: `api.js` 中的 `API_BASE_URL` 和 `OSS_URL` 必须替换为真实的服务器地址。
+3.  **跨域问题 (CORS)**: 如果这些 HTML 文件部署在与 API 服务器不同的域,浏览器会遇到 CORS 问题。需要确保服务器端已正确配置 CORS 头部。
+4.  **图片资源路径**: HTML 中引用的图片资源 (`src="https://orienteering.beswell.com/card/nanning/..."`, `./gd.png`, `./bd.png`) 在部署时需要确保路径正确。
+5.  **原生 App 交互 (`action://`)**: `uni-compat.js` 中的 `window.appAction` 只是一个模拟。在实际的混合应用 (Hybrid App) 中,需要由原生 App 拦截并处理 `action://` 协议的 URL。
+6.  **`cardfunc.init` 的 `caller` 参数**: 在 Vue 组件中 `this` 就是 `caller`。在纯 HTML 中,`caller` 的概念不再直接适用。`cardfunc.init(caller, token, ecId)` 可以简化为 `cardfunc.init(token, ecId)`,或者 `caller` 可以是当前页面的一个全局对象或空对象。
+
+这个方案提供了将现有 UniApp/Vue 逻辑迁移到纯 HTML/JS 环境的详细路线图,并考虑了 UniApp 特有 API 的兼容性问题。

+ 93 - 0
card/pages/tpl/style3/new/PROJECT_INSIGHTS.md

@@ -0,0 +1,93 @@
+# ColorMapRun H5 Card 项目开发备忘录 (Project Insights)
+
+这份文档汇总了 AI 助手 (Gemini) 在协助开发过程中对项目的理解、架构分析以及关键技术细节。旨在帮助开发者快速上手或在不同环境间同步上下文。
+
+## 1. 项目概况
+
+*   **项目名称**: ColorMapRun Mobile H5 Card
+*   **核心功能**: 为 ColorMapRun App 提供嵌入式的 H5 卡片页面,用于展示活动详情、排行榜、报名、游戏互动等功能。
+*   **技术栈**:
+    *   **前端框架**: Vue.js (UniApp 风格,但在 SDK 模式下使用了原生 HTML/JS/CSS)。
+    *   **构建环境**: 似乎是基于 UniApp 的工程结构,但在 `sdk/` 目录下维护了一套独立的、无框架依赖的原生 H5 页面,用于嵌入 App。
+    *   **通信协议**: 自定义 JSBridge (`bridge.js`) 与 Native App (Flutter) 交互。
+    *   **样式**: CSS3, Flexbox, FontAwesome (部分页面)。
+
+## 2. 核心目录结构
+
+*   **`sdk/`**: **(核心关注点)** 包含可以直接在浏览器或 WebView 中运行的原生 HTML 页面。
+    *   `detail.html`: 核心页面之一,用于展示月度挑战赛详情、排行榜等。
+    *   `index.html`: 入口卡片页。
+    *   `api.js`: 封装了所有后端 API 请求,包含 Mock 数据机制。
+    *   `bridge.js`: 封装了与 App 的通信逻辑 (JSBridge)。
+    *   `API_SERVER.md`: 详细的后端接口文档(由 AI 维护更新)。
+*   **`pages/`**: UniApp 的页面源码目录,包含各种业务模块的 Vue 组件。
+    *   `tpl/`: 通用模板 (style1, style2, style3...),包含活动首页、排行榜、报名页。
+    *   `mytz/`: 每月挑战 (Monthly Challenge) 模块。
+    *   `game/`: 游戏模块 (如 Grid 网格拼图)。
+    *   `bm/`: 报名 (BaoMing) 相关模块。
+    *   `jbs/`: 锦标赛 (JinBiaoSai) 模块。
+*   **`common/`**: 公共工具库,如 `tools.js` (工具函数), `api.js` (UniApp 版 API 封装)。
+
+## 3. API 交互机制 (`sdk/api.js`)
+
+*   **封装方式**: 所有接口挂载在全局 `window.API` 对象上。
+*   **Mock 模式**:
+    *   通过 URL 参数 `?env=mock` 开启。
+    *   开启后,`API.request` 会拦截请求并返回 `api.js` 内部定义的 `MOCK_DB` 数据。
+    *   日志输出会详细打印 `[API-Mock] Request` 和 `[API-Mock] Response`。
+*   **正式模式**:
+    *   默认请求 `https://colormaprun.com/api/card/` (Base URL)。
+    *   请求头包含 `Content-Type: application/x-www-form-urlencoded` 和 `token`。
+    *   自动处理 `401 Unauthorized`,调用 `Bridge.toLogin()` 跳转登录。
+*   **日志管理**:
+    *   引入了 `Logger` 工具。
+    *   仅在 `env=mock` 时输出 `Logger.log` 和 `Logger.warn`,生产环境静默(`Logger.error` 除外)。
+
+## 4. Bridge 交互机制 (`sdk/bridge.js`)
+
+*   **通信方式**:
+    1.  **优先**: `window.uni.postMessage` (UniApp WebView 标准)。
+    2.  **降级**: URL 拦截协议 (如 `action://to_login/`) 或注入对象 (如 `window.share_wx`)。
+*   **主要功能**:
+    *   页面跳转: `toHome`, `toLogin`, `back`, `appAction` (通用跳转)。
+    *   功能调用: `openMap`, `openMatch`, `shareWx`, `makePhoneCall` 等。
+*   **日志管理**: 同样集成了 `Logger` 工具,生产环境隐藏敏感信息(如 Token)。
+
+## 5. 关键业务模块与逻辑
+
+### 5.1 月度挑战赛 (`sdk/detail.html`)
+*   **功能**: 展示用户当月的挑战进度(仪表盘)、积分/场地排行榜(领奖台 + 列表)。
+*   **逻辑**:
+    *   优先加载当前月份数据。
+    *   如果当前月份无数据,会自动向前回溯 6 个月查找有数据的月份。
+    *   **未登录处理**: 新增了 `renderGuestState`,未登录用户显示随机头像和“去登录”提示。
+    *   **UI 调整**: 针对移动端进行了适配,Header 区域紧凑化以腾出更多列表空间。
+
+### 5.2 游戏/网格挑战 (`pages/game/grid`)
+*   **功能**: 九宫格拼图游戏,点亮格子解锁奖励。
+*   **数据结构**: 依赖 `getGrids` 接口,返回 `detailRs` 包含格子的状态、关联活动 (`ocaId`) 或地图 (`mapId`)。
+
+### 5.3 动态配置 (`CardConfigQuery`)
+*   后端通过 `getCardConfig` 接口返回 `configJson` 字符串。
+*   前端解析 JSON 后,动态应用:
+    *   **CSS**: 注入自定义样式。
+    *   **弹窗**: 配置规则弹窗、消息弹窗的内容。
+    *   **UI 元素**: 如 `tabActiveColor`, `teamType` 等。
+
+## 6. 待办与注意事项 (TODOs)
+
+1.  **API 返回结构待定**:
+    *   `getCardUri`
+    *   `getMatchFinishInfo`
+    *   `redisRebuild`
+    *   以上三个接口已在 `api.js` 和文档中占位,但 Mock 数据目前为空对象 `{}`,需根据后端实际返回进行更新。
+2.  **生产环境部署**:
+    *   上线前请确保 `sdk/detail.html` 等文件中的 `env=mock` 参数已移除或由 App 端正确控制。
+    *   建议服务器开启 Gzip 压缩以优化加载速度。
+3.  **代码同步**:
+    *   `sdk/api.js` (原生版) 和 `common/api.js` (UniApp 版) 维护了两套类似的接口定义,修改时需注意同步(目前主要维护了 `sdk/api.js`)。
+
+## 7. 常用调试指令
+
+*   **开启 Mock**: 在 URL 后追加 `?env=mock`。
+*   **查看完整日志**: 开启 Mock 模式后,控制台会输出详细的 Bridge 调用和 API 请求日志。

+ 375 - 0
card/pages/tpl/style3/new/api.js

@@ -0,0 +1,375 @@
+(function (window) {
+    'use strict';
+
+    // Logger Utility
+    const Logger = {
+        _isDev: false,
+
+        init: function(isDev) {
+            this._isDev = isDev;
+        },
+
+        log: function() {
+            if (this._isDev) {
+                console.log.apply(console, arguments);
+            }
+        },
+
+        warn: function() {
+            if (this._isDev) {
+                console.warn.apply(console, arguments);
+            }
+        },
+
+        error: function() {
+            console.error.apply(console, arguments); // Always log errors
+        }
+    };
+
+    // Determine _isDev status from main window's URL query params
+    function getQueryParam(name) {
+        const params = new URLSearchParams(window.location.search);
+        return params.get(name);
+    }
+    const env = (getQueryParam('env') || '').toLowerCase();
+    Logger.init(env === 'mock'); // Initialize Logger
+
+    /**
+     * ColorMapRun API SDK (Full Version with Mock)
+     * 封装了与后端服务器的所有交互
+     * 依赖: bridge.js (用于 401 跳转登录)
+     */
+
+    // 基础配置
+    var Config = {
+        baseUrl: 'https://colormaprun.com/api/card/', 
+        ossUrl: 'http://oss-card.colormaprun.com/card/',
+        token: '',
+        useMock: false 
+    };
+
+    // 模拟数据定义 (涵盖所有接口)
+    var MOCK_DB = {
+        'CardBaseQuery': {
+            ecName: '[Mock]跑向大明湖卡片',
+            ecDesc: '欢迎参加彩图奔跑活动!',
+            beginSecond: Date.now() / 1000 - 86400 * 5,
+            endSecond: Date.now() / 1000 + 86400 * 10,
+            secondCardName: '地图导航'
+        },
+        'CardDetailQuery': {
+            mcId: 101,
+            mcName: '[Mock]线上马拉松',
+            mcType: 1,
+            beginSecond: Date.now() / 1000 - 86400 * 2,
+            endSecond: Date.now() / 1000 + 86400 * 5,
+            teamNum: 0,
+            coiId: 1, // Mocked coiId
+            coiName: '个人组',
+            ocaId: 201 // Mocked ocaId
+        },
+        'MatchRsDetailQuery': [
+            { id: 1, name: '[Mock]活动1', status: 1 },
+            { id: 2, name: '[Mock]活动2', status: 0 }
+        ],
+        'CardRankDetailQuery': {
+            totalRankRs: [
+                { nickName: 'Mock张三', score: 10000, headUrl: 'https://picsum.photos/40/40?random=1', rankNum: 1 },
+                { nickName: 'Mock李四', score: 9500, headUrl: 'https://picsum.photos/40/40?random=2', rankNum: 2 },
+                { nickName: 'Mock王五', score: 8800, headUrl: 'https://picsum.photos/40/40?random=3', rankNum: 3 },
+                { nickName: 'Mock赵六', score: 7200, headUrl: 'https://picsum.photos/40/40?random=4', rankNum: 4 },
+                { nickName: 'Mock小明', score: 6500, headUrl: 'https://picsum.photos/40/40?random=5', rankNum: 5 }
+            ],
+            teamRankRs: [],
+            inTeamRs: [],
+            otherRs: []
+        },
+        'UserCurrentRankNumQuery': { rankNum: 5 },
+        'UserJoinCardQuery': { isJoin: false }, // 默认未报名
+        'IsNewUserInCardComp': { isNew: true },
+        'OnlineMcSignUpDetail': {
+            teamList: [
+                { teamId: 1, teamName: '[Mock]个人组' },
+                { teamId: 2, teamName: '[Mock]团队组' }
+            ],
+            signupFields: [
+                { name: 'realName', label: '真实姓名', type: 'text', required: true, value: '' },
+                { name: 'phone', label: '手机号码', type: 'tel', required: true, value: '' }
+            ]
+        },
+        'OnlineMcSignUp': {}, // 报名成功返回空 data
+        'IsAllowMcSignUp': { allowSignUp: true },
+        'CurrentMonthlyChallengeQuery': {
+            year: '2023',
+            monthRs: [{ month: 11, realNum: 10, targetNum: 20 }]
+        },
+        'CardConfigQuery': {
+            configJson: JSON.stringify({
+                css: ".custom-header { background-color: #f0f8ff; }",
+                tabActiveColor: "#007bff",
+                teamType: 0, // Mocked teamType
+                popupRuleConfig: { height: "60%", theme: "light" },
+                popupMessageConfig: {}, // Mocked popupMessageConfig
+                popupRuleList: [
+                    { type: 1, data: { title: '规则1', content: '这是<b>Mock</b>的活动规则内容。' } },
+                    { type: 1, data: { title: '规则2', content: '第二条规则。', logo: { src: 'https://picsum.photos/100/50', width: '100px', height: '50px' } } }
+                ],
+                popupDataList: [ // Mocked popupDataList
+                    { type: 1, data: { title: '通用弹窗1', content: '通用弹窗内容1。' } }
+                ]
+            })
+        },
+        'UserConfigQuery': {
+            configJson: JSON.stringify({
+                tplInfo: { tplTypeId: 1, ssctId: 1 },
+                mapInfo: [
+                    { activityList: [{ showName: '迷你路线', pathImg: '', ocaId: 1, matchType: 1, point: { longitude: 117.0, latitude: 36.6, name: '起点' } }] }
+                ],
+                popupRuleList: [{ type: 1, data: { title: '用户个性化', content: '这是用户专属内容' } }]
+            })
+        },
+        'MonthlyChallengeQuery': [
+            { year: '2023', monthRs: [{ month: 10, realNum: 15, targetNum: 20 }] },
+            { year: '2022', monthRs: [{ month: 12, realNum: 25, targetNum: 20 }] }
+        ],
+        'MonthRankDetailQuery': [
+            { nickName: '月榜冠军', score: 500, headUrl: 'https://picsum.photos/40/40?random=6' }
+        ],
+        'AchievementQuery': [
+            {
+                year: '2023',
+                aiRs: [
+                    { aiName: '初次登场', aiTime: Date.now() / 1000 - 86400 * 30, iconUrl: 'https://picsum.photos/60/60?random=7' },
+                    { aiName: '跑步达人', aiTime: Date.now() / 1000 - 86400 * 10, iconUrl: 'https://picsum.photos/60/60?random=8' }
+                ]
+            }
+        ],
+        'ExchangeListQuery': [
+            { exchangeId: 1, goodsName: '[Mock]运动手环', createTime: Date.now() / 1000 - 86400 * 7, status: 1 },
+            { exchangeId: 2, goodsName: '[Mock]定制水杯', createTime: Date.now() / 1000 - 86400 * 15, status: 0 }
+        ],
+        'ExchangeDetailQuery': {
+            exchangeId: 1, goodsName: '[Mock]运动手环', createTime: Date.now() / 1000 - 86400 * 7, status: 1,
+            address: 'Mock地址', receiver: 'Mock收件人', phone: '138****8888'
+        },
+        'UnReadMessageQuery': [
+            { mqId: 1, mqType: 1, mqTitle: '[Mock]新成就', mqMessage: '恭喜您解锁新成就!', iconUrl: 'https://picsum.photos/50/50?random=9' }
+        ],
+        'ReadMessage': {},
+        'MapListQuery': [
+            { mapId: 1, mapName: '[Mock]公园地图', latitude: 36.6, longitude: 117.0, activityList: [] }
+        ],
+        'CompStatisticQuery': { totalDistance: 123.45, totalPeople: 1000 },
+        'WarnMessageQuery': [
+            { warnType: 1, warnTitle: '[Mock]黄牌警告', warnMessage: '您的成绩异常', iconUrl: 'https://picsum.photos/50/50?random=10' }
+        ],
+        'CertStyleQuery': {
+            styleId: 1, styleName: '简约风', templateUrl: 'https://picsum.photos/600/400?random=11',
+            elements: [
+                { type: 'text', field: 'userName', x: 100, y: 150, fontSize: 24, color: '#333' }
+            ]
+        },
+        'UserBaseQueryInCertificate': { userName: '[Mock]证书用户', activityName: '[Mock]活动名', completionTime: Date.now() / 1000 },
+        'CertificateCreateByUserAi': { certUrl: 'https://picsum.photos/600/400?random=12' },
+        'OnlineScoreQuery': { score: 880, extTime: Date.now() / 1000 + 86400 * 30 },
+        'CanExchangeGoodsList': [
+            { goodsId: 1, goodsName: '[Mock]运动手环', goodsPic: 'https://picsum.photos/100/100?random=13', goodsLeftNum: 50, corrScore: 500 },
+            { goodsId: 2, goodsName: '[Mock]定制水杯', goodsPic: 'https://picsum.photos/100/100?random=14', goodsLeftNum: 0, corrScore: 300 }
+        ],
+        'CanExchangeGoodsDetail': {
+            goodsId: 1, goodsName: '[Mock]运动手环', goodsPic: 'https://picsum.photos/200/200?random=15', corrScore: 500,
+            desc: '这是<b>Mock</b>的运动手环详情,功能强大,是您运动的好伙伴!'
+        },
+        'ScoreExchangeGoods': {},
+        'UserBasicInformationQuery': { nickName: 'Mock用户', headUrl: 'https://picsum.photos/50/50?random=16' },
+        'GridsQuery': {
+            compId: 201, compName: '[Mock]网格挑战', widthNum: 3, heightNum: 3,
+            maskImgPic: 'https://picsum.photos/300/300?random=17',
+            actualImgPic: 'https://picsum.photos/300/300?random=18',
+            state: 2, // 1:未开始 2:进行中 3:已结束
+            detailRs: [
+                { orderNum: 1, isComplete: 1, showName: '完成1', relationType: 1, ocaId: 10, longitude: 117.1, latitude: 36.6, popupImg: 'https://picsum.photos/100/100?random=19' },
+                { orderNum: 2, isComplete: 0, showName: '未完2', relationType: 1, ocaId: 11, longitude: 117.2, latitude: 36.7, popupImg: 'https://picsum.photos/100/100?random=20' }
+            ]
+        },
+        'CardUriQuery': {}, // 暂无 Mock 结构
+        'MatchFinishInfoQuery': {}, // 暂无 Mock 结构
+        'RedisRebuild': {} // 暂无 Mock 结构
+    };
+
+    var API = {
+        init: function(options) {
+            if (options.baseUrl) Config.baseUrl = options.baseUrl;
+            if (options.ossUrl) Config.ossUrl = options.ossUrl;
+            if (options.token) Config.token = options.token;
+            if (typeof options.useMock === 'boolean') Config.useMock = options.useMock;
+            if (Config.useMock) Logger.warn('%c [API] Mock 模式已开启 ', 'background: orange; color: white;');
+        },
+
+        getOssUrl: function() {
+            return Config.ossUrl;
+        },
+
+        setToken: function(token) {
+            Config.token = token;
+        },
+
+        request: function(endpoint, data) {
+            if (Config.useMock) {
+                return new Promise(function(resolve, reject) {
+                    Logger.log('[API-Mock] Request:', endpoint, data);
+                    setTimeout(function() {
+                        var mockData = MOCK_DB[endpoint];
+                        if (endpoint === 'OnlineMcSignUp') MOCK_DB['UserJoinCardQuery'].isJoin = true;
+                        if (endpoint === 'ScoreExchangeGoods') MOCK_DB['OnlineScoreQuery'].score -= 100;
+                        
+                        Logger.log('[API-Mock] Response:', endpoint, mockData || {});
+                        resolve(mockData || {});
+                    }, 300); 
+                });
+            }
+
+            var url = Config.baseUrl + endpoint;
+            var headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'token': Config.token };
+            var formData = new URLSearchParams();
+            for (var key in data) { if (data.hasOwnProperty(key)) formData.append(key, data[key]); }
+
+            Logger.log('[API] Request:', endpoint, data);
+
+            return fetch(url, { method: 'POST', headers: headers, body: formData, mode: 'cors', credentials: 'omit' })
+            .then(function(response) { return response.json(); })
+            .then(function(res) {
+                Logger.log('[API] Response:', endpoint, res);
+                if (res.code === 0) return res.data;
+                if (res.code === 401 || res.statusCode === 401) {
+                    Logger.warn('[API] Token invalid');
+                    if (window.Bridge && window.Bridge._post) window.Bridge._post('toLogin');
+                    else alert('登录已过期');
+                    throw new Error('Unauthorized');
+                }
+                var msg = res.message || '请求失败';
+                if (window.Bridge && window.Bridge.showToast) {
+                    window.Bridge.showToast(msg, 'none');
+                } else {
+                    alert(msg);
+                }
+                throw new Error(msg);
+            })
+            .catch(function(err) { Logger.error('[API] Error:', err); throw err; });
+        },
+
+        // ==============================
+        // 完整业务接口封装 (按原始 api.js 顺序)
+        // ==============================
+
+        // 1. 卡片基本信息查询
+        getCardBase: function(ecId, pageName) { return this.request('CardBaseQuery', { ecId: ecId, pageName: pageName }); },
+
+        // 2. 卡片对应活动或赛事详情查询
+        getCardDetail: function(ecId) { return this.request('CardDetailQuery', { ecId: ecId }); },
+
+        // 3. 卡片对应线上赛多个活动查询
+        getMatchRsDetail: function(ecId) { return this.request('MatchRsDetailQuery', { ecId: ecId }); },
+
+        // 4. 排名查询
+        getRankDetail: function(mcIdListStr, mcType, dispArrStr) { return this.request('CardRankDetailQuery', { mcIdListStr: mcIdListStr, mcType: mcType, dispArrStr: dispArrStr }); },
+
+        // 5. 卡片用户当前排名查询
+        getUserCurrentRank: function(ecId) { return this.request('UserCurrentRankNumQuery', { ecId: ecId }); },
+
+        // 6. 用户是否已经报名卡片对应赛事查询
+        getUserJoinStatus: function(ecId) { return this.request('UserJoinCardQuery', { ecId: ecId }); },
+
+        // 7. 用户在卡片对应赛事是否新用户
+        isNewUserInCardComp: function(ecId) { return this.request('IsNewUserInCardComp', { ecId: ecId }); },
+
+        // 8. 线上赛报名页面信息详情
+        getOnlineMcSignUpDetail: function(ecId) { return this.request('OnlineMcSignUpDetail', { ecId: ecId }); },
+
+        // 9. 线上赛报名(重新分组)
+        signUpOnline: function(mcId, coiId, selectTeam, nickName) { 
+            return this.request('OnlineMcSignUp', { mcId: mcId, coiId: coiId, selectTeam: selectTeam, nickName: nickName }); 
+        },
+
+        // 10. 是否允许重新分组(报名)
+        isAllowMcSignUp: function(ecId) { return this.request('IsAllowMcSignUp', { ecId: ecId }); },
+
+        // 11. 玩家当前月挑战记录查询
+        getCurrentMonthlyChallenge: function() { return this.request('CurrentMonthlyChallengeQuery', {}); },
+
+        // 12. 卡片配置信息查询
+        getCardConfig: function(ecId, pageName) { return this.request('CardConfigQuery', { ecId: ecId, pageName: pageName }); },
+
+        // 13. 用户自定义配置信息查询
+        getUserConfig: function(ecId, pageName) { return this.request('UserConfigQuery', { ecId: ecId, pageName: pageName }); },
+
+        // 14. 玩家所有月挑战记录查询
+        getMonthlyChallenge: function() { return this.request('MonthlyChallengeQuery', {}); },
+
+        // 15. 月挑战排名查询
+        getMonthRankDetail: function() { return this.request('MonthRankDetailQuery', {}); },
+
+        // 16. 玩家活动成就查询
+        getAchievement: function() { return this.request('AchievementQuery', {}); },
+
+        // 17. 玩家兑换记录查询
+        getExchangeList: function(ecId) { return this.request('ExchangeListQuery', { ecId: ecId }); },
+
+        // 18. 玩家兑换详情查询
+        getExchangeDetail: function(ecId, exchangeId) { return this.request('ExchangeDetailQuery', { ecId: ecId, exchangeId: exchangeId }); },
+
+        // 19. 未读消息列表查询
+        getUnReadMessages: function(relationId, relationType) { return this.request('UnReadMessageQuery', { relationId: relationId, relationType: relationType || 2 }); },
+
+        // 20. 标记消息已读
+        readMessage: function(mqIdListStr) { return this.request('ReadMessage', { mqIdListStr: mqIdListStr }); },
+
+        // 21. 卡片对应地图列表详情查询
+        getMapList: function(ecId) { return this.request('MapListQuery', { ecId: ecId }); },
+
+        // 22. 赛事总成绩统计查询
+        getCompStatistic: function(ecId) { return this.request('CompStatisticQuery', { ecId: ecId }); },
+
+        // 23. 警告列表查询
+        getWarnMessage: function(ecId) { return this.request('WarnMessageQuery', { ecId: ecId }); },
+
+        // 24. 查询电子证书样式
+        getCertStyle: function(ecId) { return this.request('CertStyleQuery', { ecId: ecId }); },
+
+        // 25. 查询电子证书成就对应用户基本信息
+        getUserBaseInCertificate: function(ecId) { return this.request('UserBaseQueryInCertificate', { ecId: ecId }); },
+
+        // 26. 根据成就信息确认生成电子证书
+        createCertificate: function(data) { return this.request('CertificateCreateByUserAi', data); },
+
+        // 27. 卡片内可用积分查询
+        getScore: function(ecId) { return this.request('OnlineScoreQuery', { ecId: ecId }); },
+
+        // 28. 积分可兑换商品列表查询
+        getGoodsList: function(ecId) { return this.request('CanExchangeGoodsList', { ecId: ecId }); },
+
+        // 29. 积分可兑换商品详情
+        getGoodsDetail: function(goodsId) { return this.request('CanExchangeGoodsDetail', { goodsId: goodsId }); },
+
+        // 30. 积分兑换商品
+        exchangeGoods: function(ecId, goodsId) { return this.request('ScoreExchangeGoods', { ecId: ecId, goodsId: goodsId }); },
+
+        // 31. 用户基本信息查询
+        getUserInfo: function() { return this.request('UserBasicInformationQuery', {}); },
+
+        // 32. 网格卡片信息查询
+        getGrids: function(ecId) { return this.request('GridsQuery', { ecId: ecId }); },
+
+        // 33. 卡片URI查询
+        getCardUri: function(ecId) { return this.request('CardUriQuery', { ecId: ecId }); },
+
+        // 34. 赛事完赛信息查询
+        getMatchFinishInfo: function(ecId) { return this.request('MatchFinishInfoQuery', { ecId: ecId }); },
+
+        // 35. Redis 重建 (管理接口)
+        redisRebuild: function(ecId) { return this.request('RedisRebuild', { ecId: ecId }); }
+    };
+
+    window.API = API;
+
+})(window);

二進制
card/pages/tpl/style3/new/bd.png


+ 360 - 0
card/pages/tpl/style3/new/bridge.js

@@ -0,0 +1,360 @@
+(function (window) {
+  'use strict';
+
+  // Logger Utility
+  const Logger = {
+      _isDev: false,
+
+      init: function(isDev) {
+          this._isDev = isDev;
+      },
+
+      log: function() {
+          if (this._isDev) {
+              console.log.apply(console, arguments);
+          }
+      },
+
+      warn: function() {
+          if (this._isDev) {
+              console.warn.apply(console, arguments);
+          }
+      },
+
+      error: function() {
+          console.error.apply(console, arguments); // Always log errors
+      }
+  };
+
+  // Determine _isDev status from main window's URL query params
+  // This function is defined here to be self-contained for bridge.js
+  function getQueryParam(name) {
+      const params = new URLSearchParams(window.location.search);
+      return params.get(name);
+  }
+  const env = (getQueryParam('env') || '').toLowerCase();
+  Logger.init(env === 'mock'); // Initialize Logger
+
+  /**
+   * ColorMapRun JSBridge SDK (Compatible Version)
+   * 用于 H5 页面与 Flutter App 进行交互
+   * 兼容性说明:
+   * - 优先检测 uni.webView 标准通道
+   * - 降级适配旧版 App 的 action:// 协议拦截和 window.share_wx 注入对象
+   */
+
+  var Bridge = {
+    version: '1.0.2',
+    
+    /**
+     * 内部核心发送方法
+     */
+    _post: function (action, data) {
+      data = data || {};
+      Logger.log('[Bridge] Call:', action, data);
+
+      // 1. 优先尝试标准 uni 通道 (新版 App)
+      if (window.uni && window.uni.postMessage) {
+        window.uni.postMessage({
+          data: {
+            action: action,
+            data: data
+          }
+        });
+        return;
+      }
+
+      // 2. 降级适配 (旧版 App)
+      this._fallback(action, data);
+    },
+
+    /**
+     * 旧版 App 适配逻辑
+     */
+    _fallback: function (action, data) {
+      var url = '';
+
+      switch (action) {
+        case 'back':
+          // 尝试关闭页面或返回
+          window.history.back();
+          break;
+          
+        case 'toHome':
+          // 协议: action://to_home/
+          url = 'action://to_home/';
+          break;
+
+        case 'toLogin':
+          // 协议: action://to_login/
+          url = 'action://to_login/';
+          break;
+
+        case 'openMap':
+          // 协议: action://to_map_app?title=xxx&latitude=xxx&longitude=xxx
+          url = 'action://to_map_app?title=' + encodeURIComponent(data.name || '') +
+                '&latitude=' + data.latitude +
+                '&longitude=' + data.longitude;
+          break;
+
+        case 'openMatch':
+          // 协议: action://to_detail/?id=xxx&matchType=xxx
+          url = 'action://to_detail/?id=' + data.id +
+                '&matchType=' + (data.type || 1);
+          break;
+          
+        case 'openActivityList':
+           // 协议: action://to_activity_list/?id=xxx&mapName=xxx
+           url = 'action://to_activity_list/?id=' + data.id +
+                 '&mapName=' + encodeURIComponent(data.mapName || '');
+           break;
+
+        case 'shareWx':
+          // 旧版使用注入对象 share_wx
+          if (window.share_wx && window.share_wx.postMessage) {
+             window.share_wx.postMessage(JSON.stringify(data));
+          } else {
+             Logger.error('[Bridge] share_wx injection not found');
+             alert('微信分享功能不可用(环境不支持)');
+          }
+          return; // shareWx 不需要走 URL 拦截
+
+        case 'launchWxMini':
+           // 旧版使用注入对象 wx_launch_mini
+           if (window.wx_launch_mini && window.wx_launch_mini.postMessage) {
+               window.wx_launch_mini.postMessage(JSON.stringify(data));
+           } else {
+               Logger.error('[Bridge] wx_launch_mini injection not found');
+           }
+           return;
+
+        case 'saveImage':
+           // 旧版使用注入对象 save_base64
+           if (window.save_base64 && window.save_base64.postMessage) {
+               window.save_base64.postMessage(data.base64);
+           } else {
+               Logger.error('[Bridge] save_base64 injection not found');
+           }
+           return;
+
+        case 'makePhoneCall':
+          url = 'tel:' + data.phoneNumber;
+          break;
+          
+        case 'showToast':
+          // 降级为 alert,体验稍差但保证可见
+          // setTimeout 避免阻塞当前执行流
+          setTimeout(function() { alert(data.title); }, 10);
+          return;
+          
+        case 'showModal':
+           setTimeout(function() { 
+               var result = confirm(data.content || data.title); 
+               // 无法同步返回结果给 App 逻辑,仅做展示
+           }, 10);
+           return;
+
+        default:
+          Logger.warn('[Bridge] No legacy fallback for action:', action);
+      }
+
+      if (url) {
+        Logger.log('[Bridge] Legacy URL jump:', url);
+        // 触发 URL 拦截
+        window.location.href = url;
+      }
+    },
+
+    // ==============================
+    // Ported from common/tools.js
+    // ==============================
+
+    /**
+     * 对url追加项目版本号
+     */
+    urlAddVer: function(url) {
+      var newUrl = url;
+      try {
+        if (window.uni && window.uni.getSystemInfoSync) {
+          var systemInfo = window.uni.getSystemInfoSync();
+          var version_number = systemInfo.appVersion;
+
+          if (version_number) {
+            if (newUrl.indexOf('_v=') !== -1) {
+              return newUrl;
+            }
+            if (newUrl.indexOf('?') !== -1) {
+              newUrl += "&_v=" + version_number;
+            } else {
+              newUrl += "?_v=" + version_number;
+            }
+          }
+        }
+      } catch (e) {
+        Logger.warn('[Bridge] urlAddVer error:', e);
+      }
+      Logger.log("[Bridge] urlAddVer newUrl:", newUrl);
+      return newUrl;
+    },
+
+    /**
+     * 导航到APP内的某个页面或执行APP内部的某些功能
+     */
+    appAction: function(url, actType) {
+      actType = actType || "";
+      Logger.log("[Bridge] appAction:", url, "actType:", actType);
+
+      if (url.indexOf('http') !== -1) {
+        window.location.href = this.urlAddVer(url);
+      } else if (url == "reload") {
+        window.location.reload();
+      } else if (actType == "uni.navigateTo" && window.uni && window.uni.navigateTo) {
+        window.uni.navigateTo({
+          url: this.urlAddVer(url)
+        });
+      } else {
+        window.location.href = url;
+      }
+    },
+
+    // ==============================
+    // 公开 API
+    // ==============================
+
+    back: function () {
+      this._post('back');
+    },
+    
+    toHome: function() {
+        this._post('toHome');
+    },
+    
+    toLogin: function() {
+        this._post('toLogin');
+    },
+
+    setTitle: function (title) {
+      this._post('setTitle', { title: title });
+    },
+
+    openMap: function (latitude, longitude, name) {
+      this._post('openMap', {
+        latitude: latitude,
+        longitude: longitude,
+        name: name
+      });
+    },
+
+    openMatch: function (id, type) {
+      this._post('openMatch', {
+        id: id,
+        type: type
+      });
+    },
+    
+    openActivityList: function(id, mapName) {
+        this._post('openActivityList', {
+            id: id,
+            mapName: mapName
+        });
+    },
+
+    shareWx: function (options) {
+      this._post('shareWx', options);
+    },
+
+    launchWxMini: function (username, path) {
+      this._post('launchWxMini', { username: username, path: path });
+    },
+    
+    saveImage: function (base64Str) {
+       this._post('saveImage', { base64: base64Str });
+    },
+    
+    // --- 新增完善功能 ---
+    
+    /**
+     * 预览图片
+     * @param {Array} urls 图片地址数组
+     * @param {String} current 当前显示图片的地址
+     */
+    previewImage: function(urls, current) {
+        this._post('previewImage', { urls: urls, current: current });
+    },
+
+    /**
+     * 拨打电话
+     * @param {String} phoneNumber 电话号码
+     */
+    makePhoneCall: function(phoneNumber) {
+        this._post('makePhoneCall', { phoneNumber: phoneNumber });
+    },
+
+    /**
+     * 设置剪贴板内容
+     * @param {String} data 文本内容
+     */
+    setClipboardData: function(data) {
+        this._post('setClipboardData', { data: data });
+    },
+
+    /**
+     * 显示 Toast 提示
+     * @param {String} title 提示内容
+     * @param {String} icon 图标 (success/loading/none)
+     * @param {Number} duration 持续时间(ms)
+     */
+    showToast: function(title, icon, duration) {
+        this._post('showToast', { 
+            title: title, 
+            icon: icon || 'none', 
+            duration: duration || 1500 
+        });
+    },
+
+    /**
+     * 显示 Loading 提示框
+     * @param {String} title 提示内容
+     */
+    showLoading: function(title) {
+        this._post('showLoading', { title: title || '加载中' });
+    },
+
+    /**
+     * 隐藏 Loading 提示框
+     */
+    hideLoading: function() {
+        this._post('hideLoading');
+    },
+    
+    /**
+     * 显示模态确认框
+     * @param {Object} options { title, content, showCancel, confirmText, cancelText }
+     * @param {Function} successCallback 点击确定/取消的回调 (仅在支持的双向通信环境有效)
+     */
+    showModal: function(options, successCallback) {
+        // TODO: 这里的 callback 在单向 Bridge 中难以实现,通常需要 App 回调 JS 方法
+        this._post('showModal', options);
+    },
+    
+    // ------------------
+
+    getToken: function () {
+        this._post('getToken');
+    },
+    
+    _tokenCallback: null,
+    onToken: function(callback) {
+        this._tokenCallback = callback;
+    },
+    receiveToken: function(token) {
+        Logger.log('[Bridge] Received token:', token);
+        if (this._tokenCallback) {
+            this._tokenCallback(token);
+        }
+    }
+  };
+
+  window.Bridge = Bridge;
+
+})(window);

文件差異過大導致無法顯示
+ 5 - 0
card/pages/tpl/style3/new/css/all.min.css


二進制
card/pages/tpl/style3/new/gd.png


+ 77 - 0
card/pages/tpl/style3/new/index.html

@@ -0,0 +1,77 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <script src="https://cdn.tailwindcss.com"></script>
+    <style>
+        @import url('https://fonts.googleapis.com/css2?family=Fredoka:wght@400;600&display=swap');
+        body, html { 
+            font-family: 'Fredoka', sans-serif; 
+            width: 100%; 
+            height: 100%; 
+            margin: 0; 
+            padding: 0;
+            background-color: transparent; /* 确保倒角外部透明 */
+        }
+    </style>
+</head>
+<body class="flex items-center justify-center overflow-hidden">
+
+    <!-- 卡片容器 
+         1. h-full w-full: 占满屏幕
+         2. rounded-[30px]: 强制圆角
+         3. overflow-hidden: 裁剪图片确保圆角生效
+         4. 无背景色设置(或设为white),确保外部是透的
+    -->
+    <div class="relative w-full h-full bg-white overflow-hidden rounded-[30px] shadow-2xl">
+        
+        <!-- 1. 背景层 -->
+        <img src="https://orienteering.beswell.com/card/nanning/nncardrg.jpg" 
+             alt="活动背景" 
+             class="absolute inset-0 w-full h-full object-cover">
+        
+        <!-- 遮罩层 -->
+        <div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-black/20"></div>
+
+        <!-- 2. 倒计时区域 
+             使用 vw 单位实现等比例缩放 
+        -->
+        <div class="absolute top-[5%] right-[5%] bg-black/40 backdrop-blur-md rounded-full border border-white/20 shadow-lg z-10"
+             style="padding: 1.5vw 3vw;">
+            <div class="flex items-center gap-[1vw] text-white leading-none font-medium" id="timer-container">
+                <span class="opacity-90" style="font-size: 3.5vw;">距开始</span>
+                <span class="font-bold text-yellow-300 tabular-nums" style="font-size: 4.5vw;">
+                    <span id="days">02</span>天<span id="hours">14</span>时
+                </span>
+            </div>
+        </div>
+
+        <!-- 3. 进入按钮 
+             使用 vw 单位控制宽度、字体、圆角和边距,实现完全响应式
+        -->
+        <div class="absolute bottom-[8%] left-0 w-full flex justify-center z-10 px-[6vw]">
+            <button id="action-btn" 
+                    class="w-full bg-gradient-to-r from-orange-500 to-red-600 text-white font-bold shadow-xl border border-white/20 active:scale-95 transition-transform tracking-wide flex items-center justify-center gap-[2vw]"
+                    style="font-size: 5vw; padding: 3.5vw 0; border-radius: 9999px;">
+                开始报名 <i class="fas fa-chevron-right opacity-80" style="font-size: 4vw;"></i>
+            </button>
+        </div>
+    </div>
+
+    <!-- FontAwesome for the arrow icon -->
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
+
+    <script src="js/uni-compat.js"></script>
+    <script src="js/define.js"></script>
+    <script src="js/api.js"></script>
+    <script src="js/tools.js"></script>
+    <script src="js/cardfunc.js"></script>
+    <script src="js/logic-index.js"></script>
+    <script>
+        document.addEventListener('DOMContentLoaded', function() {
+            initIndexPage();
+        });
+    </script>
+</body>
+</html>

+ 68 - 0
card/pages/tpl/style3/new/js/api.js

@@ -0,0 +1,68 @@
+(function() {
+    // Configuration
+    // TODO: REPLACE WITH ACTUAL API URL IF NEEDED
+    // Based on ossUrl in vue files, guessing the api url.
+    const API_SERVER = 'https://api.colormaprun.com/card/'; 
+    const OSS_URL = 'https://oss-mbh5.colormaprun.com/';
+
+    window.ossUrl = OSS_URL;
+    window.apiServer = API_SERVER;
+    window.token = ''; // Will be set from logic-*.js via URL params
+
+    // APIs
+    window.apiCardBaseQuery = API_SERVER + 'CardBaseQuery';
+    window.apiCardDetailQuery = API_SERVER + 'CardDetailQuery';
+    window.apiMatchRsDetailQuery = API_SERVER + 'MatchRsDetailQuery';
+    window.apiCardRankDetailQuery = API_SERVER + 'CardRankDetailQuery';
+    window.apiUserCurrentRankNumQuery = API_SERVER + 'UserCurrentRankNumQuery';
+    window.apiUserJoinCardQuery = API_SERVER + 'UserJoinCardQuery';
+    window.apiIsNewUserInCardComp = API_SERVER + 'IsNewUserInCardComp';
+    window.apiOnlineMcSignUpDetail = API_SERVER + 'OnlineMcSignUpDetail';
+    window.apiOnlineMcSignUp = API_SERVER + 'OnlineMcSignUp';
+    window.apiIsAllowMcSignUp = API_SERVER + 'IsAllowMcSignUp';
+    window.apiCardConfigQuery = API_SERVER + 'CardConfigQuery';
+    window.apiUserConfigQuery = API_SERVER + 'UserConfigQuery';
+    window.apiUnReadMessageQuery = API_SERVER + 'UnReadMessageQuery';
+    window.apiMapListQuery = API_SERVER + 'MapListQuery';
+    window.apiCompStatisticQuery = API_SERVER + 'CompStatisticQuery';
+    window.apiWarnMessageQuery = API_SERVER + 'WarnMessageQuery';
+
+    // Helpers
+    window.checkResCode = function(res, failLabel='') {
+        if (res.data && res.data.code == 0) {
+            return true;
+        } else if (res.statusCode == 401) { // Not logged in
+            uni.showToast({
+                title: `您尚未登录`,
+                icon: 'none'
+            });
+            const url = `action://to_login/`;
+            window.appAction(url);
+            return false;
+        } else {
+            const msg = res.data ? res.data.message : 'Unknown Error';
+            uni.showToast({
+                title: `${failLabel}${msg}`,
+                icon: 'none'
+            });
+            return false;
+        }
+    };
+
+    window.checkToken = function(token) {
+        const regex = /^[0-9A-Za-f]{32}$/;
+        if (regex.test(token)) {
+            return true;
+        } else {
+            console.log('checkToken err: ', token);
+            uni.showToast({
+                title: `您尚未登录`,
+                icon: 'none'
+            });
+            const url = `action://to_login/`;
+            window.appAction(url);
+            return false;
+        }
+    };
+
+})();

+ 401 - 0
card/pages/tpl/style3/new/js/cardfunc.js

@@ -0,0 +1,401 @@
+(function() {
+    window.cardfunc = {
+        caller: null,
+        token: "",
+        ecId: 0, // 卡片id
+        isNewUser: false,	// 是否新用户
+        
+        cardConfigData: {
+            tabActiveColor: "#81cd00",
+    
+            popupRuleConfig: {}, // 规则弹窗配置
+            popupRuleList: [], // 规则弹窗数据
+    
+            popupExchgConfig: {}, // 兑换地址弹窗配置
+            popupExchgList: [], // 兑换地址弹窗数据
+    
+            popupHelpConfig: {}, // 帮助弹窗配置
+            popupHelpList: [],
+    
+            popupMessageConfig: {}, // 通知弹窗配置
+            popupMessageList: [], // 通知弹窗数据
+    
+            popupWarnConfig: {}, // 警告弹窗配置
+            popupWarnList: [], // 警告弹窗数据
+        },
+        
+        userConfigData: {
+            
+        },
+        
+        init(token, ecId) {
+            // this.caller = caller; // Not used in pure HTML logic usually
+            this.token = token;
+            this.ecId = ecId;
+            this.removeCss();
+        },
+                
+        // 清除css
+        removeCss() {
+            if(window.tools) {
+                window.tools.removeCssCode("css-common");
+                window.tools.removeCssCode("css-custom");
+                window.tools.removeCssCode("css-user");
+            }
+        },
+    
+        getCardConfig(loadConfig, testconfig) {
+            // Assuming remote config for H5
+            this.cardConfigQuery(loadConfig);
+        },
+    
+        getUserConfig(loadConfig, testconfig) {
+            this.userConfigQuery(loadConfig);
+        },
+        
+        parseCardConfig(cardconfig) {
+            if (cardconfig == undefined || cardconfig == "") {
+                return;
+            }
+            
+            if (typeof cardconfig == "string") {
+                cardconfig = cardconfig.replace(/[\r|\n|\t]/g, "");
+                const config = JSON.parse(cardconfig);
+                return config;
+            } else {
+                return cardconfig;
+            }
+        },
+    
+        // 加载卡片通用配置
+        loadCardCommonConfig(config_common) {
+            config_common = this.parseCardConfig(config_common);
+            if (config_common == undefined || config_common == "") {
+                return;
+            }
+    
+            if (config_common.css != undefined && config_common.css.length > 0) {
+                if(window.tools) window.tools.loadCssCode(config_common.css, "css-common");
+            }
+    
+            if (config_common.tabActiveColor != undefined && config_common.tabActiveColor.length > 0) {
+                this.cardConfigData.tabActiveColor = config_common.tabActiveColor;
+            }
+    
+            // 加载规则弹窗配置
+            if (config_common.popupRuleConfig != undefined) {
+                this.cardConfigData.popupRuleConfig = config_common.popupRuleConfig;
+            }
+    
+            // 加载帮助弹窗配置
+            if (config_common.popupHelpConfig != undefined) {
+                this.cardConfigData.popupHelpConfig = config_common.popupHelpConfig;
+            }
+    
+            // 加载警告弹窗配置
+            if (config_common.popupWarnConfig != undefined) {
+                this.cardConfigData.popupWarnConfig = config_common.popupWarnConfig;
+            }
+    
+            // 加载兑换地址弹窗配置
+            if (config_common.popupExchgConfig != undefined) {
+                this.cardConfigData.popupExchgConfig = config_common.popupExchgConfig;
+            }
+    
+            // 加载通知弹窗配置
+            if (config_common.popupMessageConfig != undefined) {
+                this.cardConfigData.popupMessageConfig = config_common.popupMessageConfig;
+            }
+    
+            // 加载弹窗(规则)数据
+            const popupRuleList = config_common.popupRuleList;
+            if (popupRuleList != undefined && popupRuleList.length > 0) {
+                this.cardConfigData.popupRuleList.length = 0;
+                for (var i = 0; i < popupRuleList.length; i++) {
+                    if (popupRuleList[i] == 'default') {
+                        for (var j = 0; j < defaultPopUpDataList.length; j++) {
+                            this.cardConfigData.popupRuleList.push(defaultPopUpDataList[j]);
+                        }
+                    } else if (popupRuleList[i] == 'default2') {
+                        for (var j = 0; j < defaultPopUpDataList2.length; j++) {
+                            this.cardConfigData.popupRuleList.push(defaultPopUpDataList2[j]);
+                        }
+                    } else if (popupRuleList[i] == 'default3') {
+                        for (var j = 0; j < defaultPopUpDataList3.length; j++) {
+                            this.cardConfigData.popupRuleList.push(defaultPopUpDataList3[j]);
+                        }
+                    } else {
+                        this.cardConfigData.popupRuleList.push(popupRuleList[i]);
+                    }
+                }
+            } else {
+                this.cardConfigData.popupRuleList = defaultPopUpDataList2;
+            }
+    
+            // 加载弹窗(兑换地址)数据
+            const popupExchgList = config_common.popupExchgList;
+            if (popupExchgList != undefined && popupExchgList.length > 0) {
+                this.cardConfigData.popupExchgList.length = 0;
+                for (var i = 0; i < popupExchgList.length; i++) {
+                    this.cardConfigData.popupExchgList.push(popupExchgList[i]);
+                }
+            }
+    
+            // 加载弹窗(帮助)数据
+            const popupHelpList = config_common.popupHelpList;
+            if (popupHelpList != undefined && popupHelpList.length > 0) {
+                this.cardConfigData.popupHelpList.length = 0;
+                for (var i = 0; i < popupHelpList.length; i++) {
+                    this.cardConfigData.popupHelpList.push(popupHelpList[i]);
+                }
+            }
+        },
+        
+        // 加载用户的弹窗数据
+        loadUserPopupRule(config) {
+            const tplInfo = config.tplInfo;
+            const matchInfo = config.matchInfo;
+            if (matchInfo) {
+                let hint = "<span style='color:#FF5E00;'>参赛要求</span><br>";
+                hint += "① 赛事以自身安全为最高要求,请正确评估自身健康,切勿超负荷运动,适时参赛<br>② 参赛人群建议:6-60岁健康居民<br>";
+                hint += "<br><span style='color:#FF5E00;'>安全提醒</span><br>";
+                hint += "① 请着运动服及运动鞋<br>② 避免聚集、分散参与<br>③ 及时增减衣物,预防感冒<br>④ 注意交通安全与自身安全";
+                const contact = `联系人:${matchInfo.contactName} &nbsp;&nbsp; 电话:<a href='tel:${matchInfo.phone}' style='color: #ff5500;'>${matchInfo.phone}</a>`;
+                const content = `${hint}<br><br>${contact}`;
+                
+                const defaultMatchLogo = getApp().globalData.defaultMatchLogo;
+                const logoSrc = (tplInfo.matchLogo != undefined && tplInfo.matchLogo != "") ? tplInfo.matchLogo : defaultMatchLogo;
+                
+                const popupRule = [{
+                    "type": 1,
+                    "data": {
+                        "title": matchInfo.compName,
+                        "logo": {
+                            "src": logoSrc,
+                            "width": "260px",
+                             "height": "90px"
+                        },
+                        "content": content
+                    }
+                }];
+                this.cardConfigData.popupRuleList.unshift(...popupRule);
+            }
+        },
+        
+        // 获取用户的比赛路线数据
+        getUserPathList(config) {
+            const mapInfo = config.mapInfo;
+            const mapNum = mapInfo.length;
+            let pathList = {};
+            if (mapNum > 0) {
+                let activityList = [];
+                // 将多地图的路线信息数组合并成一个数组
+                for (var m = 0; m < mapNum; m++) {
+                    activityList.push(...mapInfo[m].activityList);
+                }
+                const activityNum = activityList.length;
+                let type = 4;
+                let navImg = "/static/common/nav3.png";
+                if (activityNum > 1) {
+                    type = 3;
+                    navImg = "/static/common/nav.png";
+                }
+                if (activityNum > 0) {
+                    const rowSize = 2; // 每行显示的路线数量
+                    const rowNum = Math.ceil(activityNum / rowSize);
+                    for (var i = 0; i < rowNum; i++) {
+                        let row = [];
+                        for (var j = 0; j < rowSize; j++) {
+                            const activity = activityList[i * rowSize + j];
+                            if (!activity) {
+                                break;
+                            }
+                            const path = {
+                                "type": type,
+                                "pathName": activity.showName,
+                                "pathImg": activity.pathImg,
+                                "path": {
+                                    "ocaId": activity.ocaId,
+                                    "mcType": activity.matchType
+                                },
+                                "navImg": navImg,
+                                "point": {
+                                    "longitude": activity.point.longitude,
+                                    "latitude": activity.point.latitude,
+                                    "name": activity.point.name
+                                }
+                            };
+                            row.push(path);
+                        }
+                        pathList["row" + (i + 1)] = row;
+                    }
+                }
+            } else {
+                console.warn("[getUserPathList] mapInfo err:", mapInfo);
+            }
+            return pathList;
+        },
+    
+        // 卡片配置信息查询
+        cardConfigQuery(callback) {
+            uni.request({
+                url: window.apiCardConfigQuery,
+                header: {
+                    "Content-Type": "application/x-www-form-urlencoded",
+                    "token": this.token,
+                },
+                method: "POST",
+                data: {
+                    ecId: this.ecId,
+                    pageName: "all"
+                },
+                success: (res) => {
+                    if(res.data && res.data.data) {
+                        const data = res.data.data;
+                        const config = data.configJson;
+                        callback(config);
+                    }
+                },
+                fail: (err) => {
+                    console.log("[cardConfigQuery] err", err);
+                },
+            });
+        },
+    
+        // 用户自定义配置信息查询
+        userConfigQuery(callback) {
+            uni.request({
+                url: window.apiUserConfigQuery,
+                header: {
+                    "Content-Type": "application/x-www-form-urlencoded",
+                    "token": this.token,
+                },
+                method: "POST",
+                data: {
+                    ecId: this.ecId,
+                    pageName: "all"
+                },
+                success: (res) => {
+                    if(res.data && res.data.data) {
+                        const data = res.data.data;
+                        const config = data.configJson;
+                        callback(config);
+                    }
+                },
+                fail: (err) => {
+                    console.log("[userConfigQuery] err", err);
+                },
+            });
+        },
+        
+        // 警告列表查询
+        warnMessageQuery(callback=null) {
+            uni.request({
+                url: window.apiWarnMessageQuery,
+                header: {
+                    "Content-Type": "application/x-www-form-urlencoded",
+                    "token": this.token,
+                },
+                method: "POST",
+                data: {
+                    ecId: this.ecId
+                },
+                success: (res) => {
+                    if (checkResCode(res)) {
+                        const warnRs = res.data.data;
+                        this.cardConfigData.popupWarnList.length = 0;
+                        for (var i = 0; i < warnRs.length; i++) {
+                            let popupData = {
+                                type: 9, // 9: 警告
+                                data: {}
+                            };
+                            popupData.data.warnType = warnRs[i].warnType;
+                            popupData.data.title = warnRs[i].warnTitle;
+                            popupData.data.iconUrl = warnRs[i].iconUrl;
+                            popupData.data.iconNum = warnRs[i].iconNum;
+                            popupData.data.message = warnRs[i].warnMessage;
+                            popupData.data.qrCodeUrl = warnRs[i].qrCodeUrl;
+                            this.cardConfigData.popupWarnList.push(popupData);
+                        }
+    
+                        if (callback != null) {
+                            callback(this.cardConfigData.popupWarnList);
+                        }
+                    }
+                },
+                fail: (err) => {
+                    console.log("warnMessageQuery err", err)
+                },
+            });
+        },
+        
+        // 未读消息列表查询
+        unReadMessageQuery(callback=null) {
+            uni.request({
+                url: window.apiUnReadMessageQuery,
+                header: {
+                    "Content-Type": "application/x-www-form-urlencoded",
+                    "token": this.token,
+                },
+                method: "POST",
+                data: {
+                    relationType: 2, // 类型 1 成就 2 卡片
+                    relationId: this.ecId
+                },
+                success: (res) => {
+                    if (checkResCode(res)) {
+                        const unReadMessageRs = res.data.data;
+                        this.cardConfigData.popupMessageList.length = 0;
+                        let mqIdListStr = "";
+                        for (var i = 0; i < unReadMessageRs.length; i++) {
+                            let popupData = {
+                                type: 6, // 6: 通知
+                                data: {}
+                            };
+                            mqIdListStr += "-" + unReadMessageRs[i].mqId;
+                            popupData.data.mqType = unReadMessageRs[i].mqType;
+                            popupData.data.title = unReadMessageRs[i].mqTitle;
+                            popupData.data.message = unReadMessageRs[i].mqMessage;
+                            this.cardConfigData.popupMessageList.push(popupData);
+                        }
+        
+                        if (callback != null) {
+                            callback(this.cardConfigData.popupMessageList, mqIdListStr);
+                        }
+                    }
+                },
+                fail: (err) => {
+                    console.log("getUnReadMessageQuery err", err);
+                },
+            });
+        },
+        
+        // 用户是否新用户
+        isNewUserQuery(callback=null) {
+            uni.request({
+                url: window.apiIsNewUserInCardComp,
+                header: {
+                    "Content-Type": "application/x-www-form-urlencoded",
+                    "token": this.token,
+                },
+                method: "POST",
+                data: {
+                    // ecId: this.ecId
+                    ecId: 0	// 0 全部赛事活动
+                },
+                success: (res) => {
+                    if (checkResCode(res)) {
+                        this.isNewUser = res.data.data.isNew;
+                        if (callback != null) {
+                            callback(this.isNewUser);
+                        }
+                    }
+                },
+                fail: (err) => {
+                    console.log("isNewUserInCardComp err", err);
+                },
+            });
+        },
+    
+    }
+})();

+ 75 - 0
card/pages/tpl/style3/new/js/define.js

@@ -0,0 +1,75 @@
+(function() {
+    window.tplStyleList = [];
+    window.tplStyleList[0] = 'blue';
+    window.tplStyleList[1] = 'orange';
+    window.tplStyleList[2] = 'green';
+
+    window.teamName = [];
+
+    window.teamName[0] = [];
+    window.teamName[0][0] = '不组队';
+    window.teamName[0][1] = '红队';
+    window.teamName[0][2] = '黄队';
+    window.teamName[0][3] = '蓝队';
+    window.teamName[0][4] = '紫队';
+
+    window.teamName[1] = [];
+    window.teamName[1][0] = '不组队';
+    window.teamName[1][1] = '学生队';
+    window.teamName[1][2] = '家长队';
+
+    window.defaultPopUpDataList = [{
+            type: 2,
+            data: {
+                title: "活动流程",
+                img: "/static/common/hdlc.png",
+            }
+        },
+        {
+            type: 2,
+            data: {
+                title: "基本标识",
+                img: "/static/common/jbbs.png",
+            }
+        }
+    ];
+
+    window.defaultPopUpDataList2 = [{
+            "type": 7,
+            "data": {
+                "title": "基本标识",
+                "logo": {
+                    "src": "/static/common/jbbs2.png",
+                    "width": "280px",
+                    "height": "250px"
+                },
+                "content": "<span style='display:block; text-align: left; color: #FF870E; margin: 8px 0;'>安全提示</span><li>避免聚集,分散参与 <li>评估自身健康,适时参与 <li>注意交通与场地安全"
+            }
+        },
+        {
+            "type": 7,
+            "data": {
+                "title": "基本图例",
+                "logo": {
+                    "src": "/static/common/jbtl.png",
+                    "width": "280px",
+                    "height": "250px"
+                },
+                "content": "<br><span style='display:block; text-align: left; color: #FF870E; margin: 8px 0;'>安全提示</span><li>避免聚集,分散参与 <li>评估自身健康,适时参与 <li>注意交通与场地安全"
+            }
+        }
+    ];
+
+    window.defaultPopUpDataList3 = [{
+            "type": 7,
+            "data": {
+                "title": "基本标识及图例",
+                "logo": {
+                    "src": "/static/common/jbbs3.png",
+                    "width": "300px",
+                    "height": "380px"
+                }
+            }
+        }
+    ];
+})();

+ 207 - 0
card/pages/tpl/style3/new/js/logic-index.js

@@ -0,0 +1,207 @@
+var state = {
+    token: "",
+    ecId: 0,
+    pageName: "index",
+    
+    ecName: '', 
+    ecDesc: '', 
+    beginSecond: null, 
+    endSecond: null, 
+    secondCardName: '', 
+    
+    isJoin: null, 
+    isFinished: false, 
+    
+    countdown: "", 
+    interval: null,
+    
+    mcState: 0, // 0: Not started, 1: In progress, 2: Finished
+    
+    // Query params
+    type: "锦标赛",
+    btnText: "开始比赛"
+};
+
+function initIndexPage() {
+    const params = new URLSearchParams(window.location.search);
+    state.token = params.get('token') || '';
+    state.ecId = params.get('id') || 0;
+    state.type = params.get('type') || "锦标赛";
+    state.btnText = params.get('btnText') || "开始比赛";
+    
+    if(window.cardfunc) {
+        window.cardfunc.init(state.token, state.ecId);
+        window.cardfunc.getCardConfig(onConfigLoaded);
+    }
+    
+    // Bind events
+    const btn = document.getElementById('action-btn');
+    if(btn) {
+        btn.addEventListener('click', btnClick);
+    }
+}
+
+function onConfigLoaded(config) {
+    // Load CSS if any (cardfunc handles common config, we handle page specific if needed)
+    // For pure HTML with Tailwind, maybe we don't need dynamic CSS injection as much, 
+    // or we rely on cardfunc.loadCardCommonConfig which is already called.
+    
+    // Now fetch data
+    getCardBaseQuery();
+    getUserJoinCardQuery();
+    // matchRsDetailQuery(); // Optional: for red dot
+}
+
+function getCardBaseQuery() {
+    uni.request({
+        url: window.apiCardBaseQuery,
+        header: {
+            "Content-Type": "application/x-www-form-urlencoded",
+            "token": state.token,
+        },
+        method: "POST",
+        data: {
+            ecId: state.ecId,
+            pageName: state.pageName
+        },
+        success: (res) => {
+            const data = res.data.data;
+            if(data) {
+                state.ecName = data.ecName;
+                state.ecDesc = data.ecDesc;
+                state.beginSecond = data.beginSecond;
+                state.endSecond = data.endSecond;
+                state.secondCardName = data.secondCardName;
+                
+                updateCountdown();
+                if(state.interval) clearInterval(state.interval);
+                state.interval = setInterval(updateCountdown, 60000);
+            }
+        }
+    });
+}
+
+function getUserJoinCardQuery() {
+    uni.request({
+        url: window.apiUserJoinCardQuery,
+        header: {
+            "Content-Type": "application/x-www-form-urlencoded",
+            "token": state.token
+        },
+        method: "POST",
+        data: {
+            ecId: state.ecId
+        },
+        success: (res) => {
+            const code = res.data.code;
+            const data = res.data.data;
+            if (code == 0) {
+                state.isJoin = data.isJoin;
+                updateUI();
+            }
+        }
+    });
+}
+
+function updateCountdown() {
+    if (state.endSecond > 0) {
+        const now = Date.now() / 1000;
+        
+        // Calculate state based on time
+        if(state.beginSecond > now) {
+            state.mcState = 0; // Not started
+        } else if (state.endSecond > now) {
+            state.mcState = 1; // In progress
+        } else {
+            state.mcState = 2; // Finished
+            state.isFinished = true;
+        }
+
+        let targetTime = state.mcState === 0 ? state.beginSecond : state.endSecond;
+        let dif = targetTime - now;
+        
+        if(state.mcState === 2) {
+            dif = 0;
+        }
+
+        // Update DOM
+        const timerContainer = document.getElementById('timer-container');
+        if(timerContainer) {
+            let label = state.mcState === 0 ? "距开始" : (state.mcState === 1 ? "距结束" : "已结束");
+            
+            if(state.mcState === 2) {
+                 timerContainer.parentElement.className = "absolute top-[5%] right-[5%] bg-gray-800/80 backdrop-blur-md rounded-full";
+                 timerContainer.parentElement.style.padding = "1.5vw 3vw";
+                 timerContainer.innerHTML = '<span class="text-gray-200 font-bold" style="font-size: 4vw;">已结束</span>';
+            } else {
+                let days = Math.floor(dif / (3600 * 24));
+                let hours = Math.floor((dif % (3600 * 24)) / 3600);
+                // let minutes = Math.floor((dif % 3600) / 60);
+                
+                timerContainer.innerHTML = `
+                    <span class="opacity-90" style="font-size: 3.5vw;">${label}</span>
+                    <span class="font-bold text-yellow-300 tabular-nums" style="font-size: 4.5vw;">
+                        <span id="days">${days.toString().padStart(2,'0')}</span>天<span id="hours">${hours.toString().padStart(2,'0')}</span>时
+                    </span>
+                `;
+            }
+        }
+        updateUI();
+    }
+}
+
+function updateUI() {
+    const btn = document.getElementById('action-btn');
+    if(!btn) return;
+
+    if(state.isJoin) {
+        // Already joined, show "Enter Game" or "View Rank"
+        if (state.mcState === 0) {
+             // Not started but joined? Maybe "Wait for start" or just "View Rank"
+             btn.innerHTML = '查看排名 <i class="fas fa-list-ol opacity-80" style="font-size: 4vw;"></i>';
+             btn.className = "w-full bg-gradient-to-r from-blue-500 to-indigo-600 text-white font-bold shadow-xl border border-white/20 active:scale-95 transition-transform tracking-wide flex items-center justify-center gap-[2vw]";
+        } else if (state.mcState === 1) {
+            btn.innerHTML = '进入比赛 <i class="fas fa-flag-checkered opacity-80" style="font-size: 4vw;"></i>';
+            btn.className = "w-full bg-gradient-to-r from-green-400 to-emerald-600 text-white font-bold shadow-xl border border-white/20 active:scale-95 transition-transform tracking-wide flex items-center justify-center gap-[2vw]";
+        } else {
+            btn.innerHTML = '查看数据';
+            btn.className = "w-full bg-slate-600 text-white font-bold shadow-xl active:scale-95 transition-transform tracking-wide flex items-center justify-center gap-[2vw]";
+        }
+    } else {
+        // Not joined
+         if (state.mcState === 0 || state.mcState === 1) {
+            btn.innerHTML = '开始报名 <i class="fas fa-chevron-right opacity-80" style="font-size: 4vw;"></i>';
+            btn.className = "w-full bg-gradient-to-r from-orange-500 to-red-600 text-white font-bold shadow-xl border border-white/20 active:scale-95 transition-transform tracking-wide flex items-center justify-center gap-[2vw]";
+         } else {
+            btn.innerHTML = '查看排名'; // Event finished
+            btn.className = "w-full bg-slate-600 text-white font-bold shadow-xl active:scale-95 transition-transform tracking-wide flex items-center justify-center gap-[2vw]";
+         }
+    }
+    
+    // Adjust styles based on vw (done in HTML mostly, but classes updated above)
+    btn.style.fontSize = "5vw";
+    btn.style.padding = "3.5vw 0";
+    btn.style.borderRadius = "9999px";
+}
+
+function btnClick() {
+    const queryString = `token=${state.token}&id=${state.ecId}&type=${state.type}&btnText=${state.btnText}`;
+    
+    if (state.isJoin) {	
+        const url = `ranklist.html?${queryString}&full=true`;
+        window.uni.navigateTo({ url: url });
+    } else {	
+        if (!state.isFinished) {
+            if (state.secondCardName == 'rankList') {
+                const url = `ranklist.html?${queryString}&full=true`;
+                 window.uni.navigateTo({ url: url });
+            } else {
+                const url = `signup.html?${queryString}&full=true`;
+                 window.uni.navigateTo({ url: url });
+            }
+        } else {
+            const url = `ranklist.html?${queryString}&full=true`;
+             window.uni.navigateTo({ url: url });
+        }
+    }
+}

+ 463 - 0
card/pages/tpl/style3/new/js/logic-ranklist.js

@@ -0,0 +1,463 @@
+var state = {
+    token: "",
+    ecId: 0,
+    mcId: 0,
+    mcName: "",
+    ocaId: 0, // Current Map/Group ID
+    
+    // Personal Info
+    userInfo: {
+        nickName: '',
+        coiName: '',
+        totalScore: 0, // Points
+        avatar: 12 // Random or from API if available
+    },
+    
+    // Global Stats
+    stats: {
+        totalDistance: 0,
+        totalAnswerNum: 0,
+        totalCp: 0,
+        endSecond: 0
+    },
+    
+    // Leaderboard Data
+    rankList: {}, // Stores raw data from API
+    
+    // UI State
+    currentTab: 'team', // 'team' or 'individual'
+    currentMetric: 'score', // 'score', 'mileage', 'accuracy', 'count', 'lap' (pace)
+    
+    mapList: [], // For drawer
+    
+    mcState: 0
+};
+
+function initRankListPage() {
+    const params = new URLSearchParams(window.location.search);
+    state.token = params.get('token') || '';
+    state.ecId = params.get('id') || 0;
+    
+    const savedOcaId = uni.getStorageSync(`rank-tpl-style3-map-${state.ecId}`);
+    if(savedOcaId) state.ocaId = savedOcaId;
+
+    window.cardfunc.init(state.token, state.ecId);
+    window.cardfunc.getCardConfig(onConfigLoaded);
+    
+    document.getElementById('drawer-backdrop').addEventListener('click', closeDrawer);
+}
+
+function onConfigLoaded(config) {
+    matchRsDetailQuery();
+    compStatisticQuery();
+    mapListQuery(); 
+    window.cardfunc.unReadMessageQuery();
+}
+
+function matchRsDetailQuery() {
+    uni.request({
+        url: window.apiMatchRsDetailQuery,
+        header: { "Content-Type": "application/x-www-form-urlencoded", "token": state.token },
+        method: "POST",
+        data: { ecId: state.ecId, ocaId: state.ocaId },
+        success: (res) => {
+            if(window.checkResCode(res)) {
+                const data = res.data.data;
+                state.mcId = data.mcId;
+                state.mcName = data.mcName;
+                state.userInfo.nickName = data.nickName;
+                state.userInfo.coiName = data.coiName;
+                state.userInfo.totalScore = data.regionTotalSysPoint || 0;
+                state.mcState = window.tools.checkMcState(data.beginSecond, data.endSecond);
+                state.stats.endSecond = data.endSecond;
+                
+                updateHeaderUI();
+                fetchCompStats();
+                fetchMapList();
+            }
+        }
+    });
+}
+
+function fetchCompStats() {
+    uni.request({
+        url: window.apiCompStatisticQuery,
+        header: { "Content-Type": "application/x-www-form-urlencoded", "token": state.token },
+        method: "POST",
+        data: { mcId: state.mcId },
+        success: (res) => {
+            if(res.data.code == 0) {
+                const data = res.data.data;
+                state.stats.totalDistance = data.totalDistance;
+                state.stats.totalAnswerNum = data.totalAnswerNum;
+                state.stats.totalCp = data.totalCp;
+                updateMarquee();
+            }
+        }
+    });
+}
+
+function fetchMapList() {
+    uni.request({
+        url: window.apiMapListQuery,
+        header: { "Content-Type": "application/x-www-form-urlencoded", "token": state.token },
+        method: "POST",
+        data: { mcId: state.mcId },
+        success: (res) => {
+            if(res.data.code == 0) {
+                state.mapList = res.data.data;
+                if(state.ocaId == 0 && state.mapList.length > 0) {
+                    state.ocaId = state.mapList[0].ocaId;
+                    uni.setStorageSync(`rank-tpl-style3-map-${state.ecId}`, state.ocaId);
+                }
+                fetchRankDetail();
+                renderDrawer();
+            }
+        }
+    });
+}
+
+function fetchRankDetail() {
+    const dispArrStr = "teamCp,teamTodayCp,teamDistance,teamRightAnswerPer,teamTodayPace,regionCp,regionTodayCp,regionDistance,regionRightAnswerPer,regionTodayPace";
+    
+    uni.request({
+        url: window.apiCardRankDetailQuery,
+        header: { "Content-Type": "application/x-www-form-urlencoded", "token": state.token },
+        method: "POST",
+        data: {
+            mcIdListStr: state.mcId,
+            mcType: 1,
+            ocaId: state.ocaId,
+            dispArrStr: dispArrStr
+        },
+        success: (res) => {
+            if(res.data.code == 0) {
+                state.rankList = res.data.data;
+                renderLeaderboard();
+            }
+        }
+    });
+}
+
+function updateHeaderUI() {
+    const nameEl = document.getElementById('profileName');
+    if(nameEl) nameEl.innerText = state.userInfo.nickName || '未命名';
+    
+    const teamEl = document.getElementById('profileTeam');
+    if(teamEl) teamEl.innerText = state.userInfo.coiName || '未加入战队';
+    
+    // Score is in the personal card right side.
+    // HTML structure: <div class="text-2xl font-black text-primary font-mono leading-none">120</div>
+    // I'll look for that div or expect user to add ID. 
+    // I'll try to select it by context if ID not present.
+    // "当前积分" is in the next div.
+    const scoreLabel = Array.from(document.querySelectorAll('div')).find(el => el.innerText === '当前积分');
+    if(scoreLabel) {
+        const valEl = scoreLabel.previousElementSibling;
+        if(valEl) valEl.innerText = state.userInfo.totalScore;
+    }
+}
+
+function updateMarquee() {
+    const marquee = document.querySelector('.animate-marquee');
+    if(marquee) {
+        const now = Date.now() / 1000;
+        const dif = state.stats.endSecond - now;
+        let timeStr = "已结束";
+        if(dif > 0) timeStr = window.tools.convertSecondsToDHM(dif);
+        
+        marquee.innerText = `当前总题目: ${state.stats.totalAnswerNum}道 | 总里程: ${window.tools.fmtDistanct(state.stats.totalDistance)}km | 总打点数: ${state.stats.totalCp}个 | 距离比赛结束还有 ${timeStr} | 加油!冲鸭!`;
+    }
+}
+
+const metricMap = {
+    team: {
+        score: 'teamCpRs',
+        mileage: 'teamDistanceRs',
+        accuracy: 'teamRightAnswerPerRs',
+        count: 'teamCpRs',
+        lap: 'teamTodayPaceRs'
+    },
+    individual: {
+        score: 'regionCpRs',
+        mileage: 'regionDistanceRs',
+        accuracy: 'regionRightAnswerPerRs',
+        count: 'regionCpRs',
+        lap: 'regionTodayPaceRs'
+    }
+};
+
+function renderLeaderboard() {
+    const container = document.getElementById('leaderboard-container');
+    const key = metricMap[state.currentTab][state.currentMetric];
+    const list = state.rankList[key] || [];
+    
+    let html = '';
+    
+    list.forEach((item, index) => {
+        const rank = index + 1;
+        const isTop3 = index < 3;
+        const isMe = (item.nickName === state.userInfo.nickName);
+        
+        let rankIconHtml = '';
+        if (isTop3) {
+            const colors = ['text-yellow-400', 'text-gray-400', 'text-orange-600'];
+            const icon = state.currentTab === 'team' ? 'fa-trophy' : 'fa-medal';
+            rankIconHtml = `<div class="w-8 flex justify-center shrink-0 mr-1"><i class="fas ${icon} ${colors[index]} text-lg drop-shadow-sm"></i></div>`;
+        } else {
+            rankIconHtml = `<div class="w-8 text-center font-bold text-gray-400 text-sm mr-1">${rank}</div>`;
+        }
+        
+        let avatarHtml = '';
+        if (state.currentTab === 'team') {
+            avatarHtml = `<div class="w-9 h-9 bg-blue-50 rounded-full mr-3 flex items-center justify-center shrink-0 text-primary"><i class="fas fa-user-friends text-base"></i></div>`;
+        } else {
+             const img = item.avatar || (index % 10 + 1);
+             avatarHtml = `<img src="https://i.pravatar.cc/100?img=${img}" class="w-9 h-9 rounded-full mr-3 border-2 ${isTop3 ? 'border-yellow-400' : 'border-transparent'} shrink-0">`;
+        }
+
+        let containerClass = "bg-white rounded-xl py-2 px-3 flex items-center shadow-sm border border-gray-100 relative fade-in-up";
+        let nameClass = "font-bold text-gray-800 text-sm";
+        let valClass = "font-bold text-gray-600 font-mono text-base";
+
+        if (isMe) {
+            containerClass = "bg-blue-50 rounded-xl py-2 px-3 flex items-center shadow-md border-2 border-primary/30 relative overflow-hidden transform scale-[1.02] fade-in-up z-10 my-2";
+            nameClass = "font-bold text-primary text-sm";
+            valClass = "font-bold text-primary font-mono text-lg";
+        }
+        
+        let val = item.val || item.score || item.value || 0; 
+        if(item.teamCp !== undefined) val = item.teamCp; // Specifics based on API key
+        // Actually API usually returns numeric values in fields like `value` or `score` or same as key name (e.g. teamDistanceRs array contains objects with teamDistance)
+        // Let's try to find the value based on metric.
+        if(state.currentMetric === 'mileage') {
+            val = item.teamDistance || item.regionDistance || item.value || 0;
+            val = window.tools.fmtDistanct(val);
+        } else if (state.currentMetric === 'score') {
+             val = item.teamCp || item.regionCp || item.value || 0;
+        } else if (state.currentMetric === 'accuracy') {
+             val = item.teamRightAnswerPer || item.regionRightAnswerPer || 0;
+        } else if (state.currentMetric === 'lap') {
+             val = item.teamTodayPace || item.regionTodayPace || 0;
+             val = window.tools.fmtPace(val);
+        } else {
+             val = item.teamCp || item.regionCp || 0;
+        }
+
+        let unit = '';
+        if(state.currentMetric === 'mileage') unit = 'km';
+        else if (state.currentMetric === 'score') unit = '分';
+        else if (state.currentMetric === 'accuracy') unit = '%';
+        else if (state.currentMetric === 'count') unit = '个';
+
+        let itemName = item.coiName || item.nickName || item.name || '未知';
+
+        html += `
+            <div class="${containerClass}">
+                ${isMe ? '<div class="absolute right-0 top-0 bg-primary text-white text-[8px] px-1.5 py-0.5 rounded-bl-lg">我</div>' : ''}
+                ${rankIconHtml}
+                ${avatarHtml}
+                <div class="flex-1 min-w-0">
+                    <h4 class="${nameClass} truncate">${itemName}</h4>
+                    <p class="text-[10px] text-gray-400">${item.coiName || ''}</p>
+                </div>
+                <div class="text-right">
+                    <div class="${valClass}">${val}</div>
+                    <div class="text-[10px] text-gray-400">${unit}</div>
+                </div>
+            </div>
+        `;
+    });
+    
+    container.innerHTML = html || '<div class="text-center text-gray-400 py-10">暂无数据</div>';
+}
+
+// Exposed UI Functions
+
+window.switchMainTab = function(type) {
+    state.currentTab = type;
+    updateTabUI();
+    renderLeaderboard();
+};
+
+window.switchMetric = function(metric) {
+    state.currentMetric = metric;
+    updateMetricUI();
+    renderLeaderboard();
+};
+
+function updateTabUI() {
+    const tTeam = document.getElementById('tab-team');
+    const tInd = document.getElementById('tab-ind');
+    const active = "flex-1 py-2 rounded-full text-sm font-bold text-center transition-all duration-300 bg-white text-primary shadow-sm";
+    const inactive = "flex-1 py-2 rounded-full text-sm font-bold text-center transition-all duration-300 text-gray-500 hover:text-gray-700";
+    
+    if(state.currentTab === 'team') {
+        tTeam.className = active;
+        tInd.className = inactive;
+    } else {
+        tTeam.className = inactive;
+        tInd.className = active;
+    }
+}
+
+function updateMetricUI() {
+     document.querySelectorAll('.metric-btn').forEach(btn => {
+         btn.className = 'metric-btn bg-white text-gray-500 border border-gray-200 px-3 py-1 rounded-full text-xs font-bold shrink-0 active:bg-gray-50 transition-colors';
+     });
+     const activeBtn = document.getElementById(`metric-${state.currentMetric}`);
+     if(activeBtn) activeBtn.className = 'metric-btn bg-primary text-white px-3 py-1 rounded-full text-xs shadow-md shadow-blue-100 font-bold shrink-0 relative overflow-visible transition-colors';
+}
+
+// Drawer
+window.openDrawer = function() {
+    document.getElementById('drawer-backdrop').classList.remove('hidden');
+    document.getElementById('drawer').classList.remove('translate-y-full');
+};
+window.closeDrawer = function() {
+    document.getElementById('drawer-backdrop').classList.add('hidden');
+    document.getElementById('drawer').classList.add('translate-y-full');
+};
+function renderDrawer() {
+    const drawerContent = document.querySelector('#drawer .space-y-6');
+    // We can populate map options here
+}
+window.triggerJump = function(target) {
+    const map = state.mapList.find(m => m.mapName === target);
+    if(map) {
+        if(state.ocaId !== map.ocaId) {
+            state.ocaId = map.ocaId;
+            uni.setStorageSync(`rank-tpl-style3-map-${state.ecId}`, state.ocaId);
+            matchRsDetailQuery();
+            fetchRankDetail();
+        }
+    } else {
+        // If it's '高德地图' etc.
+        if(target.includes('地图')) {
+             alert("正在打开导航:" + target);
+        } else {
+             // Assume map name
+             const map2 = state.mapList.find(m => m.mapName === target);
+             if(map2) {
+                  state.ocaId = map2.ocaId;
+                  uni.setStorageSync(`rank-tpl-style3-map-${state.ecId}`, state.ocaId);
+                  matchRsDetailQuery();
+                  fetchRankDetail();
+             }
+        }
+    }
+    closeDrawer();
+};
+
+// Info Modal
+window.openInfoModal = function() { document.getElementById('infoModal').classList.remove('hidden'); }
+window.closeInfoModal = function() { document.getElementById('infoModal').classList.add('hidden'); }
+
+// Edit Modal
+window.openEditModal = function() {
+    const editNameInput = document.getElementById('editNameInput');
+    const selectedTeamText = document.getElementById('selectedTeamText');
+    
+    editNameInput.value = state.userInfo.nickName;
+    selectedTeamText.innerHTML = `<i class="fas fa-user-friends text-primary"></i> ${state.userInfo.coiName || '选择战队'}`;
+    
+    document.getElementById('editProfileModal').classList.remove('hidden');
+    
+    // Populate teams if empty (reuse mapList or fetch teams if different API? 
+    // Actually teams come from apiOnlineMcSignUpDetail which we might need to call if we want to allow changing teams.
+    // For simplicity, assuming mapList contains teams or we need to fetch cois.
+    // The signup page fetches apiOnlineMcSignUpDetail. Let's do that here too if needed.
+    getOnlineMcSignUpDetail(); 
+};
+window.closeEditModal = function() {
+    document.getElementById('editProfileModal').classList.add('hidden');
+    window.closeDropdown();
+};
+
+// Fetch teams for dropdown
+function getOnlineMcSignUpDetail() {
+    uni.request({
+        url: window.apiOnlineMcSignUpDetail,
+        header: { "Content-Type": "application/x-www-form-urlencoded", "token": state.token },
+        method: "POST",
+        data: { mcId: state.mcId },
+        success: (res) => {
+            const data = res.data.data;
+            if(data) {
+                const teams = data.coiRs;
+                renderEditDropdown(teams);
+            }
+        }
+    });
+}
+function renderEditDropdown(teams) {
+    const ul = document.querySelector('#dropdownMenu ul');
+    if(!ul) return;
+    let html = '';
+    teams.forEach(team => {
+        html += `<li onclick="selectEditOption('${team.coiId}', '${team.coiName}')" class="px-4 py-3 hover:bg-blue-50 cursor-pointer flex items-center justify-between border-b border-gray-50"><span class="font-bold flex items-center gap-2"><i class="fas fa-user-friends text-blue-200"></i> ${team.coiName}</span></li>`;
+    });
+    ul.innerHTML = html;
+}
+
+window.toggleDropdown = function(event) {
+    event.stopPropagation();
+    const dropdownMenu = document.getElementById('dropdownMenu');
+    const dropdownArrow = document.getElementById('dropdownArrow');
+    if (dropdownMenu.classList.contains('hidden')) {
+        dropdownMenu.classList.remove('hidden');
+        setTimeout(() => dropdownMenu.classList.add('dropdown-enter-active'), 10);
+        dropdownArrow.style.transform = 'rotate(180deg)';
+    } else { window.closeDropdown(); }
+};
+window.closeDropdown = function() {
+    const dropdownMenu = document.getElementById('dropdownMenu');
+    const dropdownArrow = document.getElementById('dropdownArrow');
+    dropdownMenu.classList.remove('dropdown-enter-active');
+    dropdownArrow.style.transform = 'rotate(0deg)';
+    setTimeout(() => dropdownMenu.classList.add('hidden'), 200);
+};
+window.closeDropdownOnClickOutside = function(event) {
+    if (!document.getElementById('editProfileModal').classList.contains('hidden')) {
+        const btn = document.getElementById('dropdownBtn');
+        const menu = document.getElementById('dropdownMenu');
+        if (menu && !menu.contains(event.target) && btn && !btn.contains(event.target)) {
+            window.closeDropdown();
+        }
+    }
+};
+let editingCoiId = 0;
+window.selectEditOption = function(id, name) {
+    editingCoiId = id;
+    document.getElementById('selectedTeamText').innerHTML = `<i class="fas fa-user-friends text-primary"></i> ${name}`;
+    window.closeDropdown();
+};
+
+window.saveProfile = function() {
+    const newName = document.getElementById('editNameInput').value;
+    if(!newName) { uni.showToast({title:'请输入昵称',icon:'none'}); return; }
+    
+    // Call API
+    uni.request({
+        url: window.apiOnlineMcSignUp,
+        header: { "Content-Type": "application/x-www-form-urlencoded", "token": state.token },
+        method: "POST",
+        data: {
+            mcId: state.mcId,
+            coiId: editingCoiId || 0, // If 0, maybe keep old? API requires valid ID usually.
+            selectTeam: 0,
+            nickName: newName
+        },
+        success: (res) => {
+            if(window.checkResCode(res)) {
+                uni.showToast({title:'修改成功',icon:'none'});
+                window.closeEditModal();
+                matchRsDetailQuery(); // Refresh
+            }
+        }
+    });
+};
+
+document.addEventListener('DOMContentLoaded', () => {
+    initRankListPage();
+});

+ 264 - 0
card/pages/tpl/style3/new/js/logic-signup.js

@@ -0,0 +1,264 @@
+var state = {
+    token: "",
+    ecId: 0,
+    from: "",
+    mcId: 0,
+    mcName: "",
+    mcType: 0,
+    beginSecond: null,
+    endSecond: null,
+    coiId: 0,
+    coiName: "",
+    teamNum: 0,
+    nickName: "",
+    coiRs: [],
+    
+    configParam: {
+        labelName: "昵称",
+        labelOrg: "组织",
+        subTitle: ""
+    },
+    
+    introduce: { title: "", content: "" },
+    activityRules: { title: "", content: "" },
+    
+    mcState: 0
+};
+
+function initSignupPage() {
+    const params = new URLSearchParams(window.location.search);
+    state.token = params.get('token') || '';
+    state.ecId = params.get('id') || 0;
+    state.from = params.get('from') || '';
+    
+    window.cardfunc.init(state.token, state.ecId);
+    window.cardfunc.getCardConfig(onConfigLoaded);
+    
+    // Bind events
+    // Dropdown logic is already in HTML script, but we can enhance or replace it.
+    // For now, I'll assume the HTML script functions are available or I should override them 
+    // if I want to connect them to state.
+    // The HTML script defines `selectOption` globally. I should probably piggyback on that or replace it.
+    
+    // Let's expose necessary functions to window so HTML can call them
+    window.selectOption = selectOption;
+    window.openConfirm = openConfirm;
+}
+
+function onConfigLoaded(config) {
+    // Load logic from signup.vue loadConfig
+    const pageConfig = window.cardfunc.parseCardConfig(config['signup']);
+    if(pageConfig) {
+        // Load params
+        const param = pageConfig.param;
+        if (param) {
+            if (param.labelName) state.configParam.labelName = param.labelName;
+            if (param.labelOrg) state.configParam.labelOrg = param.labelOrg;
+            if (param.subTitle) state.configParam.subTitle = param.subTitle;
+        }
+        
+        // Load introduce/rules if needed to display (HTML structure is static, but content could be dynamic)
+        // The new UI seems to have static "Match Intro". 
+        // If we want to use dynamic intro, we need to inject it.
+        // For now, let's stick to the static UI as per "migration to new UI" request, 
+        // unless the new UI requires dynamic text. The HTML provided has hardcoded intro.
+        // I will leave it static for now to preserve the new design.
+    }
+    
+    // Fetch Data
+    getCardDetailQuery();
+}
+
+function getCardDetailQuery() {
+    uni.request({
+        url: window.apiCardDetailQuery,
+        header: {
+            "Content-Type": "application/x-www-form-urlencoded",
+            "token": state.token
+        },
+        method: "POST",
+        data: { ecId: state.ecId },
+        success: (res) => {
+            const data = res.data.data;
+            if(data) {
+                state.mcType = data.mcType;
+                state.mcId = data.mcId;
+                state.mcName = data.mcName;
+                state.beginSecond = data.beginSecond;
+                state.endSecond = data.endSecond;
+                state.coiId = data.coiId;
+                state.coiName = data.coiName;
+                state.teamNum = data.teamNum;
+                state.nickName = data.nickName;
+                
+                state.mcState = window.tools.checkMcState(state.beginSecond, state.endSecond);
+                
+                // Update Time UI
+                updateTimeUI();
+                
+                // Fetch Dropdown Options
+                getOnlineMcSignUpDetail();
+                
+                // Pre-fill form if data exists
+                if(state.nickName) {
+                    document.getElementById('userName').value = state.nickName;
+                }
+                if(state.coiId > 0 && state.coiName) {
+                    // We will update dropdown after fetching details
+                }
+            }
+        }
+    });
+}
+
+function getOnlineMcSignUpDetail() {
+    uni.request({
+        url: window.apiOnlineMcSignUpDetail,
+        header: {
+            "Content-Type": "application/x-www-form-urlencoded",
+            "token": state.token,
+        },
+        method: "POST",
+        data: { mcId: state.mcId },
+        success: (res) => {
+            const data = res.data.data;
+            if(data) {
+                state.coiRs = data.coiRs;
+                if (!state.nickName && data.name) {
+                    state.nickName = data.name;
+                    document.getElementById('userName').value = state.nickName;
+                }
+                renderDropdown();
+                
+                // If user already selected a team (re-entering page)
+                if(state.coiId > 0) {
+                    // Find team name if not set
+                    const team = state.coiRs.find(t => t.coiId == state.coiId);
+                    if(team) selectOption(team.coiId, team.coiName, true); // true = skip UI toggle
+                }
+            }
+        }
+    });
+}
+
+function updateTimeUI() {
+    // Format: 11.28 08:00 - 11.30 18:00
+    const timeStr = window.tools.fmtMcTime2(state.beginSecond, state.endSecond);
+    // Need to find the element. The HTML has "11.28 08:00 - 11.30 18:00" hardcoded.
+    // I'll search for the element containing the time.
+    // The HTML structure: <span class="font-bold text-gray-800 font-mono truncate">...</span>
+    // I should give it an ID in the HTML update step, but for logic file I'll assume I can access it.
+    // I'll use document.querySelector to find it relative to "比赛时间" text or add an ID in Step 10.
+    // Let's assume I will add ID `matchTimeDisplay` to the span.
+    const el = document.getElementById('matchTimeDisplay');
+    if(el) el.innerText = timeStr;
+}
+
+function renderDropdown() {
+    const ul = document.querySelector('#dropdownMenu ul');
+    if(!ul) return;
+    
+    let html = '';
+    state.coiRs.forEach(team => {
+        html += `
+            <li onclick="selectOption('${team.coiId}', '${team.coiName}')" class="px-4 py-3 hover:bg-blue-50 cursor-pointer flex items-center justify-between border-b border-gray-50 last:border-0 transition-colors group/item">
+                <div class="flex items-center">
+                    <i class="fas fa-user-friends text-blue-200 mr-2 group-hover/item:text-primary transition-colors"></i>
+                    <span class="font-bold">${team.coiName}</span>
+                </div>
+                <i class="fas fa-check text-primary opacity-0 check-icon" data-val="${team.coiId}"></i>
+            </li>
+        `;
+    });
+    ul.innerHTML = html;
+}
+
+function selectOption(value, text, skipToggle = false) {
+    state.coiId = value;
+    state.coiName = text;
+    
+    document.getElementById('teamSelect').value = value;
+    const selectedText = document.getElementById('selectedText');
+    selectedText.innerHTML = '<i class="fas fa-user-friends text-primary mr-2"></i>' + text;
+    selectedText.classList.remove('text-gray-400', 'font-normal');
+    selectedText.classList.add('text-gray-800', 'font-bold');
+    
+    // Update check icons
+    const icons = document.querySelectorAll('.check-icon');
+    icons.forEach(icon => {
+        if(icon.getAttribute('data-val') == value) {
+            icon.classList.remove('opacity-0');
+        } else {
+            icon.classList.add('opacity-0');
+        }
+    });
+
+    if(!skipToggle) {
+        // closeDropdown is defined in HTML script. 
+        // Ideally I should move that logic here or call it.
+        // Since I'm injecting logic.js at the bottom, I can call global functions from HTML.
+        if(window.closeDropdown) window.closeDropdown();
+    }
+}
+
+function openConfirm() {
+    if(!window.checkToken(state.token)) return;
+    
+    const nameInput = document.getElementById('userName');
+    state.nickName = nameInput.value.trim();
+    
+    if (!state.nickName) {
+        uni.showToast({ title: `请填写${state.configParam.labelName}`, icon: 'none' });
+        return;
+    }
+    if (!state.coiId) {
+        uni.showToast({ title: `请选择${state.configParam.labelOrg}`, icon: 'none' });
+        return;
+    }
+    
+    document.getElementById('confirmName').innerText = state.nickName;
+    document.getElementById('confirmTeamText').innerText = state.coiName;
+    document.getElementById('confirmModal').classList.remove('hidden');
+}
+
+// Overwrite the HTML's onclick="location.href='#'" for confirm button
+// I'll bind the event dynamically in init or replace the HTML in Step 10.
+// Better to bind dynamically.
+function confirmSignup() {
+    uni.request({
+        url: window.apiOnlineMcSignUp,
+        header: {
+            "Content-Type": "application/x-www-form-urlencoded",
+            "token": state.token,
+        },
+        method: "POST",
+        data: {
+            mcId: state.mcId,
+            coiId: state.coiId,
+            selectTeam: 0, // Default
+            nickName: state.nickName
+        },
+        success: (res) => {
+            if (window.checkResCode(res)) {
+                uni.showToast({ title: '比赛报名成功!', icon: 'none' });
+                setTimeout(() => {
+                    const queryString = `token=${state.token}&id=${state.ecId}`;
+                    window.location.href = `ranklist.html?${queryString}`;
+                }, 1500);
+            }
+        },
+        fail: () => {
+            uni.showToast({ title: '出错了,报名失败', icon: 'none' });
+        }
+    });
+}
+
+// Hook up confirm button
+document.addEventListener('DOMContentLoaded', () => {
+    // Wait for init
+    const confirmBtn = document.querySelector('#confirmModal button:last-child');
+    if(confirmBtn) {
+        confirmBtn.removeAttribute('onclick'); // Remove inline handler
+        confirmBtn.addEventListener('click', confirmSignup);
+    }
+});

文件差異過大導致無法顯示
+ 0 - 0
card/pages/tpl/style3/new/js/multiavatar.min.js


+ 205 - 0
card/pages/tpl/style3/new/js/tools.js

@@ -0,0 +1,205 @@
+(function() {
+    window.tools = {
+        
+        // 判断对象数组中指定属性是否有某个值的数据
+        objArrHasValue(objArr, key, value) {
+            return objArr.find(obj => obj[key] === value) !== undefined;
+        },
+    
+        // 获取对象数组中指定属性为指定值的对象
+        objArrGetObjByValue(objArr, key, value) {
+            return objArr.find(obj => obj[key] === value);
+        },
+        
+        // 对url追加项目版本号,用于页面更新后用户端的强制刷新
+        urlAddVer(url) {
+            let newUrl = url;
+            // Mock version for H5
+            const version_number = '1.0.0';
+    
+            if (newUrl.indexOf('_v=') !== -1) {
+                return newUrl;
+            }
+    
+            if (newUrl.indexOf('?') !== -1) {
+                newUrl += "&_v=" + version_number;
+            } else {
+                newUrl += "?_v=" + version_number;
+            }
+            console.log("[urlAddVer] newUrl", newUrl);
+            return newUrl;
+        },
+    
+        // 导航到彩图奔跑APP内的某个页面或执行APP内部的某些功能
+        appAction(url, actType = "") {
+            console.log("appAction", url);
+            // Safe guard for getApp audio
+            try {
+                if(window.getApp && window.getApp().$audio) {
+                    window.getApp().$audio.destroy();
+                    window.getApp().$audio.pause();
+                }
+            } catch(e) {}
+    
+            if (url.indexOf('http') !== -1) { // http 或 https 开头的网址
+                window.location.href = this.urlAddVer(url);
+            } else if (url == "reload") {
+                window.location.reload();
+            } else if (actType == "uni.navigateTo") {
+                uni.navigateTo({
+                    url: this.urlAddVer(url)
+                });
+            } else {
+                // Native bridge simulation or actual call
+                if(window.appAction) {
+                     window.appAction(url);
+                } else {
+                     window.location.href = url;
+                }
+            }
+        },
+    
+        // 格式化赛事时间 09-09 16:48
+        fmtMcTime(timestamp) {
+            var date = new Date(timestamp * 1000); 
+            var M = (date.getMonth() + 1 < 10 ? '0' + (date.getMonth() + 1) : date.getMonth() + 1) + '-';
+            var D = (date.getDate() < 10 ? '0' + date.getDate() : date.getDate()) + ' ';
+            var h = (date.getHours() < 10 ? '0' + date.getHours() : date.getHours()) + ':';
+            var m = (date.getMinutes() < 10 ? '0' + date.getMinutes() : date.getMinutes());
+            const timeStr = M + D + h + m;
+            return timeStr;
+        },
+    
+        // 获取活动时间 09-09 16:48 至 09-30 16:48
+        getActtime(beginSecond, endSecond) {
+            const acttime = this.fmtMcTime(beginSecond) + " 至 " + this.fmtMcTime(endSecond);
+            return acttime;
+        },
+    
+        // 格式化赛事时间 2024.9.9-30 2024.9.9-10.30 2024.9.9-2025.10.30
+        fmtMcTime2(timestamp1, timestamp2) {
+            const date1 = new Date(timestamp1 * 1000); 
+            const date2 = new Date(timestamp2 * 1000); 
+    
+            const Y1 = date1.getFullYear();
+            const Y2 = date2.getFullYear();
+            const M1 = date1.getMonth() + 1;
+            const M2 = date2.getMonth() + 1;
+            const D1 = date1.getDate();
+            const D2 = date2.getDate();
+    
+            var timeStr1 = Y1 + '.' + M1 + '.' + D1;
+            var timeStr2 = '';
+    
+            if (Y2 != Y1) {
+                timeStr2 += Y2 + '.' + M2 + '.' + D2;
+            } else if (M2 != M1) {
+                timeStr2 += M2 + '.' + D2;
+            } else if (D2 != D1) {
+                timeStr2 += D2;
+            }
+    
+            var timeStr = timeStr1;
+            if (timeStr2.length > 0) {
+                timeStr += '-' + timeStr2;
+            }
+            return timeStr;
+        },
+    
+        // 判断赛事/活动状态 0: 未开始  1: 进行中  2: 已结束
+        checkMcState(beginSecond, endSecond) {
+            let mcState = 0; // 未开始
+            if (beginSecond > 0 && endSecond > 0) {
+                const now = Date.now() / 1000;
+                const dif1 = beginSecond - now;
+                const dif2 = endSecond - now;
+                if (dif1 > 0) {
+                    mcState = 0; // 未开始
+                } else if (dif2 > 0) {
+                    mcState = 1; // 进行中
+                } else {
+                    mcState = 2; // 已结束
+                }
+            }
+            return mcState;
+        },
+    
+        // 动态创建<style>标签
+        loadCssCode(cssCode, styleId = "css-custom") {
+            this.removeCssCode(styleId);
+            var style = window.document.createElement("style");
+            style.type = "text/css";
+            style.id = styleId;
+            if (style.styleSheet) {
+                style.styleSheet.cssText = cssCode;
+            } else {
+                style.appendChild(document.createTextNode(cssCode));
+            }
+            document.getElementsByTagName("head")[0].appendChild(style);
+        },
+    
+        // 删除之前动态创建的<style>标签
+        removeCssCode(styleId = "css-custom") {
+            var oldCss = document.getElementById(styleId);
+            if (oldCss != null) {
+                document.getElementsByTagName("head")[0].removeChild(oldCss);
+            }
+        },
+    
+        // uni-data-select 组件,根据选中的值获取对应的文本 (Mock for pure JS)
+        getSelectedText(obj, value) {
+            if(!obj) return '';
+            const selectedOption = obj.find(option => option.value === value);
+            return selectedOption ? selectedOption.text : '';
+        },
+    
+        objectToQueryString(obj) {
+            return Object.keys(obj).map(k => k + '=' + obj[k]).join('&');
+        },
+    
+        // 秒数转换成 XX天XX小时
+        convertSecondsToDHM(seconds) {
+            var days = Math.floor(seconds / (3600 * 24));
+            var hours = Math.floor((seconds % (3600 * 24)) / 3600);
+            var minutes = Math.floor((seconds % (3600 * 24)) % 3600 / 60);
+            if (days > 0)
+                return `${days}天${hours.toString().padStart(2, '0')}小时`;
+            else
+                return `${hours.toString().padStart(2, '0')}小时${minutes.toString().padStart(2, '0')}分钟`;
+        },
+    
+        // 秒数转换成时分秒
+        convertSecondsToHMS(seconds, style = 0) {
+            if (!(seconds > 0)) {
+                return '--';
+            }
+            var hours = Math.floor(seconds / 3600);
+            var minutes = Math.floor((seconds % 3600) / 60);
+            var remainingSeconds = Math.floor(seconds % 60);
+            if (style == 0)
+                return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
+            else if (style == 1) {
+                if (hours > 0)
+                    return `${hours}h${minutes}′${remainingSeconds.toString().padStart(2, '0')}″`;
+                else
+                    return `${minutes}′${remainingSeconds.toString().padStart(2, '0')}″`;
+            } else if (style == 2) {
+                if (hours > 0)
+                    return `${hours*60+minutes}′${remainingSeconds.toString().padStart(2, '0')}″`;
+                else
+                    return `${minutes}′${remainingSeconds.toString().padStart(2, '0')}″`;
+            }
+        },
+        
+        // ... other simple validation helpers can be kept as is ...
+        isPhone(val) {
+            var patrn = /^(((1[3456789][0-9]{1})|(15[0-9]{1}))+\d{8})$/
+            if (!patrn.test(val) || val === '') {
+                uni.showToast({ title: '手机号格式不正确', icon: 'none' })
+                return false
+            } else {
+                return true
+            }
+        }
+    }
+})();

+ 187 - 0
card/pages/tpl/style3/new/js/uni-compat.js

@@ -0,0 +1,187 @@
+(function() {
+    window.uni = window.uni || {};
+
+    // 1. Network Requests
+    uni.request = function(options) {
+        let headers = options.header || {};
+        let body = undefined;
+        let url = options.url;
+
+        // Handle Content-Type and Body
+        if (options.method === 'POST') {
+            if (!headers['Content-Type']) {
+                headers['Content-Type'] = 'application/json';
+            }
+
+            if (headers['Content-Type'].includes('application/x-www-form-urlencoded')) {
+                if (options.data) {
+                    body = Object.keys(options.data)
+                        .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(options.data[key])}`)
+                        .join('&');
+                }
+            } else {
+                // Default to JSON
+                if (options.data) {
+                    body = JSON.stringify(options.data);
+                }
+            }
+        } else if (options.method === 'GET' && options.data) {
+             // Append query params for GET
+            const queryString = Object.keys(options.data)
+                .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(options.data[key])}`)
+                .join('&');
+            url += (url.includes('?') ? '&' : '?') + queryString;
+        }
+
+        fetch(url, {
+            method: options.method || 'GET',
+            headers: headers,
+            body: body
+        })
+        .then(response => {
+            // You might want to handle non-200 status codes here if needed, 
+            // but uni.request success callback usually fires even for 4xx/5xx 
+            // with the statusCode in the result.
+            return response.json().then(data => ({
+                data: data,
+                statusCode: response.status,
+                header: response.headers
+            })).catch(() => ({
+                data: null, // or text
+                statusCode: response.status,
+                header: response.headers
+            }));
+        })
+        .then(res => {
+            if (options.success) {
+                options.success(res);
+            }
+        })
+        .catch(err => {
+            console.error('uni.request error:', err);
+            if (options.fail) {
+                options.fail(err);
+            }
+        });
+    };
+
+    // 2. Storage
+    uni.setStorageSync = function(key, data) {
+        try {
+            // uniApp stores data as is, but localStorage only supports strings.
+            // We wrap it to handle types like numbers or booleans correctly upon retrieval if we wanted to match uni strictly,
+            // but JSON.stringify is the standard web way.
+            window.localStorage.setItem(key, JSON.stringify(data));
+        } catch (e) {
+            console.error('setStorageSync error', e);
+        }
+    };
+
+    uni.getStorageSync = function(key) {
+        try {
+            const value = window.localStorage.getItem(key);
+            if (value === null) return ''; // uniApp returns empty string if not found
+            return JSON.parse(value);
+        } catch (e) {
+            return window.localStorage.getItem(key); // Fallback for non-JSON
+        }
+    };
+    
+    uni.setStorage = function(obj) {
+        try {
+            uni.setStorageSync(obj.key, obj.data);
+            if(obj.success) obj.success();
+        } catch(e) {
+            if(obj.fail) obj.fail(e);
+        }
+    }
+
+    uni.getStorage = function(obj) {
+        try {
+            const res = uni.getStorageSync(obj.key);
+            if(obj.success) obj.success({ data: res });
+        } catch(e) {
+            if(obj.fail) obj.fail(e);
+        }
+    }
+    
+    uni.removeStorageSync = function(key) {
+        window.localStorage.removeItem(key);
+    }
+
+    // 3. Interaction
+    uni.showToast = function(options) {
+        // Simple alert or console log for now. 
+        // In a real implementation, creating a DOM element for toast is better.
+        // const title = options.title || '';
+        // const icon = options.icon || 'none';
+        // console.log(`[Toast] ${title} (${icon})`);
+        // // alert(title); // Alert is too intrusive
+        
+        // Create a simple custom toast
+        const toast = document.createElement('div');
+        toast.style.position = 'fixed';
+        toast.style.top = '50%';
+        toast.style.left = '50%';
+        toast.style.transform = 'translate(-50%, -50%)';
+        toast.style.backgroundColor = 'rgba(0,0,0,0.7)';
+        toast.style.color = '#fff';
+        toast.style.padding = '10px 20px';
+        toast.style.borderRadius = '5px';
+        toast.style.zIndex = '9999';
+        toast.innerText = options.title;
+        document.body.appendChild(toast);
+        setTimeout(() => {
+            document.body.removeChild(toast);
+        }, options.duration || 1500);
+    };
+    
+    uni.showLoading = function(options) {
+        // console.log('[Loading]', options);
+    }
+    
+    uni.hideLoading = function() {
+        // console.log('[HideLoading]');
+    }
+
+    // 4. Navigation
+    uni.navigateTo = function(options) {
+        window.location.href = options.url;
+    };
+    
+    // 5. System Info
+    uni.getSystemInfoSync = function() {
+        return {
+            appVersion: '1.0.0', // Mock
+            platform: 'devtools' // Mock
+        };
+    };
+
+    // 6. Global App Object Mock
+    window.getApp = function() {
+        return {
+            globalData: {
+                defaultMatchLogo: 'https://orienteering.beswell.com/card/nanning/logo.png' // Placeholder
+            },
+            $cardconfigType: 'remote'
+        };
+    };
+
+    // 7. App Action Helper (Mocking native bridge)
+    window.appAction = function(url, actType) {
+        console.log('[appAction]', url);
+        if (url.startsWith('action://')) {
+            if (url.includes('to_login')) {
+                alert('请先登录 (模拟跳转)');
+            } else if (url.includes('to_home')) {
+                window.location.href = 'index.html';
+            } else if (url.includes('to_detail')) {
+                 // Extract ID if possible or just alert
+                 alert('正在进入比赛... (模拟跳转)');
+            }
+        } else {
+             window.location.href = url;
+        }
+    };
+
+})();

+ 146 - 0
card/pages/tpl/style3/new/mock_flutter.js

@@ -0,0 +1,146 @@
+(function (window) {
+    'use strict';
+  
+    // Local Logger for MockFlutter - always enabled
+    const Logger = {
+        log: function() {
+            console.log.apply(console, arguments);
+        },
+        warn: function() {
+            console.warn.apply(console, arguments);
+        },
+        error: function() {
+            console.error.apply(console, arguments);
+        },
+        group: function() {
+            console.group.apply(console, arguments);
+        },
+        groupEnd: function() {
+            console.groupEnd.apply(console, arguments);
+        }
+    };
+  
+    Logger.log('%c [MockFlutter] 已加载,当前处于开发调试模式 ', 'background: #42b983; color: white; font-size: 14px; padding: 4px;');
+  
+    // 1. 模拟 uni.postMessage
+    if (!window.uni) {
+      window.uni = {};
+    }
+  
+    window.uni.postMessage = function (message) {
+      const payload = message.data || {};
+      const action = payload.action;
+      const data = payload.data;
+  
+      Logger.group('%c [MockFlutter] 收到 App 指令 ', 'color: #1aad19; font-weight: bold;');
+      Logger.log('Action:', action);
+      Logger.log('Data:', data);
+      Logger.groupEnd();
+  
+      // 模拟具体行为反馈
+      switch (action) {
+        case 'openMap':
+          alert(`[模拟App] 正在打开地图\n纬度: ${data.latitude}\n经度: ${data.longitude}\n名称: ${data.name}`);
+          break;
+        case 'openMatch':
+          alert(`[模拟App] 正在打开赛事详情\nID: ${data.id}\n类型: ${data.type}`);
+          break;
+        case 'openActivityList':
+          alert(`[模拟App] 正在打开活动列表\nID: ${data.id}\n名称: ${data.mapName}`);
+          break;
+        case 'back':
+          Logger.log('[模拟App] 执行返回操作');
+          // alert('[模拟App] 执行返回操作'); // 弹窗太多会烦,这里只打印
+          break;
+        case 'toHome':
+          alert('[模拟App] 执行返回 App 首页');
+          break;
+        case 'toLogin':
+          alert('[模拟App] 执行跳转登录页');
+          break;
+        case 'setTitle':
+          alert(`[模拟App] 设置标题为: ${data.title}`);
+          break;
+        case 'shareWx':
+          alert(`[模拟App] 正在调起微信分享\n标题: ${data.title}\n链接: ${data.url}`);
+          break;
+        case 'launchWxMini':
+          alert(`[模拟App] 正在打开微信小程序\n原始ID: ${data.username}\n路径: ${data.path}`);
+          break;
+        case 'saveImage':
+          alert(`[模拟App] 正在保存图片 (Base64长度: ${data.base64 ? data.base64.length : '0'})`);
+          break;
+        case 'getToken':
+            Logger.log('[模拟App] 收到获取Token请求,1秒后模拟回调...');
+            setTimeout(function() {
+                if (window.Bridge && window.Bridge.receiveToken) {
+                    window.Bridge.receiveToken('MOCK_TOKEN_FOR_BRIDGE_12345');
+                    Logger.log('[模拟App] 已通过 Bridge.receiveToken 回调 Token');
+                } else {
+                    Logger.warn('[模拟App] 无法回调 Token,Bridge.receiveToken 未定义');
+                }
+            }, 1000);
+            break;
+
+        // --- 新增 Mock 方法 ---
+        case 'previewImage':
+            alert(`[模拟App] 预览图片\n当前: ${data.current}\n列表: ${JSON.stringify(data.urls)}`);
+            break;
+        case 'makePhoneCall':
+            alert(`[模拟App] 拨打电话: ${data.phoneNumber}`);
+            break;
+        case 'setClipboardData':
+            alert(`[模拟App] 剪贴板内容已设置: ${data.data}`);
+            break;
+        case 'showToast':
+            Logger.log(`[模拟App] ShowToast: ${data.title} (icon: ${data.icon})`);
+            break;
+        case 'showLoading':
+            Logger.log(`[模拟App] ShowLoading: ${data.title}`);
+            break;
+        case 'hideLoading':
+            Logger.log(`[模拟App] HideLoading`);
+            break;
+        case 'showModal':
+            const confirmed = confirm(`[模拟App] Modal: ${data.title}\n${data.content || ''}`);
+            Logger.log(`[模拟App] Modal result: ${confirmed ? 'Confirm' : 'Cancel'}`);
+            break;
+
+        default:
+          Logger.log(`[模拟App] 收到未知指令: ${action}, 数据: `, data);
+          // alert(`[模拟App] 收到未知指令: ${action}`);
+      }
+    };
+  
+    // 2. 模拟旧版注入对象 (防止报错,并使用可读中文)
+    if (!window.share_wx) {
+        window.share_wx = {
+            postMessage: function(jsonStr) {
+                Logger.log('[MockFlutter] share_wx.postMessage 收到:', jsonStr);
+                alert('[模拟App/旧通道] 微信分享:\n' + jsonStr);
+            }
+        };
+    }
+    
+    if (!window.wx_launch_mini) {
+        window.wx_launch_mini = {
+            postMessage: function(jsonStr) {
+                Logger.log('[MockFlutter] wx_launch_mini.postMessage 收到:', jsonStr);
+                alert('[模拟App/旧通道] 打开小程序:\n' + jsonStr);
+            }
+        };
+    }
+
+    if (!window.save_base64) {
+        window.save_base64 = {
+            postMessage: function(base64Str) {
+                Logger.log('[MockFlutter] save_base64.postMessage 收到 (Base64长度):', base64Str ? base64Str.length : '0');
+                alert(`[模拟App/旧通道] 保存图片 (Base64长度: ${base64Str ? base64Str.length : '0'})`);
+            }
+        };
+    }
+  
+    // 3. 模拟 UserAgent (可选)
+    // 某些逻辑可能会检查 navigator.userAgent,这里可以根据需要注入,但目前 SDK 不依赖这个判断
+  
+  })(window);

+ 275 - 0
card/pages/tpl/style3/new/ranklist.html

@@ -0,0 +1,275 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <script src="https://cdn.tailwindcss.com"></script>
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
+    <script>
+        tailwind.config = {
+            theme: {
+                extend: {
+                    colors: {
+                        primary: '#3372ac',
+                    },
+                    animation: {
+                        marquee: 'marquee 25s linear infinite',
+                    },
+                    keyframes: {
+                        marquee: {
+                            '0%': { transform: 'translateX(100%)' },
+                            '100%': { transform: 'translateX(-100%)' },
+                        }
+                    }
+                }
+            }
+        }
+    </script>
+    <style>
+        @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap');
+        body { font-family: 'Nunito', sans-serif; }
+        .no-scrollbar::-webkit-scrollbar { display: none; }
+        .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
+        .snap-mandatory { scroll-behavior: smooth; }
+        .fade-in-up { animation: fadeInUp 0.4s ease-out forwards; opacity: 0; transform: translateY(10px); }
+        @keyframes fadeInUp { to { opacity: 1; transform: translateY(0); } }
+        
+        .dropdown-enter { opacity: 0; transform: translateY(-10px) scale(0.95); }
+        .dropdown-enter-active { opacity: 1; transform: translateY(0) scale(1); transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); }
+    </style>
+</head>
+<body class="bg-slate-50 relative pb-24" onclick="closeDropdownOnClickOutside(event)">
+
+    <!-- 1. 顶部 Header -->
+    <div class="relative w-full h-[260px]">
+        <img src="https://orienteering.beswell.com/card/nanning/cardtop1122-2.jpg" 
+             class="w-full h-full object-cover shadow-md" alt="Header BG">
+        <div class="absolute inset-0 bg-gradient-to-b from-primary/60 via-transparent to-black/30"></div>
+
+        <div class="absolute top-0 w-full pt-10 px-5 flex justify-between items-center z-10 text-white">
+            <button class="bg-black/20 backdrop-blur-sm p-2 rounded-full w-9 h-9 flex items-center justify-center active:scale-90 transition">
+                <i class="fas fa-chevron-left"></i>
+            </button>
+            <h1 class="text-lg font-bold tracking-wider shadow-sm">实时赛况</h1>
+            <button onclick="openInfoModal()" class="bg-black/20 backdrop-blur-sm px-3 py-1.5 rounded-full text-xs font-semibold flex items-center gap-1 active:scale-90 transition">
+                <i class="fas fa-question-circle"></i> 说明
+            </button>
+        </div>
+
+        <!-- 底部公告跑马灯 -->
+        <div class="absolute bottom-[35px] w-full z-10 px-4">
+            <div class="bg-black/40 backdrop-blur-md rounded-full py-1.5 px-3 flex items-center gap-2 overflow-hidden border border-white/10 shadow-lg">
+                <i class="fas fa-bullhorn text-yellow-300 text-xs shrink-0"></i>
+                <div class="flex-1 overflow-hidden relative h-4">
+                    <div class="absolute whitespace-nowrap text-[10px] text-white animate-marquee leading-4">
+                        当前总题目: 1500道 | 总里程: 5024km | 总打点数: 8900个 | 距离比赛结束还有 1天 5小时 | 加油!冲鸭!
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <!-- 2. 个人信息卡 -->
+    <div class="px-4 -mt-8 relative z-20">
+        <div class="bg-white rounded-xl shadow-lg p-4 flex justify-between items-center border border-white/50">
+            <div class="flex items-center gap-3">
+                <div class="relative">
+                    <img src="https://i.pravatar.cc/100?img=12" class="w-12 h-12 rounded-full border-2 border-slate-100">
+                    <div class="absolute -bottom-1 -right-1 bg-primary text-white text-[8px] px-1.5 py-0.5 rounded-full border border-white">Lv.3</div>
+                </div>
+                <div>
+                    <!-- 昵称 & 编辑按钮 -->
+                    <h2 class="font-bold text-gray-800 text-sm flex items-center gap-1">
+                        <span id="profileName">奔跑的蜗牛</span>
+                        <button onclick="openEditModal()" class="text-xs text-primary/60 p-1 hover:text-primary transition-colors"><i class="fas fa-pen"></i></button>
+                    </h2>
+                    <!-- 战队 -->
+                    <p class="text-xs text-gray-500 mt-0.5 flex items-center gap-1">
+                        <i class="fas fa-user-friends text-blue-300 text-[10px]"></i>
+                        <span id="profileTeam">飞虎队</span>
+                    </p>
+                </div>
+            </div>
+            <div class="text-center pl-4 border-l border-gray-100">
+                <div id="userScore" class="text-2xl font-black text-primary font-mono leading-none">120</div>
+                <div class="text-[10px] text-gray-400 mt-1">当前积分</div>
+            </div>
+        </div>
+    </div>
+
+    <!-- 3. 排行榜 Tabs -->
+    <div class="mt-6 px-4">
+        
+        <!-- 主榜单切换:轻量化分段控制器风格 -->
+        <div class="bg-slate-100 p-1 rounded-full flex gap-1 mb-4 relative shadow-inner">
+            <button onclick="switchMainTab('team')" id="tab-team" 
+                    class="flex-1 py-2 rounded-full text-sm font-bold text-center transition-all duration-300 bg-white text-primary shadow-sm">
+                团队榜
+            </button>
+            <button onclick="switchMainTab('individual')" id="tab-ind" 
+                    class="flex-1 py-2 rounded-full text-sm font-bold text-center transition-all duration-300 text-gray-500 hover:text-gray-700">
+                个人榜
+            </button>
+        </div>
+        
+        <!-- 维度切换 -->
+        <div class="flex gap-2 justify-center pt-4 overflow-x-auto no-scrollbar pb-2" id="metric-tabs">
+            <button onclick="switchMetric('score')" id="metric-score" class="metric-btn bg-primary text-white px-3 py-1 rounded-full text-xs shadow-md shadow-blue-100 font-bold shrink-0 relative overflow-visible">
+                积分
+                <span id="prize-badge" class="absolute -top-2 -right-2 bg-red-500 text-white text-[9px] px-1.5 py-0.5 rounded-full shadow-sm border border-white z-10 leading-none">奖</span>
+            </button>
+            <button onclick="switchMetric('mileage')" id="metric-mileage" class="metric-btn bg-white text-gray-500 border border-gray-200 px-3 py-1 rounded-full text-xs font-bold shrink-0 active:bg-gray-50 transition-colors">里程</button>
+            <button onclick="switchMetric('accuracy')" id="metric-accuracy" class="metric-btn bg-white text-gray-500 border border-gray-200 px-3 py-1 rounded-full text-xs font-bold shrink-0 active:bg-gray-50 transition-colors">答题正确率</button>
+            <button onclick="switchMetric('count')" id="metric-count" class="metric-btn bg-white text-gray-500 border border-gray-200 px-3 py-1 rounded-full text-xs font-bold shrink-0 active:bg-gray-50 transition-colors">打点数</button>
+            <button onclick="switchMetric('lap')" id="metric-lap" class="metric-btn bg-white text-gray-500 border border-gray-200 px-3 py-1 rounded-full text-xs font-bold shrink-0 active:bg-gray-50 transition-colors">单圈用时</button>
+        </div>
+    </div>
+
+    <!-- 4. 排行榜列表容器 -->
+    <!-- 修改点:space-y-2 (间距变小), 内层渲染 logic 变 p-2 -->
+    <div id="leaderboard-container" class="px-4 mt-1 space-y-2 mb-24 min-h-[300px]"></div>
+
+    <!-- 5. 底部悬浮栏 -->
+    <div class="fixed bottom-0 w-full bg-white border-t border-gray-100 shadow-[0_-5px_20px_rgba(0,0,0,0.05)] px-4 py-3 flex items-center z-40 safe-area-bottom">
+        <button onclick="openDrawer()" class="w-full bg-gradient-to-r from-orange-500 to-red-600 text-white py-3 rounded-full font-bold shadow-lg shadow-orange-100 transform active:scale-[0.98] transition flex items-center justify-center gap-2 text-base">
+            <i class="fas fa-flag-checkered animate-pulse"></i> 进入比赛
+        </button>
+    </div>
+
+    <!-- ================= 模态框区域 ================= -->
+
+    <!-- A. 编辑个人资料模态框 -->
+    <div id="editProfileModal" class="fixed inset-0 z-[60] hidden flex items-center justify-center">
+        <div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onclick="closeEditModal()"></div>
+        <div class="bg-white w-[85%] rounded-2xl p-6 relative z-10 shadow-2xl animate-[fadeInUp_0.3s_ease-out]">
+            <h3 class="font-bold text-lg mb-6 text-gray-800 text-center">修改报名信息</h3>
+            <div class="space-y-5">
+                <div>
+                    <label class="block text-sm font-bold text-gray-500 mb-2">我的昵称</label>
+                    <input type="text" id="editNameInput" value="奔跑的蜗牛" 
+                           class="w-full bg-slate-50 border border-gray-200 rounded-lg px-4 py-2.5 text-sm font-bold text-gray-800 focus:outline-none focus:ring-1 focus:ring-primary focus:bg-white transition-colors">
+                </div>
+                <div>
+                    <label class="block text-sm font-bold text-gray-500 mb-2">所属战队</label>
+                    <div class="relative group">
+                        <button type="button" id="dropdownBtn" onclick="toggleDropdown(event)" 
+                                class="w-full bg-slate-50 border border-gray-200 rounded-lg px-4 py-2.5 text-sm font-bold text-left flex items-center justify-between focus:outline-none focus:ring-1 focus:ring-primary transition-all">
+                            <span id="selectedTeamText" class="text-gray-800 flex items-center gap-2">
+                                <i class="fas fa-user-friends text-primary"></i> 飞虎队
+                            </span>
+                            <i class="fas fa-chevron-circle-down text-gray-400 transition-transform duration-300" id="dropdownArrow"></i>
+                        </button>
+                        <div id="dropdownMenu" class="hidden absolute top-[110%] left-0 w-full bg-white rounded-xl shadow-2xl border border-gray-100 overflow-hidden z-50 dropdown-enter max-h-48 overflow-y-auto">
+                            <ul class="text-sm text-gray-700">
+                                <li onclick="selectOption('飞虎队')" class="px-4 py-3 hover:bg-blue-50 cursor-pointer flex items-center justify-between border-b border-gray-50"><span class="font-bold flex items-center gap-2"><i class="fas fa-user-friends text-blue-200"></i> 飞虎队</span></li>
+                                <li onclick="selectOption('火箭队')" class="px-4 py-3 hover:bg-blue-50 cursor-pointer flex items-center justify-between border-b border-gray-50"><span class="font-bold flex items-center gap-2"><i class="fas fa-user-friends text-blue-200"></i> 火箭队</span></li>
+                                <li onclick="selectOption('摸鱼队')" class="px-4 py-3 hover:bg-blue-50 cursor-pointer flex items-center justify-between border-b border-gray-50"><span class="font-bold flex items-center gap-2"><i class="fas fa-user-friends text-blue-200"></i> 摸鱼队</span></li>
+                                <li onclick="selectOption('汪汪队')" class="px-4 py-3 hover:bg-blue-50 cursor-pointer flex items-center justify-between"><span class="font-bold flex items-center gap-2"><i class="fas fa-user-friends text-blue-200"></i> 汪汪队</span></li>
+                            </ul>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="flex gap-3 mt-8">
+                <button onclick="closeEditModal()" class="flex-1 py-2.5 rounded-full border border-gray-200 text-gray-500 font-bold text-sm">取消</button>
+                <button onclick="saveProfile()" class="flex-1 py-2.5 rounded-full bg-primary text-white font-bold text-sm shadow-lg shadow-blue-200">保存修改</button>
+            </div>
+        </div>
+    </div>
+
+    <!-- B. 说明模态框 -->
+    <div id="infoModal" class="fixed inset-0 z-50 hidden transition-opacity duration-300">
+        <div class="absolute inset-0 bg-slate-900/70 backdrop-blur-sm" onclick="closeInfoModal()"></div>
+        <div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[90%] bg-white rounded-3xl p-6 shadow-2xl">
+            <button onclick="closeInfoModal()" class="absolute -top-12 right-0 text-white/80 hover:text-white w-10 h-10 border border-white/30 rounded-full flex items-center justify-center">
+                <i class="fas fa-times text-lg"></i>
+            </button>
+            <h3 class="text-center font-bold text-xl mb-4 text-primary border-b border-gray-100 pb-3">
+                <i class="fas fa-book-open mr-2"></i>比赛规则说明
+            </h3>
+            <div id="modalSlider" class="overflow-x-auto flex snap-x snap-mandatory no-scrollbar space-x-4 pb-2" onscroll="updateDots()">
+                <div class="snap-center shrink-0 w-full bg-blue-50 rounded-2xl p-5 text-center border border-blue-100">
+                    <div class="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-3"><i class="fas fa-map-marker-alt text-xl text-primary"></i></div>
+                    <h4 class="font-bold text-base text-gray-800 mb-1">1. 定向寻宝</h4>
+                    <p class="text-gray-600 text-xs leading-relaxed">比赛设置12个隐藏打卡点。</p>
+                </div>
+                <div class="snap-center shrink-0 w-full bg-blue-50 rounded-2xl p-5 text-center border border-blue-100">
+                    <div class="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-3"><i class="fas fa-mobile-alt text-xl text-primary"></i></div>
+                    <h4 class="font-bold text-base text-gray-800 mb-1">2. 扫码打卡</h4>
+                    <p class="text-gray-600 text-xs leading-relaxed">到达点位后,使用APP扫描二维码。</p>
+                </div>
+                <div class="snap-center shrink-0 w-full bg-blue-50 rounded-2xl p-5 text-center border border-blue-100">
+                    <div class="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-3"><i class="fas fa-medal text-xl text-primary"></i></div>
+                    <h4 class="font-bold text-base text-gray-800 mb-1">3. 完赛奖励</h4>
+                    <p class="text-gray-600 text-xs leading-relaxed">前50名有精美礼品!加油!</p>
+                </div>
+            </div>
+            <div class="flex justify-center gap-2 mt-4" id="dotsContainer">
+                <div class="dot w-6 h-1.5 rounded-full bg-primary transition-all duration-300"></div>
+                <div class="dot w-1.5 h-1.5 rounded-full bg-gray-300 transition-all duration-300"></div>
+                <div class="dot w-1.5 h-1.5 rounded-full bg-gray-300 transition-all duration-300"></div>
+            </div>
+        </div>
+    </div>
+
+    <!-- C. 上滑抽屉 -->
+    <div id="drawer-backdrop" class="fixed inset-0 bg-black/50 z-50 hidden transition-opacity" onclick="closeDrawer()"></div>
+    <div id="drawer" class="fixed bottom-0 left-0 w-full bg-white rounded-t-3xl p-6 z-50 transform translate-y-full transition-transform duration-300 ease-out safe-area-bottom">
+        <div class="w-12 h-1.5 bg-gray-200 rounded-full mx-auto mb-6"></div>
+        <h3 class="font-bold text-lg mb-5 text-gray-800">前往比赛场地</h3>
+        <div class="space-y-6">
+            <div>
+                <button onclick="triggerJump('兴隆山校区')" class="w-full border-2 border-primary bg-blue-50 text-primary py-4 rounded-xl font-bold text-sm flex items-center justify-between px-6 shadow-sm active:scale-[0.98] transition-transform">
+                    <div class="flex items-center gap-3">
+                        <i class="fas fa-map-marked-alt text-2xl"></i>
+                        <div class="text-left">
+                            <div class="text-base font-bold">兴隆山校区</div>
+                            <div class="text-[10px] opacity-70 font-normal">当前比赛区域</div>
+                        </div>
+                    </div>
+                    <i class="fas fa-chevron-right text-lg"></i>
+                </button>
+            </div>
+            <div>
+                <label class="text-xs text-gray-400 font-bold uppercase mb-2 block pl-1">选择导航</label>
+                <div class="bg-gray-50 rounded-2xl p-2 flex flex-col gap-2 border border-gray-100">
+                    <button onclick="triggerJump('高德地图')" class="flex items-center gap-4 w-full bg-white p-3 rounded-xl border border-gray-100 shadow-sm active:bg-gray-50 active:scale-[0.99] transition-transform">
+                        <img src="./gd.png" class="w-8 h-8 object-contain">
+                        <div class="text-left">
+                            <div class="font-bold text-gray-800 text-sm">高德地图</div>
+                            <div class="text-[10px] text-gray-400">推荐路线</div>
+                        </div>
+                        <i class="fas fa-location-arrow ml-auto text-primary"></i>
+                    </button>
+                    <button onclick="triggerJump('百度地图')" class="flex items-center gap-4 w-full bg-white p-3 rounded-xl border border-gray-100 shadow-sm active:bg-gray-50 active:scale-[0.99] transition-transform">
+                        <img src="./bd.png" class="w-8 h-8 object-contain">
+                        <div class="text-left">
+                            <div class="font-bold text-gray-800 text-sm">百度地图</div>
+                            <div class="text-[10px] text-gray-400">备选方案</div>
+                        </div>
+                        <i class="fas fa-location-arrow ml-auto text-primary"></i>
+                    </button>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <script src="js/uni-compat.js"></script>
+    <script src="js/define.js"></script>
+    <script src="js/api.js"></script>
+    <script src="js/tools.js"></script>
+    <script src="js/cardfunc.js"></script>
+    <script src="js/logic-ranklist.js"></script>
+    <script>
+        // UI Logic that doesn't need state
+        const slider = document.getElementById('modalSlider');
+        const dots = document.querySelectorAll('.dot');
+        function updateDots() {
+            const index = Math.round(slider.scrollLeft / slider.offsetWidth);
+            dots.forEach((dot, i) => {
+                 dot.className = (i === index) ? 'dot w-6 h-1.5 rounded-full bg-primary transition-all duration-300' : 'dot w-1.5 h-1.5 rounded-full bg-gray-300 transition-all duration-300';
+            });
+        }
+    </script>
+</body>
+</html>

+ 304 - 0
card/pages/tpl/style3/new/signup.html

@@ -0,0 +1,304 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <script src="https://cdn.tailwindcss.com"></script>
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
+    <script>
+        tailwind.config = {
+            theme: {
+                extend: {
+                    colors: {
+                        primary: '#3372ac',
+                    }
+                }
+            }
+        }
+    </script>
+    <style>
+        @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap');
+        body { font-family: 'Nunito', sans-serif; }
+        .no-scrollbar::-webkit-scrollbar { display: none; }
+        .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
+        .snap-mandatory { scroll-behavior: smooth; }
+        
+        .dropdown-enter {
+            opacity: 0;
+            transform: translateY(-10px) scale(0.95);
+        }
+        .dropdown-enter-active {
+            opacity: 1;
+            transform: translateY(0) scale(1);
+            transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
+        }
+    </style>
+</head>
+<body class="bg-slate-50 relative pb-24" onclick="closeDropdownOnClickOutside(event)">
+
+    <!-- 1. 顶部 Header -->
+    <div class="relative w-full h-[260px]">
+        <img src="https://orienteering.beswell.com/card/nanning/cardtop1122-2.jpg" 
+             class="w-full h-full object-cover shadow-md" alt="Header BG">
+        <div class="absolute inset-0 bg-gradient-to-b from-primary/60 via-transparent to-black/5"></div>
+        <div class="absolute top-0 w-full pt-10 px-5 flex justify-between items-center z-10 text-white">
+            <button class="bg-black/20 backdrop-blur-sm p-2 rounded-full w-9 h-9 flex items-center justify-center active:scale-90 transition">
+                <i class="fas fa-chevron-left"></i>
+            </button>
+            <h1 class="text-lg font-bold tracking-wider shadow-sm">赛事报名</h1>
+            <button onclick="openModal()" class="bg-black/20 backdrop-blur-sm px-3 py-1.5 rounded-full text-xs font-semibold flex items-center gap-1 active:scale-90 transition">
+                <i class="fas fa-question-circle"></i> 说明
+            </button>
+        </div>
+    </div>
+
+    <!-- 2. 主体内容 -->
+    <div class="relative z-20 px-4">
+        
+        <!-- A. 比赛时间 (字体已放大 text-xs -> text-sm) -->
+        <div class="bg-white rounded-full shadow-lg py-2 px-4 -mt-6 mb-5 mx-2 flex items-center justify-between border border-white/60 relative z-30">
+            <!-- 这里将 text-xs 改为了 text-sm -->
+            <div class="flex items-center gap-2 w-full text-sm overflow-hidden whitespace-nowrap">
+                <div class="bg-blue-50 text-primary w-6 h-6 rounded-full flex items-center justify-center shrink-0">
+                    <i class="fas fa-clock text-xs"></i>
+                </div>
+                <!-- 删除了 scale-90,保持原大 -->
+                <span class="text-gray-400 font-bold uppercase origin-left shrink-0">比赛时间</span>
+                <span class="text-gray-300">|</span>
+                <span id="matchTimeDisplay" class="font-bold text-gray-800 font-mono truncate">11.28 08:00 - 11.30 18:00</span>
+            </div>
+        </div>
+
+        <!-- B. 报名表单 -->
+        <div class="bg-white rounded-xl shadow-sm p-5 mb-5 space-y-5 relative z-40">
+            <!-- 昵称行 -->
+            <div class="flex items-center gap-3">
+                <label class="w-16 text-sm font-bold text-gray-500 text-right">报名昵称</label>
+                <input type="text" id="userName" placeholder="请输入您的昵称" 
+                       class="flex-1 bg-slate-50 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:bg-blue-50 focus:ring-1 focus:ring-primary/50 transition-all text-gray-800 font-bold placeholder-gray-400">
+            </div>
+
+            <!-- 战队行 -->
+            <div class="flex items-center gap-3 relative">
+                <label class="w-16 text-sm font-bold text-gray-500 text-right">选择战队</label>
+                
+                <div class="relative flex-1 group">
+                    <button type="button" id="dropdownBtn" onclick="toggleDropdown(event)" 
+                            class="w-full bg-slate-50 rounded-lg px-3 py-2.5 text-sm font-bold text-left flex items-center justify-between focus:outline-none focus:ring-1 focus:ring-primary/50 transition-all active:bg-blue-50">
+                        <span id="selectedText" class="text-gray-400 font-normal flex items-center">
+                            请选择或搜索...
+                        </span>
+                        <i class="fas fa-chevron-circle-down text-primary/70 transition-transform duration-300" id="dropdownArrow"></i>
+                    </button>
+
+                    <input type="hidden" id="teamSelect" value="">
+
+                    <div id="dropdownMenu" class="hidden absolute top-[110%] left-0 w-full bg-white rounded-xl shadow-2xl border border-gray-100 overflow-hidden origin-top z-50 dropdown-enter">
+                        <ul class="text-sm text-gray-700 max-h-48 overflow-y-auto no-scrollbar">
+                            <li onclick="selectOption('飞虎队', '飞虎队')" class="px-4 py-3 hover:bg-blue-50 cursor-pointer flex items-center justify-between border-b border-gray-50 last:border-0 transition-colors group/item">
+                                <div class="flex items-center">
+                                    <i class="fas fa-user-friends text-blue-200 mr-2 group-hover/item:text-primary transition-colors"></i>
+                                    <span class="font-bold">飞虎队 (兴隆山)</span>
+                                </div>
+                                <i class="fas fa-check text-primary opacity-0 check-icon"></i>
+                            </li>
+                            <li onclick="selectOption('火箭队', '火箭队')" class="px-4 py-3 hover:bg-blue-50 cursor-pointer flex items-center justify-between border-b border-gray-50 last:border-0 transition-colors group/item">
+                                <div class="flex items-center">
+                                    <i class="fas fa-user-friends text-blue-200 mr-2 group-hover/item:text-primary transition-colors"></i>
+                                    <span class="font-bold">火箭队 (中心校区)</span>
+                                </div>
+                                <i class="fas fa-check text-primary opacity-0 check-icon"></i>
+                            </li>
+                            <li onclick="selectOption('摸鱼队', '摸鱼队')" class="px-4 py-3 hover:bg-blue-50 cursor-pointer flex items-center justify-between border-b border-gray-50 last:border-0 transition-colors group/item">
+                                <div class="flex items-center">
+                                    <i class="fas fa-user-friends text-blue-200 mr-2 group-hover/item:text-primary transition-colors"></i>
+                                    <span class="font-bold">摸鱼队 (快乐组)</span>
+                                </div>
+                                <i class="fas fa-check text-primary opacity-0 check-icon"></i>
+                            </li>
+                             <li onclick="selectOption('汪汪队', '汪汪队')" class="px-4 py-3 hover:bg-blue-50 cursor-pointer flex items-center justify-between transition-colors group/item">
+                                <div class="flex items-center">
+                                    <i class="fas fa-user-friends text-blue-200 mr-2 group-hover/item:text-primary transition-colors"></i>
+                                    <span class="font-bold">汪汪队 (教职工)</span>
+                                </div>
+                                <i class="fas fa-check text-primary opacity-0 check-icon"></i>
+                            </li>
+                        </ul>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- C. 赛事介绍 -->
+        <div class="bg-white rounded-xl shadow-sm p-5 pb-8 relative z-10">
+            <h3 class="font-bold text-base text-primary mb-4 flex items-center gap-2">
+                <span class="w-1 h-4 bg-primary rounded-full"></span> 赛事介绍
+            </h3>
+            
+            <div class="text-xs text-gray-600 space-y-3 leading-relaxed">
+                <p class="font-bold text-sm text-gray-900 border-b border-gray-100 pb-2">
+                    南宁学院40周年校庆 (暨「智跑南院」数字定向赛)
+                </p>
+                <div class="space-y-2">
+                    <div class="flex flex-col sm:flex-row gap-1">
+                        <span class="font-bold text-primary shrink-0">一、主办单位 /</span>
+                        <span class="text-gray-700">共青团南宁学院委员会、教育学院</span>
+                    </div>
+                    <div class="flex flex-col sm:flex-row gap-1">
+                        <span class="font-bold text-primary shrink-0">二、承办单位 /</span>
+                        <span class="text-gray-700">各二级学院团委</span>
+                    </div>
+                    <div class="flex flex-col sm:flex-row gap-1">
+                        <span class="font-bold text-primary shrink-0">三、协办单位 /</span>
+                        <span class="text-gray-700">南宁学院学生会、青年志愿者协会、教育学院大学生竞技中心、教育学院大学生运动健康促进中心</span>
+                    </div>
+                    <div class="flex flex-col sm:flex-row gap-1">
+                        <span class="font-bold text-primary shrink-0">四、参赛人员 /</span>
+                        <span class="text-gray-700">全校在校学生</span>
+                    </div>
+                    <div class="flex flex-col sm:flex-row gap-1">
+                        <span class="font-bold text-primary shrink-0">五、赞助单位 /</span>
+                        <span class="text-gray-700">一食堂三楼广西桂师傅餐饮管理有限公司</span>
+                    </div>
+                </div>
+                <div class="text-right mt-4 pt-2 font-bold text-primary opacity-90">
+                    共青团南宁学院委员会
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <!-- 底部按钮 -->
+    <div class="fixed bottom-0 w-full bg-white px-4 py-3 shadow-[0_-4px_15px_rgba(0,0,0,0.05)] z-50">
+        <button onclick="openConfirm()" class="w-full bg-gradient-to-r from-orange-500 to-red-600 text-white font-bold text-lg py-2.5 rounded-full shadow-lg shadow-orange-100 active:scale-[0.98] transition-transform flex items-center justify-center gap-2">
+            立即报名 <i class="fas fa-paper-plane text-sm"></i>
+        </button>
+    </div>
+
+    <!-- 说明模态框 -->
+    <div id="infoModal" class="fixed inset-0 z-50 hidden transition-opacity duration-300">
+        <div class="absolute inset-0 bg-slate-900/70 backdrop-blur-sm" onclick="closeModal()"></div>
+        <div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[90%] bg-white rounded-3xl p-6 shadow-2xl">
+            <button onclick="closeModal()" class="absolute -top-12 right-0 text-white/80 hover:text-white w-10 h-10 border border-white/30 rounded-full flex items-center justify-center">
+                <i class="fas fa-times text-lg"></i>
+            </button>
+            <h3 class="text-center font-bold text-xl mb-4 text-primary border-b border-gray-100 pb-3">
+                <i class="fas fa-book-open mr-2"></i>比赛规则说明
+            </h3>
+            <div id="modalSlider" class="overflow-x-auto flex snap-x snap-mandatory no-scrollbar space-x-4 pb-2" onscroll="updateDots()">
+                <!-- Slides... -->
+                <div class="snap-center shrink-0 w-full bg-blue-50 rounded-2xl p-5 text-center border border-blue-100">
+                    <div class="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-3">
+                        <i class="fas fa-map-marker-alt text-xl text-primary"></i>
+                    </div>
+                    <h4 class="font-bold text-base text-gray-800 mb-1">1. 定向寻宝</h4>
+                    <p class="text-gray-600 text-xs leading-relaxed">比赛设置12个隐藏打卡点,根据地图指引,规划最佳路线。</p>
+                </div>
+                <div class="snap-center shrink-0 w-full bg-blue-50 rounded-2xl p-5 text-center border border-blue-100">
+                    <div class="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-3">
+                        <i class="fas fa-mobile-alt text-xl text-primary"></i>
+                    </div>
+                    <h4 class="font-bold text-base text-gray-800 mb-1">2. 扫码打卡</h4>
+                    <p class="text-gray-600 text-xs leading-relaxed">到达点位后,使用APP扫描二维码,系统自动记录时间。</p>
+                </div>
+                <div class="snap-center shrink-0 w-full bg-blue-50 rounded-2xl p-5 text-center border border-blue-100">
+                    <div class="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-3">
+                        <i class="fas fa-medal text-xl text-primary"></i>
+                    </div>
+                    <h4 class="font-bold text-base text-gray-800 mb-1">3. 完赛奖励</h4>
+                    <p class="text-gray-600 text-xs leading-relaxed">规定时间内完赛,前50名有精美礼品!加油!</p>
+                </div>
+            </div>
+            <div class="flex justify-center gap-2 mt-4" id="dotsContainer">
+                <div class="dot w-6 h-1.5 rounded-full bg-primary transition-all duration-300"></div>
+                <div class="dot w-1.5 h-1.5 rounded-full bg-gray-300 transition-all duration-300"></div>
+                <div class="dot w-1.5 h-1.5 rounded-full bg-gray-300 transition-all duration-300"></div>
+            </div>
+        </div>
+    </div>
+
+    <!-- 确认模态框 -->
+    <div id="confirmModal" class="fixed inset-0 z-50 hidden flex items-center justify-center">
+        <div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onclick="closeConfirm()"></div>
+        <div class="bg-white w-[80%] rounded-2xl p-6 relative z-10 text-center shadow-xl">
+            <div class="w-14 h-14 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
+                <i class="fas fa-check text-2xl text-green-600"></i>
+            </div>
+            <h3 class="font-bold text-lg mb-2 text-gray-800">确认提交报名?</h3>
+            <div class="bg-slate-50 rounded-lg p-4 mb-6 text-left border border-slate-100">
+                <p class="mb-2"><span class="text-gray-500 text-sm">昵称:</span><span class="font-bold text-gray-800 ml-2" id="confirmName"></span></p>
+                <p><span class="text-gray-500 text-sm">战队:</span><span class="font-bold text-primary ml-2" id="confirmTeamText"></span></p>
+            </div>
+            <div class="flex gap-3">
+                <button onclick="closeConfirm()" class="flex-1 py-2.5 rounded-lg border border-gray-200 text-gray-600 font-bold text-sm">再想想</button>
+                <button onclick="location.href='#'" class="flex-1 py-2.5 rounded-lg bg-gradient-to-r from-orange-500 to-red-600 text-white font-bold text-sm shadow-md">确认提交</button>
+            </div>
+        </div>
+    </div>
+
+    <script src="js/uni-compat.js"></script>
+    <script src="js/define.js"></script>
+    <script src="js/api.js"></script>
+    <script src="js/tools.js"></script>
+    <script src="js/cardfunc.js"></script>
+    <script src="js/logic-signup.js"></script>
+    <script>
+        document.addEventListener('DOMContentLoaded', function() {
+            initSignupPage();
+        });
+
+        const dropdownMenu = document.getElementById('dropdownMenu');
+        const dropdownArrow = document.getElementById('dropdownArrow');
+        const selectedText = document.getElementById('selectedText');
+        const teamInput = document.getElementById('teamSelect');
+        const checkIcons = document.querySelectorAll('.check-icon');
+
+        function toggleDropdown(event) {
+            event.stopPropagation();
+            const isHidden = dropdownMenu.classList.contains('hidden');
+            if (isHidden) {
+                dropdownMenu.classList.remove('hidden');
+                setTimeout(() => dropdownMenu.classList.add('dropdown-enter-active'), 10);
+                dropdownArrow.style.transform = 'rotate(180deg)';
+            } else {
+                closeDropdown();
+            }
+        }
+
+        function closeDropdown() {
+            dropdownMenu.classList.remove('dropdown-enter-active');
+            dropdownArrow.style.transform = 'rotate(0deg)';
+            setTimeout(() => dropdownMenu.classList.add('hidden'), 200);
+        }
+
+        // selectOption is in logic-signup.js
+
+        function closeDropdownOnClickOutside(event) {
+            if (!dropdownMenu.contains(event.target) && event.target.id !== 'dropdownBtn') {
+                closeDropdown();
+            }
+        }
+
+        function openModal() { document.getElementById('infoModal').classList.remove('hidden'); }
+        function closeModal() { document.getElementById('infoModal').classList.add('hidden'); }
+        
+        // openConfirm is in logic-signup.js
+        function closeConfirm() { document.getElementById('confirmModal').classList.add('hidden'); }
+
+        const slider = document.getElementById('modalSlider');
+        const dots = document.querySelectorAll('.dot');
+        function updateDots() {
+            const index = Math.round(slider.scrollLeft / slider.offsetWidth);
+            dots.forEach((dot, i) => {
+                if (i === index) {
+                    dot.classList.remove('w-1.5', 'bg-gray-300');
+                    dot.classList.add('w-6', 'bg-primary');
+                } else {
+                    dot.classList.remove('w-6', 'bg-primary');
+                    dot.classList.add('w-1.5', 'bg-gray-300');
+                }
+            });
+        }
+    </script>
+</body>
+</html>

二進制
card/pages/tpl/style3/new/webfonts/fa-brands-400.woff2


二進制
card/pages/tpl/style3/new/webfonts/fa-regular-400.woff2


二進制
card/pages/tpl/style3/new/webfonts/fa-solid-900.woff2


+ 86 - 0
card/pages/tpl/style3/rankList_analysis.md

@@ -0,0 +1,86 @@
+# `pages/tpl/style3/rankList.vue` 交互流程与 API 分析
+
+`pages/tpl/style3/rankList.vue` 是一个功能丰富的赛事排名列表页面,用于展示个人和团体的多维度成绩。以下是其详细的交互流程、API 调用和功能分析。
+
+### 1. 交互流程
+
+1.  **页面加载 (`onLoad`)**:
+    *   **参数解析**: 页面初始化时,从 URL 中提取关键参数:`token`、`id` (即 `ecId`,赛事ID) 和 `ovtype` (可选,用于特定视图逻辑)。
+    *   **状态初始化**:
+        *   初始化 `cardfunc` 模块。
+        *   生成用于本地缓存的 Key,包括 `firstEnterKey` (首次进入标志)、`rankKey` (榜单数据指纹)、`mapKey` (地图选择状态)、`messageKey` (消息阅读状态)。
+        *   尝试从本地缓存读取上次选择的地图/校区 ID (`mapKey`),以便恢复用户的浏览上下文。
+        *   设置页面显示的当前日期 (`today`)。
+    *   **配置与数据预加载**:
+        *   调用 `cardfunc.getCardConfig()` 获取页面的动态配置(包括 Tab 标题、榜单字段映射、页面文案等)。
+        *   检查是否有未读消息 (`unReadMessageQuery`) 和警告消息 (`warnMessageQuery`)。
+        *   发起 `matchRsDetailQuery()` 请求以获取赛事的基本详情。
+
+2.  **页面显示 (`onShow`)**:
+    *   每次页面显示时,调用 `getUserJoinCardQuery()` 检查用户的报名状态。这将决定页面底部主操作按钮的显示文本(是“我要报名”还是“选择场地/进入比赛”)。
+
+3.  **数据获取与展示**:
+    *   **赛事详情与倒计时**: `matchRsDetailQuery` 成功返回后,页面会设置赛事名称、时间,并启动一个每分钟执行一次的定时器来更新倒计时显示。同时,它会触发后续的数据请求:`mapListQuery` (获取分组/地图列表) 和 `compStatisticQuery` (获取统计数据)。
+    *   **顶部统计**: `compStatisticQuery` 返回的数据(如总里程、总答题数)会展示在页面的顶部区域。
+    *   **榜单数据**:
+        *   页面使用双层 Tab 结构:
+            *   **Tab 1 (分组类型)**: 例如“团体”和“个人”。其中“个人”选项可能包含一个下拉列表,由 `mapListQuery` 返回的数据填充(通常对应不同的校区或组别)。
+            *   **Tab 2 (排名维度)**: 例如“总积分”、“总里程”、“今日配速”等。
+        *   核心榜单数据通过 `getCardRankDetailQuery` 获取。根据当前选中的地图 ID (`ocaId`),请求对应的榜单数据并存储在 `rankList` 对象中。
+        *   组件 `my-ranklist` 根据当前的 Tab 选择,从 `rankList` 中提取相应的数据进行渲染。
+
+4.  **用户交互**:
+    *   **切换榜单**: 用户点击 Tab 1 或 Tab 2 时,触发相应的点击事件,更新 `tab1Current` 或 `tab2Current` 索引,从而切换显示的榜单内容。
+    *   **切换分组**: 当用户在 Tab 1 的下拉菜单中选择不同的项(如切换校区)时,触发 `onSelectChange`。这会更新 `ocaId`,保存到本地缓存,并重新调用 `getCardRankDetailQuery` 刷新榜单数据。
+    *   **底部按钮 (`btnStartGame`)**:
+        *   **已报名**: 跳转到 `/pages/tpl/style3/rankOverview`(概览页)或其他指定页面。
+        *   **未报名**: 跳转到 `/pages/tpl/style3/signup`(报名页)。
+    *   **顶部功能**:
+        *   **我的奖券**: 点击跳转到成就/奖券页面。
+        *   **兑奖**: 点击弹出兑奖地址或跳转到商品列表。
+        *   **消息/规则**: 点击图标弹出相应的说明窗口。
+
+5.  **消息与通知**:
+    *   页面会自动检查未读消息和警告。如果有未读且未忽略的消息,或有警告信息,会自动弹出 `my-popup` 提示用户。
+    *   榜单更新检查逻辑(`dealNotice`)也存在,用于提示用户榜单数据是否有变化。
+
+### 2. 调用的 API
+
+该页面集成了多个后端 API,主要包括:
+
+1.  **`apiMatchRsDetailQuery`**:
+    *   **用途**: 获取赛事的详细信息(名称、开始/结束时间等)。
+    *   **联动**: 成功后触发倒计时、地图列表查询和统计数据查询。
+
+2.  **`apiMapListQuery`**:
+    *   **用途**: 获取赛事关联的地图(分组)列表。
+    *   **作用**: 数据用于填充 Tab 1 的下拉菜单,允许用户在不同组别(如不同校区)的个人榜单间切换。
+
+3.  **`apiCompStatisticQuery`**:
+    *   **用途**: 获取赛事的全局统计数据。
+    *   **返回**: 总里程 (`totalDistance`)、总答题数 (`totalAnswerNum`) 等,展示在页面顶部。
+
+4.  **`apiCardRankDetailQuery`**:
+    *   **用途**: 获取具体的榜单数据。
+    *   **参数**: `mcIdListStr` (赛事ID), `ocaId` (当前选中的地图/分组ID), `dispArrStr` (请求的字段集合)。
+    *   **返回**: 一个包含多种排名列表(如团队总分、个人总里程等)的复杂对象。
+
+5.  **`apiUserJoinCardQuery`**:
+    *   **用途**: 查询当前用户的报名状态。
+    *   **作用**: 控制底部按钮的功能和文案。
+
+6.  **`apiIsAllowMcSignUp`**:
+    *   **用途**: 查询是否允许重新报名或分组(虽然在当前主流程中未见核心调用,但作为功能储备存在)。
+
+### 3. 关键组件与本地存储
+
+*   **组件**:
+    *   `my-topbar`: 顶部导航与功能入口。
+    *   `my-tab`: 支持多级和下拉选择的 Tab 切换组件。
+    *   `my-ranklist`: 负责渲染排名列表的核心组件。
+    *   `my-popup`: 通用的弹窗组件,用于显示各类信息。
+
+*   **本地存储 (`Storage`)**:
+    *   `mapKey`: 持久化用户选择的分组/地图 ID,提升用户体验。
+    *   `messageKey`: 记录已读消息,防止重复弹窗干扰用户。
+    *   `firstEnterKey`: 用于判断是否首次进入,以展示引导信息。

+ 78 - 0
card/pages/tpl/style3/rankOverview_analysis.md

@@ -0,0 +1,78 @@
+# `pages/tpl/style3/rankOverview.vue` 交互流程与 API 分析
+
+`pages/tpl/style3/rankOverview.vue` 页面是赛事的总览界面,主要用于展示用户在特定赛事(或其分区、地图)中的个人成绩概况,并作为进入实际比赛路线的入口。
+
+### 1. 交互流程
+
+1.  **页面加载 (`onLoad`)**:
+    *   **参数解析**: 页面启动时,从 URL 中解析出 `token`、`id` (赛事ID `ecId`) 等参数。
+    *   **模块初始化**: 调用 `cardfunc.init()` 进行卡片功能模块的初始化。
+    *   **本地存储键生成**: 创建一系列用于本地存储的键,如 `firstEnterKey` (首次进入引导)、`rankKey` (榜单数据指纹)、`mapKey` (地图选择)。
+    *   **恢复状态**: 尝试从本地存储 `mapKey` 中加载用户上次选择的地图或分组 ID (`ocaId`),以恢复上次的查看状态。
+    *   **检查新用户**: 调用 `cardfunc.isNewUserQuery()` 判断当前用户是否为新用户,这会影响后续的引导流程。
+    *   **配置加载与数据请求**:
+        *   调用 `cardfunc.getCardConfig()` 加载页面相关的配置,包括布局、文案、路线列表 (`pathList`) 等。
+        *   配置加载完成后,会触发 `matchRsDetailQuery()` 来获取赛事详情和用户成绩数据。
+        *   同时会调用 `cardfunc.warnMessageQuery()` 检查是否有警告消息需要显示。
+
+2.  **页面展示与数据更新**:
+    *   **顶部信息**: 显示赛事名称 (`mcName`) 和活动时间 (`acttime`)。
+    *   **个人成绩概览**: 页面中部区域 (`midType0` 或 `midType1` 模板) 展示用户的核心成绩数据,例如总场次 (`regionTotalNum`)、打点数 (`regionTotalCp`)、里程 (`regionTotalDictance`)、积分/百味豆 (`regionTotalSysPoint`) 和最快配速 (`regionFastPace`)。这些数据通过 `matchRsDetailQuery` 获取并实时更新。
+    *   **地图/分组选择**: 提供一个 `<e-select>` 下拉选择器,允许用户在不同的地图或赛事分组之间切换。这部分数据通过 `apiMapListQuery` 获取。用户选择不同的地图会触发 `eSelectChange` 方法,更新 `ocaId` 并重新加载该地图下的成绩和路线。
+    *   **比赛路线列表**: 页面下部通过 `my-pathList` 组件展示当前选定地图下的具体比赛路线列表。路线数据来自页面的配置。
+
+3.  **用户交互**:
+    *   **开始比赛**:
+        *   用户可以通过点击 `my-pathList` 中的某个路线项来选定该路线。
+        *   点击底部“开始比赛”按钮 (`btnStartGame`):
+            *   如果赛事处于进行中 (`mcState == 1`),会通过 `tools.appAction()` 调用原生 APP 接口 (`action://to_detail/`) 跳转到对应路线的比赛详情页面,开始游戏。
+            *   如果赛事尚未开始 (`mcState == 0`) 或已经结束 (`mcState == 2`),则会弹出相应的提示信息。
+    *   **修改分组/重新报名**: 在赛事进行中且允许修改分组的情况下 (`allowMcSignUp` 为 `true`),用户可以点击“修改”按钮 (`btnReGroup`) 跳转到报名页面 (`/pages/tpl/style3/signup`),可能用于重新选择分组。
+    *   **辅助信息弹窗**:
+        *   点击顶部的信息按钮 (`btnInfo`) 会弹出赛事规则弹窗 (`mypopup`)。
+        *   点击中部的“帮助”文本 (`btnHelp`) 会弹出帮助信息弹窗 (`mypopupHelp`)。
+        *   如果有警告消息,点击黄色卡片图标 (`btnWarn`) 会弹出警告弹窗 (`mypopupWarn`)。
+    *   **新手引导**: 如果用户是首次进入页面且符合新用户条件,可能会在点击路线后触发 `my-guide` 组件显示新手引导。
+    *   **返回**: 点击顶部返回按钮 (`btnBack`) 会跳转回 `rankList` 页面。
+
+### 2. 调用的 API
+
+该页面主要通过 `uni.request` 调用以下后端 API:
+
+1.  **`apiMapListQuery`**:
+    *   **用途**: 查询当前赛事关联的地图或分组列表。
+    *   **参数**: `mcId` (赛事ID)。
+    *   **作用**: 返回的数据 `mapList` 用于填充 `<e-select>` 组件的下拉选项,允许用户选择不同的比赛地图或分组。在页面加载时,如果 `ocaId` 尚未设置,会默认选择第一个地图。
+
+2.  **`apiMatchRsDetailQuery`**:
+    *   **用途**: 获取当前赛事(和选定地图 `ocaId`)的详细信息及用户的个人成绩数据。
+    *   **参数**: `ecId` (卡片ID), `ocaId` (选定地图/分组ID)。
+    *   **返回数据**:
+        *   **赛事详情**: `mcType` (赛事类型), `mcId` (赛事ID), `mcName` (赛事名称), `beginSecond` (开始时间戳), `endSecond` (结束时间戳)。
+        *   **用户信息**: `coiName` (报名单位名称), `nickName` (用户昵称)。
+        *   **个人成绩**: `regionTotalNum` (场次), `regionTotalCp` (打点数), `regionTotalCpRankNum` (打点排名), `regionTotalSysPoint` (百味豆/积分), `regionTotalDictance` (里程), `regionFastPace` (最快配速) 等。
+    *   **联动**: 成功获取数据后,会更新页面上的各类成绩展示,计算赛事状态 (`mcState`),触发倒计时更新,并调用 `isAllowMcSignUp` 检查是否允许重新报名。
+
+3.  **`apiIsAllowMcSignUp`**:
+    *   **用途**: 查询当前赛事是否允许用户进行重新报名或修改分组。
+    *   **参数**: `ecId` (卡片ID)。
+    *   **作用**: 返回的布尔值 `allowSignUp` 控制“修改”按钮的可见性。
+
+### 3. 关键组件与配置驱动
+
+*   **`my-topbar`**: 顶部导航栏,提供返回、信息和标题显示。
+*   **`<e-select>`**: 用于地图/分组选择的下拉组件。
+*   **`my-pathList`**: 展示比赛路线列表的自定义组件,支持点击选择路线。
+*   **`my-popup`**: 通用弹窗组件,用于显示赛事规则、帮助信息和警告。
+*   **`my-guide`**: 新手引导弹窗组件,针对首次使用的用户。
+*   **`cardConfig` 配置**: 页面大量依赖 `cardfunc.getCardConfig` 返回的配置对象。例如:
+    *   `config.pathList`: 定义了可用的比赛路线及其详情。
+    *   `config.midType`: 控制页面中部成绩展示区域的布局和样式。
+    *   `config.subTitle`: 页面副标题。
+    *   `config.popupHelpList`, `config.popupWarnList`, `config.popupRuleList`: 用于各类弹窗显示的内容。
+
+### 4. 本地存储 (`uni.getStorageSync`, `uni.setStorageSync`)
+
+*   `mapKey`: 用于持久化存储用户当前选择的地图/分组 ID (`ocaId`),以便下次进入页面时自动加载。
+*   `firstEnterKey`: 记录用户是否首次进入该页面,用于触发新手引导弹窗。
+*   `rankKey`: 存储赛事数据的指纹,用于检查数据更新(尽管在本页面中主要是用于传递给其他页面,如 `rankList`)。

+ 73 - 0
card/pages/tpl/style3/signup_analysis.md

@@ -0,0 +1,73 @@
+# `pages/tpl/style3/signup.vue` 交互流程与 API 分析
+
+`pages/tpl/style3/signup.vue` 是一个用于用户报名赛事活动的页面。以下是其详细的交互流程和 API 调用分析。
+
+### 1. 交互流程
+
+1.  **页面加载 (`onLoad`)**:
+    *   **参数解析**: 页面初始化时,会从 URL 参数中获取关键信息,包括 `token` (用户令牌), `id` (赛事ID,即 `ecId`), `from` (指示用户从哪个页面跳转而来,用于返回逻辑)。
+    *   **模块初始化**: 调用 `cardfunc.init()` 初始化卡片功能相关模块。
+    *   **配置获取**: 通过 `cardfunc.getCardConfig()` 加载页面的配置信息。这些配置包括页面样式、规则文本、输入字段的标签等。配置加载完成后,`pageReady` 状态会变为 `true`,页面内容将显示给用户。
+    *   **数据获取**:
+        *   调用 `getCardDetailQuery()` 以获取当前赛事的详细信息。
+        *   调用 `matchRsDetailQuery()` 获取赛事的榜单数据,这通常用于检查是否有新数据更新并可能触发通知提示。
+
+2.  **用户填写信息**:
+    *   **昵称 (`nickName`)**: 页面提供一个输入框供用户填写昵称。如果用户之前已报名,该字段可能会预填充。用户点击输入框时,会触发 `checkToken` 函数,用于验证用户登录状态。
+    *   **组织/单位 (`coiId`/`coiName`)**: 用户可以通过 `e-select` 组件选择或搜索所属的组织或单位。此组件支持关键字过滤,方便用户快速定位。
+    *   **活动信息展示**: 页面还会显示赛事的活动时间、倒计时,以及从配置中加载的介绍 (`introduce`) 和活动规则 (`activityRules`)。
+
+3.  **报名操作 (`btnSignup`)**:
+    *   **前置验证**: 用户点击“报名”按钮时,系统会进行一系列验证:
+        *   检查用户是否已登录 (`token` 是否有效)。
+        *   验证昵称 (`nickName`) 字段是否已填写。
+        *   验证组织/单位 (`coiId`) 是否已选择。
+    *   **报名信息确认**: 如果所有验证通过,会弹出一个 `alertDialog` 确认框,向用户展示即将提交的报名信息,包括赛事名称、填写的昵称和选择的组织。
+    *   **提交报名**: 用户在确认框中点击“确认”后,调用 `onlineMcSignUp()` 方法将报名数据提交至后端。
+    *   **结果反馈与跳转**:
+        *   如果报名成功,会显示“比赛报名成功!”的提示信息,随后页面会自动跳转到该赛事的排行榜页面 (`/pages/tpl/style3/rankList`)。
+        *   如果报名失败,将显示“出错了,报名失败”的提示信息。
+
+4.  **辅助功能与导航**:
+    *   **返回功能 (`btnBack`)**: 页面顶部的返回按钮,根据 `from` 参数的设置,可以返回到用户来时的页面,或者在没有指定 `from` 的情况下,返回到 APP 的主页 (`action://to_home/`)。
+    *   **信息/规则弹窗 (`btnInfo`)**: 页面顶部的信息按钮,点击后会通过 `my-popup` 组件弹出包含详细规则或介绍的窗口。
+    *   **首次进入提示 (`dealFirstEnter`)**: 页面会检查本地存储 (`localStorage`) 是否有当前赛事ID对应的首次进入标志。如果是首次进入,会自动弹出信息/规则弹窗,以引导用户了解规则。
+
+### 2. 调用的 API
+
+该页面通过 `uni.request` 封装,调用了以下后端接口,这些接口的定义通常在 `common/api.js` 中:
+
+1.  **`apiCardDetailQuery`**:
+    *   **用途**: 获取指定赛事 (`ecId`) 的详细信息。
+    *   **参数**: `ecId` (赛事ID)。
+    *   **返回数据示例**: 包含 `mcId` (赛事ID), `mcName` (赛事名称), `mcType` (赛事类型), `beginSecond` (开始时间戳), `endSecond` (结束时间戳),以及用户的 `coiId` (已报名的组织ID), `coiName` (已报名的组织名称), `teamNum` (队伍编号), `nickName` (昵称) 等信息。
+    *   **后续动作**: 获取这些信息后,页面会根据时间戳计算并显示活动状态 (`mcState`),并启动倒计时更新定时器,随后会调用 `getOnlineMcSignUpDetail`。
+
+2.  **`apiOnlineMcSignUpDetail`**:
+    *   **用途**: 获取线上赛报名页面所需的详细数据,主要包括可供选择的组织列表。
+    *   **参数**: `mcId` (赛事ID)。
+    *   **返回数据示例**: `coiRs` (一个组织列表数组,每个元素包含 `coiId` 和 `coiName`),以及一个可选的 `name` (可能作为默认昵称)。
+    *   **后续动作**: `coiRs` 会被用来填充页面的 `<e-select>` 组织选择器。
+
+3.  **`apiOnlineMcSignUp`**:
+    *   **用途**: 提交用户的线上赛报名数据。
+    *   **参数**: `mcId` (赛事ID), `coiId` (选择的组织ID), `selectTeam` (队伍编号,默认为 0), `nickName` (用户填写的昵称)。
+    *   **返回数据示例**: 包含 `code` 字段,表示操作结果状态码。
+    *   **后续动作**: 根据 `code` 判断报名是否成功,并进行相应的提示和页面跳转。
+
+4.  **`apiMatchRsDetailQuery`**:
+    *   **用途**: 查询赛事的榜单或结果详情。
+    *   **参数**: `ecId` (赛事ID)。
+    *   **在此页面中的作用**: 页面会获取此 API 返回的数据,将其 `JSON.stringify` 后与本地存储中的旧数据进行对比。如果数据有变化,可能会触发一个“小红点”通知 (`dealNotice` 方法),提示用户有新的榜单信息。
+
+### 3. 使用的组件
+
+*   **`<my-topbar>`**: 页面顶部的导航栏,负责显示赛事名称和提供返回、信息按钮。
+*   **`<e-select>`**: 一个自定义的选择器组件,用于用户选择组织或单位,并支持输入关键字进行过滤。
+*   **`<my-popup>`**: 自定义的弹窗组件,用于显示活动的介绍和规则。
+*   **`<uni-popup>` 和 `<uni-popup-dialog>`**: 用于在用户点击报名时,弹出确认报名信息的对话框。
+
+### 4. 本地存储 (`uni.getStorage`, `uni.setStorage`)
+
+*   **`firstEnter-tpl-style3-{ecId}`**: 用于存储一个标志,指示用户是否是第一次进入当前赛事的报名页面。如果是首次进入,系统会主动弹出活动规则弹窗。
+*   **`rank-tpl-style3-{ecId}`**: 用于存储 `apiMatchRsDetailQuery` 返回数据的 JSON 字符串。这用于比较当前榜单数据和用户上次查看时的数据是否有更新,并显示通知。

部分文件因文件數量過多而無法顯示