Browse Source

Demo Test

Rockz-Home 1 month ago
parent
commit
3cc827771d

+ 139 - 8
card/sdk/old/API_SERVER.md

@@ -74,10 +74,13 @@
     *   **configJson 字段说明 (示例)**:
         *   `css`: `string`,动态注入到页面的 CSS 样式。
         *   `tabActiveColor`: `string`,Tab 栏选中颜色,如 "#FF5733"。
-        *   `popupRuleConfig`: `object`,弹窗组件的样式配置,如 `{ "height": "500px", "theme": "light" }`。
-        *   `popupHelpConfig`, `popupWarnConfig`, `popupExchgConfig`, `popupMessageConfig`: `object`,其他类型弹窗的配置。
+        *   `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 用户个性化配置查询**
@@ -109,7 +112,9 @@
             "beginSecond": 1700000000,
             "endSecond": 1700090000,
             "teamNum": 0, // 0:个人, >0:团队
-            "coiName": "已报名单位名称"
+            "coiId": 1, // 已报名单位ID
+            "coiName": "已报名单位名称",
+            "ocaId": 201 // 关联活动详情ID (用于跳转)
         }
         ```
 
@@ -152,6 +157,43 @@
                 { "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. 排名与成就
@@ -176,7 +218,7 @@
           ```
 
 *   **3.1.1 月挑战排名查询 (月榜)**
-    *   **API 方法**: `API.request('MonthRankDetailQuery', { year, month, dispArrStr })`
+    *   **API 方法**: `API.getMonthRankDetail({ year, month, dispArrStr })`
     *   **参数**:
         *   `year` (int): 年份,如 2025
         *   `month` (int): 月份,1-12
@@ -244,6 +286,35 @@
         ]
         ```
 
+*   **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 卡片内可用积分查询**
@@ -274,8 +345,8 @@
         ```
 
 *   **4.3 积分兑换商品**
-    *   **API 方法**: `API.exchangeGoods(ecId, goodsId)`
-    *   **参数**: `ecId` (int), `goodsId` (int)
+    *   **API 方法**: `API.exchangeGoods(ecId, goodsId, exchNum)`
+    *   **参数**: `ecId` (int), `goodsId` (int), `exchNum` (int, 可选, 默认1)
     *   **返回数据**: `{}` (成功)
 
 *   **4.4 玩家兑换记录查询**
@@ -293,6 +364,40 @@
         ]
         ```
 
+*   **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 未读消息列表查询**
@@ -326,6 +431,11 @@
         ]
         ```
 
+*   **5.3 标记消息已读**
+    *   **API 方法**: `API.readMessage(mqIdListStr)`
+    *   **参数**: `mqIdListStr` (string) - 消息ID列表字符串,如 "1,2,3"
+    *   **返回数据**: `{}` (成功)
+
 #### 6. 网格游戏
 
 *   **6.1 网格卡片信息查询**
@@ -346,8 +456,13 @@
                     "orderNum": 1, // 格子序号
                     "isComplete": 1, // 1:点亮 0:未点亮
                     "showName": "格子1",
-                    "relationType": 1, // 1:活动详情 2:列表
-                    "ocaId": 201 // 关联ID
+                    "relationType": 1, // 1:活动详情 2:活动列表 3:地图列表
+                    "ocaId": 201, // 关联活动ID (relationType=1时有效)
+                    "mapId": 0, // 关联地图ID (relationType=2时有效)
+                    "description": "描述信息",
+                    "popupImg": "http://...", // 弹窗图片
+                    "longitude": 117.0,
+                    "latitude": 36.0
                 }
             ]
         }
@@ -423,3 +538,19 @@
             }
         ]
         ```
+
+*   **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)
+    *   **返回数据**: `{}` (待定)
+

+ 93 - 0
card/sdk/old/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 请求日志。

+ 22 - 3
card/sdk/old/api.js

@@ -64,7 +64,9 @@
             beginSecond: Date.now() / 1000 - 86400 * 2,
             endSecond: Date.now() / 1000 + 86400 * 5,
             teamNum: 0,
-            coiName: '个人组'
+            coiId: 1, // Mocked coiId
+            coiName: '个人组',
+            ocaId: 201 // Mocked ocaId
         },
         'MatchRsDetailQuery': [
             { id: 1, name: '[Mock]活动1', status: 1 },
@@ -105,10 +107,15 @@
             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。' } }
                 ]
             })
         },
@@ -184,7 +191,10 @@
                 { 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 = {
@@ -348,7 +358,16 @@
         getUserInfo: function() { return this.request('UserBasicInformationQuery', {}); },
 
         // 32. 网格卡片信息查询
-        getGrids: function(ecId) { return this.request('GridsQuery', { ecId: ecId }); }
+        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;

+ 31 - 29
card/sdk/old/detail.html

@@ -39,62 +39,62 @@
 
         /* 顶部 Header */
         .header-area {
-            height: 280px; 
+            height: 210px; 
             background: linear-gradient(to bottom, rgba(72, 48, 85, 0.7), rgba(45, 52, 54, 0.95)), 
                         url('./bg.jpg') center/cover;
             padding: 20px;
-            padding-top: max(20px, env(safe-area-inset-top));
+            padding-top: max(25px, env(safe-area-inset-top));
             color: white;
             border-bottom-left-radius: 30px; border-bottom-right-radius: 30px;
             position: relative; flex-shrink: 0;
             z-index: 1; 
         }
 
-        .nav-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }
+        .nav-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 5px; }
         .icon-btn {
-            width: 36px; height: 36px; background: rgba(255,255,255,0.2); backdrop-filter: blur(5px);
+            width: 32px; height: 32px; background: rgba(255,255,255,0.2); backdrop-filter: blur(5px);
             border-radius: 50%; display: flex; align-items: center; justify-content: center;
-            cursor: pointer; border: 1px solid rgba(255,255,255,0.3);
+            cursor: pointer; border: 1px solid rgba(255,255,255,0.3); font-size: 14px;
         }
-        .month-select { font-size: 18px; font-weight: bold; display: flex; align-items: center; gap: 6px; cursor: pointer; text-shadow: 0 2px 4px rgba(0,0,0,0.5); }
+        .month-select { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 6px; cursor: pointer; text-shadow: 0 2px 4px rgba(0,0,0,0.5); }
 
         /* 仪表盘卡片 */
         .dashboard-card {
-            margin-top: 25px;
+            margin-top: 10px;
             background: rgba(0, 0, 0, 0.4); 
             backdrop-filter: blur(10px);
-            border-radius: 20px;
-            padding: 15px 20px;
+            border-radius: 16px;
+            padding: 10px 15px;
             border: 1px solid rgba(255, 255, 255, 0.1);
             box-shadow: 0 8px 20px rgba(0,0,0,0.2);
             position: relative;
             z-index: 5; 
         }
 
-        .dash-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
-        .dash-title { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; color: #fff; }
+        .dash-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
+        .dash-title { font-size: 14px; font-weight: bold; display: flex; align-items: center; gap: 6px; color: #fff; }
         .dash-icon { color: var(--primary-orange); }
-        .dash-badge { background: var(--primary-orange); color: #2d3436; font-size: 12px; font-weight: 800; padding: 4px 12px; border-radius: 12px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); transition: all 0.3s;}
+        .dash-badge { background: var(--primary-orange); color: #2d3436; font-size: 11px; font-weight: 800; padding: 3px 10px; border-radius: 10px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); transition: all 0.3s;}
         
         .dash-badge.completed { background: linear-gradient(135deg, #55efc4, #00b894); color: white; }
 
-        .trophy-track-container { position: relative; display: flex; justify-content: space-between; align-items: center; padding: 0 10px; }
+        .trophy-track-container { position: relative; display: flex; justify-content: space-between; align-items: center; padding: 0 5px; }
         
-        .track-line-bg { position: absolute; top: 50%; left: 10px; right: 10px; height: 4px; background: rgba(255,255,255,0.2); border-radius: 2px; transform: translateY(-50%); z-index: 0; }
+        .track-line-bg { position: absolute; top: 50%; left: 10px; right: 10px; height: 3px; background: rgba(255,255,255,0.2); border-radius: 2px; transform: translateY(-50%); z-index: 0; }
         
         .track-line-active { 
             position: absolute; top: 50%; left: 10px; 
             width: 25%; 
-            height: 4px; background: var(--primary-orange); border-radius: 2px; transform: translateY(-50%); z-index: 0; 
+            height: 3px; background: var(--primary-orange); border-radius: 2px; transform: translateY(-50%); z-index: 0; 
             box-shadow: 0 0 8px rgba(253, 203, 110, 0.6); 
             transition: width 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
         }
 
         .trophy-item { 
-            position: relative; z-index: 1; width: 32px; height: 32px; 
+            position: relative; z-index: 1; width: 28px; height: 28px; 
             background: #2d3436; border: 2px solid rgba(255,255,255,0.3); border-radius: 50%; 
             display: flex; justify-content: center; align-items: center; 
-            color: #636e72; font-size: 12px; 
+            color: #636e72; font-size: 10px; 
             transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); 
         }
         
@@ -103,7 +103,7 @@
             transform: scale(1.15); box-shadow: 0 0 10px rgba(253, 203, 110, 0.5); 
         }
 
-        .trophy-item.final { width: 40px; height: 40px; border-color: rgba(255,255,255,0.5); font-size: 16px; }
+        .trophy-item.final { width: 34px; height: 34px; border-color: rgba(255,255,255,0.5); font-size: 14px; }
         
         .trophy-item.final.active { 
             background: linear-gradient(135deg, #f1c40f, #e67e22); 
@@ -121,9 +121,9 @@
 
         /* 领奖台 */
         .podium-wrap {
-            height: 140px;
+            height: 120px;
             display: flex; justify-content: center; align-items: flex-end; 
-            margin-top: -60px; 
+            margin-top: -50px; 
             position: relative; 
             z-index: 10; 
             padding-bottom: 0px; 
@@ -133,27 +133,27 @@
         .p-1 { z-index: 3; } 
         .p-3 { z-index: 1; margin-left: -15px; }
         
-        .p-img { width: 50px; height: 50px; border-radius: 50%; border: 3px solid white; background: #eee; margin-bottom: -10px; position: relative; z-index: 2; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.2);}
-        .p-1 .p-img { width: 70px; height: 70px; border-color: #f1c40f; margin-bottom: -15px;}
+        .p-img { width: 44px; height: 44px; border-radius: 50%; border: 2px solid white; background: #eee; margin-bottom: -10px; position: relative; z-index: 2; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.2);}
+        .p-1 .p-img { width: 60px; height: 60px; border-color: #f1c40f; margin-bottom: -12px;}
         .p-img img { width: 100%; height: 100%; object-fit: cover; }
 
         .crown {
-            position: absolute; top: -28px; color: #f1c40f; font-size: 32px;
+            position: absolute; top: -24px; color: #f1c40f; font-size: 26px;
             filter: drop-shadow(0 2px 4px rgba(0,0,0,0.4));
             animation: crownFloat 2s ease-in-out infinite;
             z-index: 20; 
         }
         @keyframes crownFloat {
             0%, 100% { transform: translateY(0) rotate(-5deg); }
-            50% { transform: translateY(-8px) rotate(5deg); }
+            50% { transform: translateY(-6px) rotate(5deg); }
         }
 
         .p-box { width: 100%; text-align: center; padding-top: 15px; border-radius: 8px 8px 0 0; color: white; box-shadow: 0 5px 15px rgba(0,0,0,0.2); }
-        .p-1 .p-box { height: 90px; background: linear-gradient(180deg, #f1c40f, #f39c12); padding-top: 20px; }
-        .p-2 .p-box { height: 70px; background: linear-gradient(180deg, #bdc3c7, #95a5a6); }
-        .p-3 .p-box { height: 55px; background: linear-gradient(180deg, #e67e22, #d35400); }
-        .p-name { font-size: 12px; margin-bottom: 2px; text-shadow: 0 1px 1px rgba(0,0,0,0.3); white-space: nowrap; overflow: hidden; max-width: 80px; margin: 0 auto; text-overflow: ellipsis;}
-        .p-score { font-size: 14px; font-weight: bold; }
+        .p-1 .p-box { height: 76px; background: linear-gradient(180deg, #f1c40f, #f39c12); padding-top: 18px; }
+        .p-2 .p-box { height: 60px; background: linear-gradient(180deg, #bdc3c7, #95a5a6); }
+        .p-3 .p-box { height: 50px; background: linear-gradient(180deg, #e67e22, #d35400); }
+        .p-name { font-size: 11px; margin-bottom: 2px; text-shadow: 0 1px 1px rgba(0,0,0,0.3); white-space: nowrap; overflow: hidden; max-width: 70px; margin: 0 auto; text-overflow: ellipsis;}
+        .p-score { font-size: 13px; font-weight: bold; }
 
         /* 列表容器 */
         .list-container {
@@ -269,6 +269,8 @@
         /* 稍微缩小底部头像以节省高度 */
         .my-rank-bar .avatar { width: 30px; height: 30px; border-width: 2px; }
         .my-rank-bar .name { font-size: 14px; }
+        
+        .loading-mask {
             position: fixed; inset: 0; background: rgba(0,0,0,0.35);
             display: none; align-items: center; justify-content: center;
             z-index: 300; color: #fff; font-size: 14px; backdrop-filter: blur(2px);

+ 36 - 17
card/sdk/old/mock_flutter.js

@@ -1,7 +1,26 @@
 (function (window) {
     'use strict';
   
-    console.log('%c [MockFlutter] 已加载,当前处于开发调试模式 ', 'background: #42b983; color: white; font-size: 14px; padding: 4px;');
+    // 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) {
@@ -13,10 +32,10 @@
       const action = payload.action;
       const data = payload.data;
   
-      console.group('%c [MockFlutter] 收到 App 指令 ', 'color: #1aad19; font-weight: bold;');
-      console.log('Action:', action);
-      console.log('Data:', data);
-      console.groupEnd();
+      Logger.group('%c [MockFlutter] 收到 App 指令 ', 'color: #1aad19; font-weight: bold;');
+      Logger.log('Action:', action);
+      Logger.log('Data:', data);
+      Logger.groupEnd();
   
       // 模拟具体行为反馈
       switch (action) {
@@ -30,7 +49,7 @@
           alert(`[模拟App] 正在打开活动列表\nID: ${data.id}\n名称: ${data.mapName}`);
           break;
         case 'back':
-          console.log('[模拟App] 执行返回操作');
+          Logger.log('[模拟App] 执行返回操作');
           // alert('[模拟App] 执行返回操作'); // 弹窗太多会烦,这里只打印
           break;
         case 'toHome':
@@ -52,13 +71,13 @@
           alert(`[模拟App] 正在保存图片 (Base64长度: ${data.base64 ? data.base64.length : '0'})`);
           break;
         case 'getToken':
-            console.log('[模拟App] 收到获取Token请求,1秒后模拟回调...');
+            Logger.log('[模拟App] 收到获取Token请求,1秒后模拟回调...');
             setTimeout(function() {
                 if (window.Bridge && window.Bridge.receiveToken) {
                     window.Bridge.receiveToken('MOCK_TOKEN_FOR_BRIDGE_12345');
-                    console.log('[模拟App] 已通过 Bridge.receiveToken 回调 Token');
+                    Logger.log('[模拟App] 已通过 Bridge.receiveToken 回调 Token');
                 } else {
-                    console.warn('[模拟App] 无法回调 Token,Bridge.receiveToken 未定义');
+                    Logger.warn('[模拟App] 无法回调 Token,Bridge.receiveToken 未定义');
                 }
             }, 1000);
             break;
@@ -74,21 +93,21 @@
             alert(`[模拟App] 剪贴板内容已设置: ${data.data}`);
             break;
         case 'showToast':
-            console.log(`[模拟App] ShowToast: ${data.title} (icon: ${data.icon})`);
+            Logger.log(`[模拟App] ShowToast: ${data.title} (icon: ${data.icon})`);
             break;
         case 'showLoading':
-            console.log(`[模拟App] ShowLoading: ${data.title}`);
+            Logger.log(`[模拟App] ShowLoading: ${data.title}`);
             break;
         case 'hideLoading':
-            console.log(`[模拟App] HideLoading`);
+            Logger.log(`[模拟App] HideLoading`);
             break;
         case 'showModal':
             const confirmed = confirm(`[模拟App] Modal: ${data.title}\n${data.content || ''}`);
-            console.log(`[模拟App] Modal result: ${confirmed ? 'Confirm' : 'Cancel'}`);
+            Logger.log(`[模拟App] Modal result: ${confirmed ? 'Confirm' : 'Cancel'}`);
             break;
 
         default:
-          console.log(`[模拟App] 收到未知指令: ${action}, 数据: `, data);
+          Logger.log(`[模拟App] 收到未知指令: ${action}, 数据: `, data);
           // alert(`[模拟App] 收到未知指令: ${action}`);
       }
     };
@@ -97,7 +116,7 @@
     if (!window.share_wx) {
         window.share_wx = {
             postMessage: function(jsonStr) {
-                console.log('[MockFlutter] share_wx.postMessage 收到:', jsonStr);
+                Logger.log('[MockFlutter] share_wx.postMessage 收到:', jsonStr);
                 alert('[模拟App/旧通道] 微信分享:\n' + jsonStr);
             }
         };
@@ -106,7 +125,7 @@
     if (!window.wx_launch_mini) {
         window.wx_launch_mini = {
             postMessage: function(jsonStr) {
-                console.log('[MockFlutter] wx_launch_mini.postMessage 收到:', jsonStr);
+                Logger.log('[MockFlutter] wx_launch_mini.postMessage 收到:', jsonStr);
                 alert('[模拟App/旧通道] 打开小程序:\n' + jsonStr);
             }
         };
@@ -115,7 +134,7 @@
     if (!window.save_base64) {
         window.save_base64 = {
             postMessage: function(base64Str) {
-                console.log('[MockFlutter] save_base64.postMessage 收到 (Base64长度):', base64Str ? base64Str.length : '0');
+                Logger.log('[MockFlutter] save_base64.postMessage 收到 (Base64长度):', base64Str ? base64Str.length : '0');
                 alert(`[模拟App/旧通道] 保存图片 (Base64长度: ${base64Str ? base64Str.length : '0'})`);
             }
         };

+ 81 - 0
card/sdk/test/STYLE_MIGRATION_GUIDE.md

@@ -0,0 +1,81 @@
+# ColorMapRun H5 Card 风格重制与迁移实验总结 (Style Remake & Migration Experiment)
+
+本文档总结了将原有的 `sdk/index.html` 和 `sdk/detail.html` 迁移至 `sdk/test/` 目录,并在保持原有业务逻辑不变的前提下,重新设计为 **“运动竞技风 (Sporty Tech)”** 的全过程。这份文档可作为未来类似 UI 重构或风格迁移任务的操作流程模板。
+
+## 1. 实验目标
+
+*   **核心任务**: 在不改变任何 JS 逻辑功能(API 调用、交互流程)的前提下,完全重写 UI 样式。
+*   **目标风格**: 运动竞技风 (Sporty Tech) —— 暗色背景、荧光配色、斜切几何图形、动感排版。
+*   **输出产物**: `sdk/test/` 目录下的完整可运行代码。
+
+## 2. 操作流程 (SOP)
+
+### 2.1 环境准备 (Preparation)
+1.  **创建独立工作区**:
+    *   新建目录 `sdk/test/`,避免污染主线代码。
+    *   新建子目录 `sdk/test/css/` 和 `sdk/test/js/`。
+2.  **复制核心依赖**:
+    *   确保所有 JS 逻辑库和 CSS 库都已就位。
+    *   **关键动作**: 复制 `api.js`, `bridge.js`, `mock_flutter.js` 到 `sdk/test/`。
+    *   **关键动作**: 复制 `js/multiavatar.min.js` 到 `sdk/test/js/`。
+    *   **关键动作**: 复制 `css/all.min.css` 到 `sdk/test/css/`。
+    *   **易错点**: **务必复制 `webfonts/` 目录**!FontAwesome 图标依赖于此,否则图标会显示为空白。
+
+### 2.2 逻辑迁移 (Migration)
+1.  **读取原文件**: 读取原始 `index.html` 和 `detail.html` 的完整代码。
+2.  **提取 JS 逻辑**: 将 `<script>` 标签内的所有业务逻辑(包括 `Logger` 封装、API 调用、数据处理、事件监听)完整提取出来。
+3.  **保持引用**: 确保新文件头部引用的 JS/CSS 路径 (`./bridge.js`, `./api.js` 等) 与新目录结构一致。
+
+### 2.3 样式重写 (Re-design)
+1.  **定义主题变量 (`:root`)**:
+    *   设立新的色彩系统(如 `--sport-bg`, `--sport-accent`)。
+    *   统一字体栈。
+2.  **重构 HTML 结构**:
+    *   根据新设计调整 DOM 结构(例如将 Dashboard 的进度条从线性改为网格状)。
+    *   **原则**: 尽量复用原有的 ID (`id="dashBadge"`, `id="rankList"`),这样原有的 JS 逻辑可以直接操作 DOM,无需修改 JS。
+3.  **编写 CSS**:
+    *   应用新的视觉风格(斜切 `transform: skewX`, 动态背景, 荧光边框)。
+    *   **适配移动端**: 注意 `env(safe-area-inset-top)` 和 `bottom` 的适配。
+
+### 2.4 调试与微调 (Refinement)
+1.  **图标与字体检查**: 确认 FontAwesome 图标是否正常显示(检查 `webfonts` 路径)。
+2.  **布局适配**:
+    *   **卡片圆角**: 对于透明背景的卡片,使用 `clip-path: inset(0 round 16px)` 往往比 `border-radius` 更可靠,尤其是在 WebView 环境下。
+    *   **全屏适配**: 确保详情页 (`detail.html`) 占满全屏,使用 `height: 100%; overflow: hidden;` 固定框架,内部列表使用 `flex: 1; overflow-y: auto;` 滚动。
+    *   **状态栏避让**: 顶部导航栏增加 `padding-top: max(30px, env(safe-area-inset-top))` 防止遮挡。
+3.  **文本本地化**: 检查是否有硬编码的英文占位符,将其替换回中文(如 "Loading..." -> "加载中...")。
+
+## 3. 关键注意事项 (Best Practices)
+
+### 3.1 逻辑与视图分离
+*   **ID 锚点**: 在重写 HTML 时,**必须**保留原有 JS 逻辑所依赖的 `id` 和 `class`(如果 JS 通过 class 选择)。例如 `rankList` 容器、`myRankNum` 文本节点等。
+*   **JS 零修改**: 理想情况下,直接复制粘贴原有的 `<script>` 块即可运行。如果必须改 JS,说明 HTML 结构变动过大,需谨慎评估。
+
+### 3.2 WebView 兼容性
+*   **背景透明**: 在做圆角卡片时,`html` 和 `body` 必须设为 `background-color: transparent !important;`。
+*   **圆角裁剪**: 推荐使用 `clip-path` 强制裁剪,以防止子元素(如绝对定位的背景图)溢出圆角。
+*   **滚动穿透**: 全屏页面务必锁定 `body` 滚动,只允许特定容器内部滚动。
+
+### 3.3 WebView 兼容性与透明圆角 (重点更新)
+*   **H5 透明度极限**: H5 页面本身无法直接控制 WebView 宿主控件的背景色透明度。
+*   **圆角裁剪区域的颜色**: 如果 `html` 和 `body` 都已设置为 `background-color: transparent !important;`,但圆角裁剪掉的部分仍显示颜色(如灰色),则该颜色来自**WebView 控件自身的默认背景色**。
+*   **原生透明度配置**: 要实现真正的透明圆角,**必须在 Native App (如 Flutter) 端配置 WebView 控件的背景色为透明**(例如 Flutter 的 `WebView(backgroundColor: Colors.transparent)`)。
+*   **H5 替代方案**: 如果无法修改 Native App 配置,H5 端可以通过将 `html` 或 `body` 的背景色强制设置为与 App 背景色一致的颜色(如白色),来视觉上“模拟”透明效果,以达到无缝融合。
+
+### 3.4 资源完整性
+*   **FontAwesome**: 仅仅复制 CSS 文件是不够的,必须同步复制 `webfonts` 字体文件目录,且保持相对路径一致。
+
+## 4. 沟通模板 (用于后续任务)
+
+如果您希望继续进行类似的风格重制任务,可以使用以下模板指令:
+
+> “请在 `[目标目录]` 下创建一个基于 `[原文件]` 的新版本。
+> 风格要求:`[描述风格,如:赛博朋克/极简/复古]`。
+> 核心要求:**保持所有 JS 业务逻辑不变**,仅重写 HTML/CSS。
+> 注意事项:
+> 1. 确保所有依赖文件(JS/CSS/Fonts)都已正确复制。
+> 2. 保持原有的 DOM ID 以兼容 JS。
+> 3. 适配移动端安全区域和圆角效果。”
+
+---
+*Generated by Gemini based on "Sporty Edition" experiment.*

+ 375 - 0
card/sdk/test/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);

+ 360 - 0
card/sdk/test/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);

File diff suppressed because it is too large
+ 5 - 0
card/sdk/test/css/all.min.css


+ 949 - 0
card/sdk/test/detail.html

@@ -0,0 +1,949 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
+    <title>月挑战赛 - 运动版</title>
+    <!-- 本地调试保留 mock_flutter.js,正式接入时移除 -->
+    <!-- <script src="./mock_flutter.js"></script> -->
+    <script src="./bridge.js"></script>
+    <script src="./api.js"></script>
+    <script src="./js/multiavatar.min.js"></script>
+    <link href="./css/all.min.css" rel="stylesheet">
+    <style>
+        :root {
+            --sport-bg: #0f0f0f;
+            --sport-card: #1e1e1e;
+            --sport-accent: #ccff00; /* Neon Green */
+            --sport-secondary: #00ccff; /* Electric Blue */
+            --sport-text: #ffffff;
+            --sport-dim: #888;
+            --footer-bg: #1a1a1a;
+        }
+
+        * {
+            box-sizing: border-box;
+            margin: 0;
+            padding: 0;
+            font-family: 'Segoe UI', 'Roboto', sans-serif;
+            -webkit-tap-highlight-color: transparent;
+            user-select: none;
+        }
+
+        /* detail.html 应该全屏显示,无需圆角 */
+        html {
+            width: 100%;
+            height: 100%;
+            overflow: hidden; /* 确保全屏不滚动,由内部 list-container 滚动 */
+        }
+
+        body {
+            background: var(--sport-bg);
+            width: 100%;
+            height: 100%; /* 占满全屏 */
+            margin: 0;
+            display: flex;
+            flex-direction: column;
+            color: var(--sport-text);
+        }
+
+        /* 顶部 Header */
+        .header-area {
+            height: 200px; 
+            background: var(--sport-card);
+            padding: 20px;
+            padding-top: max(30px, env(safe-area-inset-top)); /* 调整顶部填充,避开手机状态栏 */
+            position: relative; flex-shrink: 0;
+            z-index: 1; 
+            /* 底部斜切 */
+            clip-path: polygon(0 0, 100% 0, 100% 85%, 0 100%);
+            border-bottom: 2px solid #333;
+        }
+
+        .nav-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 5px; }
+        
+        .icon-btn {
+            width: 36px; height: 36px; 
+            background: #333;
+            border-radius: 8px; /* 方形倒角 */
+            display: flex; align-items: center; justify-content: center;
+            cursor: pointer; border: 1px solid #444;
+            transform: skewX(-10deg);
+        }
+        .icon-btn i { transform: skewX(10deg); } /* 反向纠正图标 */
+
+        .month-select { 
+            font-size: 18px; font-weight: 900; font-style: italic;
+            display: flex; align-items: center; gap: 8px; cursor: pointer; 
+            color: var(--sport-text);
+            text-transform: uppercase;
+        }
+        .month-select span { border-bottom: 2px solid var(--sport-accent); padding-bottom: 2px; }
+
+        /* 仪表盘卡片 */
+        .dashboard-card {
+            margin-top: 15px;
+            background: #000; 
+            border-radius: 0;
+            padding: 15px 20px;
+            border-left: 4px solid var(--sport-accent);
+            box-shadow: 0 10px 30px rgba(0,0,0,0.5);
+            position: relative;
+            z-index: 5; 
+            transform: skewX(-5deg);
+            margin-left: 10px; margin-right: 10px;
+        }
+        /* 内容反向纠正 */
+        .dashboard-card > * { transform: skewX(5deg); }
+
+        .dash-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
+        .dash-title { font-size: 14px; font-weight: bold; display: flex; align-items: center; gap: 8px; color: #aaa; text-transform: uppercase; letter-spacing: 1px; }
+        .dash-icon { color: var(--sport-accent); }
+        
+        .dash-badge { 
+            background: transparent; 
+            color: var(--sport-accent); 
+            font-size: 14px; font-weight: 900; font-style: italic;
+            padding: 2px 8px; 
+            border: 1px solid var(--sport-accent);
+        }
+        
+        .dash-badge.completed { background: var(--sport-accent); color: #000; }
+
+        .trophy-track-container { position: relative; display: flex; justify-content: space-between; align-items: center; padding: 0 5px; margin-top: 10px;}
+        
+        /* 进度条改为分段格子 */
+        .track-bg-grid {
+            position: absolute; top: 50%; left: 10px; right: 10px; height: 6px;
+            background: repeating-linear-gradient(90deg, #222, #222 4px, transparent 4px, transparent 8px);
+            transform: translateY(-50%); z-index: 0;
+        }
+        
+        .track-line-active { 
+            position: absolute; top: 50%; left: 10px; 
+            width: 25%; 
+            height: 6px; background: var(--sport-accent); 
+            transform: translateY(-50%); z-index: 0; 
+            box-shadow: 0 0 10px var(--sport-accent);
+            transition: width 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
+        }
+
+        .trophy-item { 
+            position: relative; z-index: 1; width: 24px; height: 24px; 
+            background: #222; border: 1px solid #444; 
+            transform: rotate(45deg); /* 菱形节点 */
+            display: flex; justify-content: center; align-items: center; 
+            color: #666; font-size: 10px; 
+            transition: all 0.4s; 
+        }
+        
+        .trophy-item i { transform: rotate(-45deg); } /* 图标回正 */
+        
+        .trophy-item.active { 
+            background: #000; border-color: var(--sport-accent); color: var(--sport-accent); 
+            box-shadow: 0 0 8px var(--sport-accent);
+        }
+
+        .trophy-item.final { width: 32px; height: 32px; border-color: #666; font-size: 14px; }
+        
+        .trophy-item.final.active { 
+            background: var(--sport-accent); color: #000; border: none;
+            box-shadow: 0 0 20px var(--sport-accent);
+        }
+
+        /* 领奖台 */
+        .podium-wrap {
+            height: 130px;
+            display: flex; justify-content: center; align-items: flex-end; 
+            margin-top: -40px; 
+            position: relative; 
+            z-index: 10; 
+            padding-bottom: 0px; 
+        }
+        .p-col { display: flex; flex-direction: column; align-items: center; width: 30%; position: relative;}
+        .p-2 { z-index: 2; margin-right: -8px; }
+        .p-1 { z-index: 3; } 
+        .p-3 { z-index: 1; margin-left: -8px; }
+        
+        /* 六边形头像遮罩 */
+        .p-img { 
+            width: 48px; height: 48px; 
+            background: #222; margin-bottom: -15px; position: relative; z-index: 2; 
+            clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
+            display: flex; justify-content: center; align-items: center;
+        }
+        .p-img img { width: 100%; height: 100%; object-fit: cover; }
+        /* 边框模拟 */
+        .p-img::before {
+            content: ''; position: absolute; inset: 0; 
+            border: 2px solid #444;
+            clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
+            pointer-events: none;
+        }
+
+        .p-1 .p-img { width: 64px; height: 64px; margin-bottom: -20px;}
+        .p-1 .p-img::before { border-color: var(--sport-accent); box-shadow: inset 0 0 10px var(--sport-accent); }
+
+        .crown {
+            position: absolute; top: -28px; color: var(--sport-accent); font-size: 24px;
+            filter: drop-shadow(0 0 5px var(--sport-accent));
+            animation: crownFloat 2s ease-in-out infinite;
+            z-index: 20; 
+        }
+        @keyframes crownFloat {
+            0%, 100% { transform: translateY(0); }
+            50% { transform: translateY(-5px); }
+        }
+
+        /* 梯形底座 */
+        .p-box { 
+            width: 100%; text-align: center; padding-top: 20px; 
+            color: white; 
+            clip-path: polygon(15% 0, 85% 0, 100% 100%, 0% 100%);
+        }
+        .p-1 .p-box { 
+            height: 85px; 
+            background: linear-gradient(180deg, rgba(204,255,0,0.2), rgba(204,255,0,0.05)); 
+            border-top: 2px solid var(--sport-accent);
+        }
+        .p-2 .p-box { 
+            height: 65px; 
+            background: linear-gradient(180deg, rgba(255,255,255,0.1), rgba(255,255,255,0.02)); 
+            border-top: 2px solid #888;
+        }
+        .p-3 .p-box { 
+            height: 55px; 
+            background: linear-gradient(180deg, rgba(255,255,255,0.1), rgba(255,255,255,0.02)); 
+            border-top: 2px solid #666;
+        }
+        .p-name { font-size: 10px; margin-bottom: 2px; color: #aaa; white-space: nowrap; overflow: hidden; max-width: 70px; margin: 0 auto; text-overflow: ellipsis;}
+        .p-score { font-size: 14px; font-weight: 900; font-style: italic; color: #fff; }
+        .p-1 .p-score { color: var(--sport-accent); }
+
+        /* 列表容器 */
+        .list-container {
+            flex: 1; background: var(--sport-bg);
+            padding: 0 15px 120px 15px; 
+            overflow-y: auto; 
+            margin-top: 10px;
+            position: relative; z-index: 8; 
+            -webkit-overflow-scrolling: touch; 
+        }
+        
+        .tabs { 
+            display: flex; justify-content: center; gap: 20px; 
+            position: sticky; top: 0; background: var(--sport-bg); z-index: 9;
+            padding-top: 10px; padding-bottom: 10px;
+            border-bottom: 1px solid #222;
+        }
+        
+        .tab { 
+            padding: 8px 0; 
+            font-size: 14px; color: #666; cursor: pointer; 
+            transition: 0.2s; text-transform: uppercase; font-weight: bold;
+            position: relative;
+        }
+        .tab.active { color: var(--sport-text); }
+        .tab.active::after {
+            content: ''; position: absolute; bottom: 0; left: 0; right: 0;
+            height: 3px; background: var(--sport-accent);
+            box-shadow: 0 0 5px var(--sport-accent);
+        }
+        
+        .list-item { 
+            display: flex; align-items: center; padding: 12px 10px; 
+            margin-bottom: 8px;
+            background: #161616;
+            border: 1px solid #222;
+            transform: skewX(-5deg); /* 列表项倾斜 */
+        }
+        /* 内容反向纠正 */
+        .list-item > * { transform: skewX(5deg); }
+
+        .rank { width: 30px; text-align: center; font-weight: 900; font-style: italic; color: #444; font-size: 16px;}
+        /* 前三名排名颜色 */
+        .list-item:nth-child(1) .rank { color: var(--sport-accent); }
+        .list-item:nth-child(2) .rank { color: #fff; }
+        .list-item:nth-child(3) .rank { color: #ccc; }
+
+        .avatar { width: 36px; height: 36px; margin: 0 12px; background: #333; clip-path: circle(50%);}
+        .avatar img { width: 100%; height: 100%; object-fit: cover;}
+        .info { flex: 1; }
+        .name { font-size: 14px; color: #eee; font-weight: bold; }
+        .team { font-size: 10px; color: #666; display: flex; align-items: center; gap: 4px; text-transform: uppercase;}
+        .score { font-size: 16px; font-weight: 900; font-style: italic; color: var(--sport-accent); }
+
+        /* 底部我的排名 */
+        .my-rank-bar {
+            position: fixed; bottom: 0; left: 0; width: 100%;
+            background: #111; border-top: 2px solid var(--sport-accent);
+            display: flex; align-items: center; 
+            padding: 0 20px;
+            padding-top: 10px;
+            padding-bottom: calc(10px + env(safe-area-inset-bottom));
+            box-sizing: border-box;
+            z-index: 99; 
+            box-shadow: 0 -10px 30px rgba(0,0,0,0.8);
+        }
+        .my-rank-bar .rank { font-size: 18px; color: var(--sport-accent); }
+        .my-rank-bar .avatar { width: 34px; height: 34px; border: 2px solid var(--sport-accent); }
+        .my-rank-bar .name { font-size: 14px; }
+        .my-rank-bar .team { font-size: 10px; }
+        .my-score { font-size: 20px; font-weight: 900; font-style: italic; color: #fff !important; margin-left: auto; text-shadow: 0 0 5px rgba(255,255,255,0.5);}
+
+        /* 演示按钮样式 */
+        .demo-section {
+            margin: 20px 0 0 0; border-top: 1px dashed #333; padding-top: 15px;
+        }
+        .demo-label { font-size: 12px; color: #666; margin-bottom: 10px; }
+        .demo-controls { display: flex; justify-content: center; gap: 10px; }
+        .demo-btn {
+            background: #333; border: 1px solid #555; padding: 6px 12px; font-size: 12px; color: #aaa; cursor: pointer;
+            text-transform: uppercase;
+        }
+        .demo-btn:active { background: var(--sport-accent); color: #000; border-color: var(--sport-accent); }
+
+        /* 下拉菜单 & 模态框 */
+        .dropdown { 
+            position: absolute; top: 60px; left: 50%; transform: translateX(-50%); width: 200px; 
+            background: #222; border: 1px solid #444;
+            box-shadow: 0 10px 50px rgba(0,0,0,0.8); display: none; flex-direction: column; z-index: 200; 
+        }
+        .dropdown.show { display: flex; animation: dropIn 0.2s ease-out; }
+        .dd-item { padding: 12px; color: #aaa; font-size: 14px; text-align: center; border-bottom: 1px solid #333; cursor: pointer; }
+        .dd-active { color: #000; font-weight: bold; background: var(--sport-accent); }
+        @keyframes dropIn { from{opacity:0; transform:translateX(-50%) translateY(-10px);} to{opacity:1; transform:translateX(-50%) translateY(0);} }
+
+        .modal-mask { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); backdrop-filter: blur(5px); z-index: 999; display: flex; justify-content: center; align-items: center; opacity: 0; pointer-events: none; transition: opacity 0.3s; }
+        .modal-mask.show { opacity: 1; pointer-events: auto; }
+        .modal-body { 
+            width: 80%; max-width: 320px; 
+            background: #1a1a1a; border: 1px solid #444;
+            padding: 25px; text-align: center; transform: scale(0.8); transition: transform 0.3s; 
+            box-shadow: 0 20px 50px rgba(0,0,0,0.5); 
+        }
+        .modal-mask.show .modal-body { transform: scale(1); }
+        .m-close { 
+            background: transparent; color: var(--sport-accent); 
+            padding: 10px 25px; border: 1px solid var(--sport-accent); 
+            font-weight: bold; cursor: pointer; margin-top: 15px; text-transform: uppercase;
+        }
+        .rule-box { text-align: left; background: #111; border: 1px solid #333; padding: 15px; color: #ccc; font-size: 13px; line-height: 1.6; }
+        .rule-item { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 8px; }
+        .rule-item:last-child { margin-bottom: 0; }
+        .rule-item i { color: var(--sport-accent); margin-top: 2px; }
+
+        .loading-mask {
+            position: fixed; inset: 0; background: rgba(0,0,0,0.7);
+            display: none; align-items: center; justify-content: center;
+            z-index: 300; color: #fff; font-size: 14px; backdrop-filter: blur(2px);
+        }
+        .loading-mask.show { display: flex !important; } /* Fix: display flex to center content */
+        .loading-spinner {
+            border: 4px solid rgba(255,255,255,0.1);
+            border-top-color: var(--sport-accent);
+            border-radius: 50%;
+            width: 36px; height: 36px;
+            animation: spin 1s linear infinite;
+            margin-right: 10px;
+        }
+        @keyframes spin { to { transform: rotate(360deg); } }
+    </style>
+</head>
+<body>
+
+    <!-- 头部 -->
+    <div class="header-area">
+        <div class="nav-bar">
+            <div class="icon-btn" onclick="handleBack()"><i class="fa-solid fa-chevron-left" style="color:#ccc"></i></div>
+            <div class="month-select" onclick="toggleDropdown()">
+                <span id="currentMonthText">11月挑战赛</span> <i class="fa-solid fa-caret-down" style="font-size: 12px; margin-left: 5px;"></i>
+            </div>
+            <div class="icon-btn" onclick="openModal()"><i class="fa-solid fa-info" style="color:#ccc"></i></div>
+        </div>
+
+        <!-- 仪表盘卡片 -->
+        <div class="dashboard-card">
+            <div class="dash-header">
+                <div class="dash-title">
+                    任务状态
+                </div>
+                <div class="dash-badge" id="dashBadge">1 / 4</div>
+            </div>
+
+            <div class="trophy-track-container">
+                <div class="track-bg-grid"></div>
+                <div class="track-line-active" id="progressLine"></div>
+                
+                <!-- 图标1 -->
+                <div class="trophy-item active" id="t1"><i class="fa-solid fa-check"></i></div>
+                <!-- 图标2 -->
+                <div class="trophy-item" id="t2"><i class="fa-solid fa-lock"></i></div>
+                <!-- 图标3 -->
+                <div class="trophy-item" id="t3"><i class="fa-solid fa-lock"></i></div>
+                <!-- 图标4 (Final) -->
+                <div class="trophy-item final" id="t4"><i class="fa-solid fa-trophy"></i></div>
+            </div>
+        </div>
+    </div>
+
+    <!-- 下拉菜单 -->
+    <div class="dropdown" id="dropdown"></div>
+
+    <!-- 领奖台 -->
+    <div class="podium-wrap" id="podiumWrap"></div>
+
+    <!-- 列表区 -->
+    <div class="list-container">
+        <div class="tabs">
+            <div class="tab active" onclick="switchTab('score', this)">积分排名</div>
+            <div class="tab" onclick="switchTab('venue', this)">场地排名</div>
+        </div>
+        
+        <div id="rankList"></div>
+    </div>
+
+    <!-- 底部我的排名 -->
+    <div class="my-rank-bar">
+        <div class="rank" id="myRankNum">--</div>
+        <div class="avatar" id="myAvatar"></div>
+        <div class="info"><div class="name" id="myName">我</div><div class="team" id="myTeam">正在加载...</div></div>
+        <div class="my-score" id="myScoreValue">--</div>
+    </div>
+
+    <!-- 模态框 -->
+    <div class="modal-mask" id="infoModal">
+        <div class="modal-body">
+            <h3 style="color:var(--sport-text); margin-bottom:15px; text-transform:uppercase; letter-spacing:2px;">规则说明</h3>
+            <div id="ruleContent" class="rule-box">
+                <div class="rule-item"><i class="fa-solid fa-angle-right"></i><div><strong>积分规则:</strong> 在指定公园找到打卡点,耗时越短积分越高。</div></div>
+                <div class="rule-item"><i class="fa-solid fa-angle-right"></i><div><strong>场地排行:</strong> 按解锁的公园场地数量排名。</div></div>
+                <div class="rule-item"><i class="fa-solid fa-angle-right"></i><div><strong>奖杯获取:</strong> 完成4次有效挑战点亮全部奖杯。</div></div>
+            </div>
+
+            <!-- 演示功能区 -->
+            <div class="demo-section">
+                <div class="demo-label">功能演示</div>
+                <div class="demo-controls">
+                    <button class="demo-btn" onclick="demoProgress(1);closeModal()">1/4</button>
+                    <button class="demo-btn" onclick="demoProgress(3);closeModal()">3/4</button>
+                    <button class="demo-btn" onclick="demoProgress(4);closeModal()">4/4</button>
+                </div>
+            </div>
+
+            <button class="m-close" onclick="closeModal()">关闭</button>
+        </div>
+    </div>
+
+    <!-- 加载中遮罩 -->
+    <div class="loading-mask" id="loadingMask">
+        <div class="loading-spinner"></div>
+        <div>数据加载中...</div>
+    </div>
+
+    <script>
+        const dropdown = document.getElementById('dropdown');
+        const rankListEl = document.getElementById('rankList');
+        const trackLine = document.getElementById('progressLine');
+        const podiumWrap = document.getElementById('podiumWrap');
+        const ruleContent = document.getElementById('ruleContent');
+        const loadingMask = document.getElementById('loadingMask');
+        const state = {
+            activeTab: 'score',
+            scoreList: [],
+            scoreListRendered: null,
+            venueList: [],
+            venueListRendered: null,
+            months: [],
+            currentYM: null,
+            currentMonthData: null,
+            allMonthsData: null
+        };
+
+        // Logger Utility
+        const Logger = {
+            _isDev: false, // Will be set by initPage based on useMock
+
+            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() {
+                // Always log errors, regardless of dev mode
+                console.error.apply(console, arguments);
+            }
+        };
+
+        function getQuery(name) {
+            const params = new URLSearchParams(window.location.search);
+            return params.get(name);
+        }
+
+        function getYearMonth() {
+            const now = new Date();
+            const year = parseInt(getQuery('year'), 10) || now.getFullYear();
+            const month = parseInt(getQuery('month'), 10) || (now.getMonth() + 1);
+            return { year: year, month: month };
+        }
+        
+        function getRecentMonths(count) {
+            const ym = getYearMonth();
+            const list = [];
+            let y = ym.year;
+            let m = ym.month;
+            for (let i = 0; i < (count || 3); i++) {
+                list.push({ year: y, month: m });
+                m -= 1;
+                if (m === 0) { m = 12; y -= 1; }
+            }
+            return list;
+        }
+
+        function handleBack() {
+            if (window.Bridge && Bridge.toHome) Bridge.toHome();
+            else window.history.back(); // Fallback for web environment
+        }
+
+        function openModal() { document.getElementById('infoModal').classList.add('show'); }
+        function closeModal() { document.getElementById('infoModal').classList.remove('show'); }
+        function toggleDropdown() { dropdown.style.display = (dropdown.style.display === 'flex') ? 'none' : 'flex'; }
+
+        function setLoading(isLoading) {
+            rankListEl.style.opacity = isLoading ? '0.4' : '1';
+            // Use flex for the new layout
+            if (isLoading) loadingMask.classList.add('show');
+            else loadingMask.classList.remove('show');
+        }
+
+        function buildAvatar(name, salt) {
+            // Use local Multiavatar library (Pure JS, no external requests)
+            // It generates high-quality SVG avatars
+            const seedBase = name || 'user';
+            const seed = seedBase + (salt || '');
+            
+            // multiavatar(seed) returns an SVG string
+            const svgCode = multiavatar(seed);
+            
+            // We need to wrap it in a container or return it as a data URI or direct HTML
+            // Since the existing code expects an <img> tag or innerHTML, putting SVG directly is best for crispness.
+            // However, the existing styling puts it inside a small circle div.
+            // Multiavatar SVG is square, so we rely on parent CSS (overflow: hidden) to clip it to a circle.
+            return svgCode;
+        }
+
+        function setProgress(real, target) {
+            const badge = document.getElementById('dashBadge');
+            const t1 = document.getElementById('t1');
+            const t2 = document.getElementById('t2');
+            const t3 = document.getElementById('t3');
+            const t4 = document.getElementById('t4');
+            const safeReal = Math.max(real || 0, 0);
+            const safeTarget = target && target > 0 ? target : 4;
+            const ratio = Math.min(safeReal / safeTarget, 1);
+            const percent = Math.max(0, Math.min(1, ratio)) * 100;
+            trackLine.style.width = percent + '%';
+            const textReal = safeReal >= safeTarget ? safeTarget : safeReal;
+            badge.innerText = safeReal >= safeTarget ? 'SUCCESS' : `${textReal} / ${safeTarget}`;
+            badge.classList.toggle('completed', safeReal >= safeTarget);
+            function setIconActive(el, active) {
+                if (active) {
+                    el.classList.add('active');
+                    if (!el.classList.contains('final')) el.innerHTML = '<i class="fa-solid fa-check"></i>';
+                } else {
+                    el.classList.remove('active');
+                    if (!el.classList.contains('final')) el.innerHTML = '<i class="fa-solid fa-lock"></i>';
+                }
+            }
+            setIconActive(t1, safeReal >= 1);
+            setIconActive(t2, safeReal >= 2);
+            setIconActive(t3, safeReal >= 3);
+            setIconActive(t4, safeReal >= safeTarget);
+        }
+
+        function renderBadge(real, target) {
+            setProgress(real, target);
+        }
+
+        function findMonthProgress(year, month, currentMonthData, allMonthsData) {
+            const result = { realNum: 0, targetNum: 4 };
+            const pick = (arr) => {
+                if (!arr || !arr.length) return null;
+                for (let i = 0; i < arr.length; i++) {
+                    if (Number(arr[i].month) === Number(month)) return arr[i];
+                }
+                return null;
+            };
+            const fromCurrent = currentMonthData && pick(currentMonthData.monthRs || []);
+            if (fromCurrent) return { realNum: fromCurrent.realNum || 0, targetNum: fromCurrent.targetNum || 4 };
+            if (allMonthsData && allMonthsData.length) {
+                for (let i = 0; i < allMonthsData.length; i++) {
+                    const item = allMonthsData[i];
+                    if (item.year && Number(item.year) !== Number(year)) continue;
+                    const found = pick(item.monthRs || []);
+                    if (found) return { realNum: found.realNum || 0, targetNum: found.targetNum || 4 };
+                }
+            }
+            return result;
+        }
+
+        function renderMonths(list) {
+            dropdown.innerHTML = '';
+            if (!list || list.length === 0) {
+                dropdown.innerHTML = '<div class="dd-item">暂无月份数据</div>';
+                return;
+            }
+            list.forEach((item, idx) => {
+                const title = `${item.month}月挑战赛`;
+                const div = document.createElement('div');
+                const isCurrent = state.currentYM && state.currentYM.year === item.year && state.currentYM.month === item.month;
+                div.className = 'dd-item' + (isCurrent ? ' dd-active' : '');
+                div.innerText = (idx === 0 && !isCurrent) ? `${title} (本月)` : title;
+                div.onclick = () => {
+                    selectMonth(item.year, item.month, title);
+                };
+                dropdown.appendChild(div);
+            });
+        }
+
+        function renderPodium(list, tabType) {
+            const type = tabType || state.activeTab || 'score';
+            podiumWrap.innerHTML = '';
+            
+            // Ensure we have at least 3 elements for the podium, filling with null if less.
+            // This allows the podium structure to always be rendered, even if slots are empty.
+            const podiumData = Array(3).fill(null);
+            if (list && list.length > 0) {
+                list.slice(0, 3).forEach((item, index) => {
+                    podiumData[index] = item;
+                });
+            }
+
+            const p1 = podiumData[0]; // Actual Rank 1
+            const p2 = podiumData[1]; // Actual Rank 2
+            const p3 = podiumData[2]; // Actual Rank 3
+
+            // Define the visual order for rendering (2nd, 1st, 3rd)
+            const podiumItems = [
+                { person: p2, className: 'p-2' }, // Left column - Second place
+                { person: p1, className: 'p-1' }, // Middle column - First place
+                { person: p3, className: 'p-3' }  // Right column - Third place
+            ];
+
+            podiumItems.forEach(itemConfig => {
+                const person = itemConfig.person;
+                const col = document.createElement('div');
+                col.className = 'p-col ' + itemConfig.className;
+                
+                const name = person ? (person.nickName || person.name || person.userName) : '---';
+                const score = person ? (person.score != null ? person.score : (person.inRankNum != null ? person.inRankNum : '')) : '';
+                // Placeholder avatar for empty slots or Multiavatar for actual people
+                const avatarContent = person ? buildAvatar(name, person.rankNum) : '<div style="width:100%;height:100%;background:#111;display:flex;align-items:center;justify-content:center;color:#333;font-size:12px;font-weight:bold;">?</div>';
+
+                // Only the actual first place gets the crown, and only if that person exists
+                const isActualFirst = (person === p1 && p1 !== null); 
+
+                col.innerHTML = `
+                    ${isActualFirst ? '<div class="crown"><i class="fa-solid fa-crown"></i></div>' : ''}
+                    <div class="p-img">${avatarContent}</div>
+                    <div class="p-box">
+                        <div class="p-name">${name}</div>
+                        <div class="p-score">${score}</div>
+                    </div>
+                `;
+                podiumWrap.appendChild(col);
+            });
+
+            // The remaining items for the list below the podium
+            const remaining = list && list.length > 3 ? list.slice(3) : [];
+            if (type === 'venue') {
+                state.venueListRendered = remaining;
+            } else {
+                state.scoreListRendered = remaining;
+            }
+            if (state.activeTab === type) renderRankList(remaining);
+        }
+
+        function renderRankList(list) {
+            rankListEl.innerHTML = '';
+            if (!list || list.length === 0) {
+                rankListEl.innerHTML = '<div style="padding:20px; color:#666; text-align:center; text-transform:uppercase;">暂无数据</div>';
+                return;
+            }
+            list.forEach((item, idx) => {
+                const rankNum = item.rankNum || idx + 1;
+                const name = item.nickName || item.name || item.userName || item.teamName || '选手';
+                const scoreVal = (item.score != null ? item.score : item.inRankNum);
+                const score = scoreVal != null ? scoreVal : '--';
+                const team = item.teamName || item.coiName || '';
+                const teamIcon = team ? '<i class="fa-solid fa-users"></i> ' : '<i class="fa-solid fa-user"></i> ';
+                const row = document.createElement('div');
+                row.className = 'list-item';
+                row.innerHTML = `
+                    <div class="rank">${rankNum}</div>
+                    <div class="avatar">${buildAvatar(name, rankNum)}</div>
+                    <div class="info">
+                        <div class="name">${name}</div>
+                        <div class="team">${teamIcon}${team || '个人'}</div>
+                    </div>
+                    <div class="score">${score}</div>
+                `;
+                rankListEl.appendChild(row);
+            });
+        }
+
+        function renderMyInfo(myRank, myScore, userInfo) {
+            const rankNumEl = document.getElementById('myRankNum');
+            const myScoreEl = document.getElementById('myScoreValue');
+            const myNameEl = document.getElementById('myName');
+            const myTeamEl = document.getElementById('myTeam');
+            const myAvatarEl = document.getElementById('myAvatar');
+            const rankVal = myRank && Number(myRank.rankNum);
+            const hasRank = rankVal > 0;
+            rankNumEl.innerText = hasRank ? rankVal : '--';
+            myScoreEl.innerText = hasRank && myScore && myScore.score != null ? myScore.score : '--';
+            const name = (userInfo && (userInfo.nickName || userInfo.userName)) || '我';
+            myNameEl.innerText = name;
+            myTeamEl.innerText = hasRank ? '继续挑战' : '';
+            myAvatarEl.innerHTML = buildAvatar(name, 'me');
+            
+            // Remove click listener if exists (clean slate)
+            document.querySelector('.my-rank-bar').onclick = null;
+        }
+
+        function renderGuestState() {
+            const rankNumEl = document.getElementById('myRankNum');
+            const myScoreEl = document.getElementById('myScoreValue');
+            const myNameEl = document.getElementById('myName');
+            const myTeamEl = document.getElementById('myTeam');
+            const myAvatarEl = document.getElementById('myAvatar');
+            const myRankBar = document.querySelector('.my-rank-bar');
+
+            rankNumEl.innerText = '--';
+            myScoreEl.innerText = '';
+            myNameEl.innerText = '游客';
+            myTeamEl.innerText = '点击登录';
+            myTeamEl.style.color = 'var(--sport-accent)'; 
+            
+            // Random avatar
+            const randomSeed = 'guest_' + Math.floor(Math.random() * 10000);
+            myAvatarEl.innerHTML = buildAvatar('Guest', randomSeed);
+
+            // Add click listener
+            myRankBar.onclick = function() {
+                if (window.Bridge && Bridge.toLogin) {
+                    Bridge.toLogin();
+                }
+            };
+        }
+
+        function renderRules(config) {
+            if (!config || !config.configJson) return;
+            try {
+                const parsed = JSON.parse(config.configJson);
+                const rules = parsed.popupRuleList || [];
+                if (rules.length > 0 && rules[0].data && rules[0].data.content) {
+                    ruleContent.innerHTML = rules[0].data.content;
+                }
+            } catch (err) {
+                Logger.warn('[Rule] parse error', err);
+            }
+        }
+
+        function switchTab(type, tabElement) {
+            state.activeTab = type;
+            document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+            if (tabElement) tabElement.classList.add('active');
+            const baseList = type === 'venue' ? state.venueList : state.scoreList;
+            renderPodium(baseList, type);
+            const rendered = type === 'venue' ? (state.venueListRendered || baseList.slice(3)) : (state.scoreListRendered || baseList.slice(3));
+            renderRankList(rendered);
+        }
+
+        function demoProgress(step) {
+            setProgress(step, 4);
+        }
+
+        async function loadMonthData(year, month) {
+            state.currentYM = { year: year, month: month };
+            document.getElementById('currentMonthText').innerText = `${month}月挑战赛`;
+            renderMonths(state.months);
+            setLoading(true);
+            try {
+                const monthRank = await API.request('MonthRankDetailQuery', { year: year, month: month, dispArrStr: 'grad,mapNum' });
+                const monthGrad = monthRank && monthRank.gradRs ? monthRank.gradRs : [];
+                const monthMap = monthRank && monthRank.mapNumRs ? monthRank.mapNumRs : [];
+                state.scoreList = monthGrad;
+                state.venueList = monthMap;
+                const prog = findMonthProgress(year, month, state.currentMonthData, state.allMonthsData);
+                renderBadge(prog.realNum, prog.targetNum);
+                renderPodium(state.activeTab === 'venue' ? state.venueList : state.scoreList, state.activeTab);
+                switchTab(state.activeTab, document.querySelector('.tab.active'));
+            } catch (err) {
+                Logger.error('[Month] 加载失败', err);
+                rankListEl.innerHTML = '<div style="padding:20px; color:#d63031;">数据加载失败,请重试</div>';
+            } finally {
+                setLoading(false);
+            }
+        }
+
+        function selectMonth(year, month, title) {
+            dropdown.style.display = 'none';
+            loadMonthData(year, month);
+        }
+
+        async function initPage() {
+            // Platform detection for CSS adjustments
+            if (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream) {
+                document.body.classList.add('platform-ios');
+            }
+
+            const ecId = getQuery('ecId') || '4';
+            const token = getQuery('token');
+            let baseUrl = getQuery('baseUrl') || undefined;
+            const env = (getQuery('env') || '').toLowerCase();
+            const useMock = env === 'mock';
+            Logger.init(useMock); // Initialize logger based on mock status
+            const ym = getYearMonth();
+            
+            // 1. Initialize with 6 months
+            state.months = getRecentMonths(6); 
+            
+            if (!baseUrl && !useMock) baseUrl = 'https://colormaprun.com/api/card/';
+            if (window.Bridge && window.Bridge.onToken) Bridge.onToken(API.setToken);
+            API.init({ token: token || '', useMock: useMock, baseUrl: baseUrl });
+            const allowLogin = !useMock && token;
+
+            renderMonths(state.months);
+            setLoading(true);
+
+            async function safeCall(promiseFactory) {
+                try { return await promiseFactory(); }
+                catch (err) { Logger.warn('[Optional API] ignore error', err); return null; }
+            }
+
+            // Helper to load data for a specific month
+            async function fetchMonthData(year, month) {
+                return await API.request('MonthRankDetailQuery', { year: year, month: month, dispArrStr: 'grad,mapNum' });
+            }
+
+            try {
+                // 2. Try loading current month first
+                let targetYM = { year: ym.year, month: ym.month };
+                let monthRank = await fetchMonthData(targetYM.year, targetYM.month);
+                
+                // 3. Check if current month has data
+                let monthGrad = monthRank && monthRank.gradRs ? monthRank.gradRs : [];
+                let monthMap = monthRank && monthRank.mapNumRs ? monthRank.mapNumRs : [];
+                let hasData = monthGrad.length > 0 || monthMap.length > 0;
+
+                // 4. If no data, search backwards in the 6-month list
+                if (!hasData) {
+                    // Start from index 1 because index 0 (current month) is already checked
+                    for (let i = 1; i < state.months.length; i++) {
+                        const checkYM = state.months[i];
+                        const checkRank = await fetchMonthData(checkYM.year, checkYM.month);
+                        const checkGrad = checkRank && checkRank.gradRs ? checkRank.gradRs : [];
+                        const checkMap = checkRank && checkRank.mapNumRs ? checkRank.mapNumRs : [];
+                        
+                        if (checkGrad.length > 0 || checkMap.length > 0) {
+                            // Found data! Update target and data
+                            targetYM = checkYM;
+                            monthRank = checkRank;
+                            monthGrad = checkGrad;
+                            monthMap = checkMap;
+                            hasData = true;
+                            break; 
+                        }
+                    }
+                }
+
+                // Update state with the found (or original empty) data
+                state.currentYM = targetYM;
+                state.scoreList = monthGrad;
+                state.venueList = monthMap;
+
+                let base, currentMonth, allMonths, myRank, myScore, userInfo, cardConfig;
+                if (useMock || allowLogin) {
+                    [base, currentMonth, allMonths, myRank, myScore, userInfo, cardConfig] = await Promise.all([
+                        safeCall(() => API.getCardBase(ecId, 'rank')),
+                        safeCall(() => API.request('CurrentMonthlyChallengeQuery', { ecId: ecId })),
+                        safeCall(() => API.getMonthlyChallenge()),
+                        safeCall(() => API.getUserCurrentRank(ecId)),
+                        safeCall(() => API.getScore(ecId)),
+                        safeCall(() => API.getUserInfo()),
+                        safeCall(() => API.getCardConfig(ecId, 'rank'))
+                    ]);
+                } else {
+                    base = { ecName: `${targetYM.month}月挑战赛` };
+                    currentMonth = { monthRs: [{ month: targetYM.month, realNum: 0, targetNum: 4 }] };
+                    allMonths = [];
+                    myRank = null;
+                    myScore = null;
+                    userInfo = null;
+                    cardConfig = null;
+                }
+
+                const selfRow = monthGrad.find && monthGrad.find(item => item.isSelf === 1);
+                if (!base && monthGrad.length) base = { ecName: `${targetYM.month}月挑战赛` };
+                state.currentMonthData = currentMonth || null;
+                state.allMonthsData = allMonths || null;
+                
+                // IMPORTANT: Render with the actually selected month (targetYM)
+                const prog = findMonthProgress(targetYM.year, targetYM.month, state.currentMonthData, state.allMonthsData);
+                document.getElementById('currentMonthText').innerText = `${targetYM.month}月挑战赛`;
+                
+                // Re-render dropdown to highlight correct month
+                renderMonths(state.months);
+
+                renderBadge(prog.realNum, prog.targetNum);
+                renderPodium(state.scoreList, 'score');
+                switchTab(state.activeTab, document.querySelector('.tab.active'));
+                
+                if (!token && !useMock) {
+                    renderGuestState();
+                } else {
+                    let hasRenderedMyInfo = false;
+                    if (allowLogin && myRank) {
+                        const rankVal = Number(myRank.rankNum);
+                        if (rankVal > 0) {
+                            renderMyInfo({ rankNum: rankVal }, myScore, userInfo);
+                        } else {
+                            renderMyInfo({ rankNum: null }, null, userInfo);
+                        }
+                        hasRenderedMyInfo = true;
+                    } else if (selfRow) {
+                        renderMyInfo({ rankNum: selfRow.rankNum }, { score: selfRow.inRankNum || selfRow.score }, { nickName: selfRow.userName });
+                        hasRenderedMyInfo = true;
+                    }
+                    if (!hasRenderedMyInfo) {
+                        renderMyInfo(myRank, myScore, userInfo);
+                    }
+                }
+
+                renderRules(cardConfig);
+            } finally {
+                setLoading(false);
+                loadingMask.classList.remove('show'); // Force remove show class
+            }
+        }
+
+        document.addEventListener('click', (e) => {
+            if(!e.target.closest('.month-select') && !e.target.closest('.dropdown')) { dropdown.style.display = 'none'; }
+        });
+
+        document.addEventListener('DOMContentLoaded', initPage);
+    </script>
+</body>
+</html>

+ 249 - 0
card/sdk/test/index.html

@@ -0,0 +1,249 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+    <title>月度排行榜 - Sporty Edition</title>
+    <link href="./css/all.min.css" rel="stylesheet">
+    <style>
+        :root {
+            --sport-bg: #0f0f0f;
+            --sport-card: #1a1a1a;
+            --sport-accent: #ccff00; /* Neon Green */
+            --sport-text: #ffffff;
+            --sport-dim: #444;
+        }
+
+        * {
+            box-sizing: border-box;
+            margin: 0;
+            padding: 0;
+            font-family: 'Segoe UI', 'Roboto', 'Helvetica Neue', sans-serif;
+            -webkit-tap-highlight-color: transparent;
+            user-select: none;
+        }
+
+        html, body {
+            width: 100%;
+            height: 100%;
+            overflow: hidden;
+            background: transparent; /* 恢复原版简写 */
+            display: flex;
+            justify-content: center;
+            align-items: center;
+        }
+
+        /* 动态背景线现在放在 .card-container 上,并被其圆角裁剪 */
+        .card-container::before {
+            content: '';
+            position: absolute;
+            top: 0; left: 0; right: 0; bottom: 0;
+            background: repeating-linear-gradient(
+                45deg,
+                transparent,
+                transparent 10px,
+                rgba(255, 255, 255, 0.03) 10px,
+                rgba(255, 255, 255, 0.03) 20px
+            );
+            z-index: -1; /* 确保在卡片内容下方 */
+        }
+        /* 动态背景线 */
+        body::before { display: none; } /* 移除 body 上的伪元素 */
+
+
+        .card-container {
+            width: 100%; /* 填充整个视口 */
+            height: 100%; /* 填充整个视口 */
+            background: var(--sport-card); /* 卡片的主背景 */
+            border-radius: 16px; /* 卡片的圆角 */
+            position: relative;
+            z-index: 1;
+            overflow: hidden;
+            display: flex;
+            flex-direction: column;
+            cursor: pointer;
+            border: 1px solid #333;
+            transition: transform 0.1s;
+        }
+
+        .card-container:active {
+            transform: scale(0.98);
+        }
+
+        /* 装饰性斜切块 */
+        .deco-slash {
+            position: absolute;
+            top: -50px;
+            right: -100px;
+            width: 300px;
+            height: 300px;
+            background: var(--sport-accent);
+            transform: rotate(45deg);
+            opacity: 0.1;
+            pointer-events: none;
+            z-index: 0; /* 放置在内容层下方,避免覆盖圆角 */
+        }
+
+        .content-layer {
+            flex: 1;
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            justify-content: center;
+            padding: 20px;
+            padding-bottom: 40px; /* 增加底部填充,确保奖杯在内 */
+        }
+
+        .year-tag {
+            font-size: 6vmin;
+            color: #888;
+            font-weight: 700;
+            letter-spacing: 4px;
+            font-style: italic;
+            margin-bottom: 10px;
+        }
+
+        .month-wrapper {
+            position: relative;
+            display: flex;
+            justify-content: center;
+            align-items: center;
+        }
+
+        .month-num {
+            font-size: 35vmin; /* 缩小月份字体 */
+            font-weight: 900;
+            line-height: 0.8;
+            color: transparent;
+            -webkit-text-stroke: 2px var(--sport-text);
+            font-style: italic;
+            transform: skewX(-10deg);
+            position: relative;
+            z-index: 2;
+        }
+        
+        /* 月份背后的实心字,营造重影效果 */
+        .month-num::after {
+            content: attr(data-month);
+            position: absolute;
+            left: 10px;
+            top: 10px;
+            color: var(--sport-accent);
+            -webkit-text-stroke: 0;
+            z-index: -1;
+            opacity: 0.6;
+        }
+
+        .label-tag {
+            margin-top: 30px; /* 稍微上移标签 */
+            background: var(--sport-accent);
+            color: #000;
+            font-weight: 900;
+            padding: 8px 24px;
+            font-size: 5vmin;
+            text-transform: uppercase;
+            transform: skewX(-15deg);
+            box-shadow: 0 0 15px rgba(204, 255, 0, 0.4);
+        }
+        
+        .label-tag span {
+            display: block;
+            transform: skewX(15deg); /* 反向纠正文字 */
+        }
+
+        .trophy-icon {
+            margin-top: 30px; /* 调整上边距 */
+            font-size: 10vmin;
+            color: #fff;
+            animation: bounce 2s infinite;
+        }
+
+        @keyframes bounce {
+            0%, 100% { transform: translateY(0); }
+            50% { transform: translateY(-10px); }
+        }
+
+    </style>
+</head>
+<body>
+
+    <div class="card-container" onclick="redirectToDetail()">
+        <div class="deco-slash"></div>
+        <div class="content-layer">
+            <div class="year-tag">2025 赛季</div>
+            <div class="month-wrapper">
+                <div class="month-num" data-month="12">12</div>
+            </div>
+            <div class="label-tag"><span>挑战赛</span></div>
+            <div class="trophy-icon">
+                <i class="fa-solid fa-trophy"></i>
+            </div>
+        </div>
+    </div>
+
+    <script src="./bridge.js"></script>
+    <script>
+        // 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);
+            }
+        };
+
+        function getQueryParam(name) {
+            const params = new URLSearchParams(window.location.search);
+            return params.get(name);
+        }
+
+        // Initialize Logger
+        const env = (getQueryParam('env') || '').toLowerCase();
+        Logger.init(env === 'mock'); // Only log if env=mock
+
+        function redirectToDetail() {
+            const token = getQueryParam('token');
+            const id = getQueryParam('id');
+            let detailUrl = 'detail.html';
+
+            const queryParams = [];
+            if (token) {
+                queryParams.push(`token=${encodeURIComponent(token)}`);
+            }
+            if (id) {
+                queryParams.push(`id=${encodeURIComponent(id)}`);
+            }
+
+            if (queryParams.length > 0) {
+                detailUrl += `?${queryParams.join('&')}`;
+            }
+            
+            detailUrl += (detailUrl.includes('?') ? '&' : '?') + 'full=true';
+
+            Logger.log("Navigating to:", detailUrl);
+            
+            if (window.Bridge && window.Bridge.appAction) {
+                Bridge.appAction(detailUrl);
+            } else {
+                window.location.href = detailUrl;
+            }
+        }
+    </script>
+</body>
+</html>

File diff suppressed because it is too large
+ 0 - 0
card/sdk/test/js/multiavatar.min.js


+ 146 - 0
card/sdk/test/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);

BIN
card/sdk/test/webfonts/fa-brands-400.woff2


BIN
card/sdk/test/webfonts/fa-regular-400.woff2


BIN
card/sdk/test/webfonts/fa-solid-900.woff2


Some files were not shown because too many files changed in this diff