zhangyan 1 月之前
父节点
当前提交
af589e49b7

二进制
card/sdk/bg.jpg


二进制
card/sdk/card.png


文件差异内容过多而无法显示
+ 5 - 0
card/sdk/css/all.min.css


+ 148 - 47
card/sdk/detail.html

@@ -3,12 +3,13 @@
 <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>11月挑战赛</title>
+    <title>月挑战赛</title>
     <!-- 本地调试保留 mock_flutter.js,正式接入时移除 -->
-    <script src="./mock_flutter.js"></script>
+    <!-- <script src="./mock_flutter.js"></script> -->
     <script src="./bridge.js"></script>
     <script src="./api.js"></script>
-    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
+    <script src="./js/multiavatar.min.js"></script>
+    <link href="./css/all.min.css" rel="stylesheet">
     <style>
         :root {
             --primary-purple: #593259; 
@@ -40,7 +41,7 @@
         .header-area {
             height: 280px; 
             background: linear-gradient(to bottom, rgba(72, 48, 85, 0.7), rgba(45, 52, 54, 0.95)), 
-                        url('./bg.jpg?w=800') center/cover;
+                        url('./bg.jpg') center/cover;
             padding: 20px;
             padding-top: max(20px, env(safe-area-inset-top));
             color: white;
@@ -137,7 +138,7 @@
         .p-img img { width: 100%; height: 100%; object-fit: cover; }
 
         .crown {
-            position: absolute; top: -38px; color: #f1c40f; font-size: 32px;
+            position: absolute; top: -28px; color: #f1c40f; font-size: 32px;
             filter: drop-shadow(0 2px 4px rgba(0,0,0,0.4));
             animation: crownFloat 2s ease-in-out infinite;
             z-index: 20; 
@@ -185,11 +186,21 @@
 
         /* 底部我的排名 */
         .my-rank-bar {
-            position: fixed; bottom: 0; left: 0; width: 100%; height: 55px; 
+            position: fixed; bottom: 0; left: 0; width: 100%;
+            /* 移除固定高度,改用 padding 撑开,更灵活且紧凑 */
             background: var(--footer-bg); color: white;
-            display: flex; align-items: center; padding: 0 20px;
-            padding-bottom: env(safe-area-inset-bottom);
-            box-sizing: border-box; 
+            display: flex; align-items: center; 
+            padding: 0 20px;
+            
+            /* 上下留出 8px 间距 */
+            padding-top: 8px;
+            /* 底部间距 = 8px + constant(safe-area-inset-bottom) 用于兼容旧版 iOS */
+            padding-bottom: calc(8px + constant(safe-area-inset-bottom));
+            /* 底部间距 = 8px + 安全区高度 */
+            padding-bottom: calc(8px + env(safe-area-inset-bottom));
+            
+            box-sizing: border-box;
+            
             border-radius: 24px 24px 0 0; box-shadow: 0 -5px 20px rgba(0,0,0,0.2); z-index: 99; 
         }
         .my-rank-bar .rank { font-size: 16px; }
@@ -230,13 +241,39 @@
         .rule-item:last-child { margin-bottom: 0; }
         .rule-item i { color: var(--primary-orange); margin-top: 2px; }
 
-        /* 加载遮罩 */
-        .loading-mask {
+        .my-rank-bar {
+            position: fixed; bottom: 0; left: 0; width: 100%;
+            background: var(--footer-bg); color: white;
+            display: flex; align-items: center; 
+            padding: 0 20px;
+            
+            /* Android / 默认: 稍大一点的间距以防贴边 */
+            padding-top: 6px;
+            padding-bottom: 10px;
+            
+            box-sizing: border-box;
+            border-radius: 24px 24px 0 0; box-shadow: 0 -5px 20px rgba(0,0,0,0.2); z-index: 99; 
+        }
+
+        /* iOS 专属优化: 极致压缩高度 */
+        @supports (-webkit-touch-callout: none) {
+            .my-rank-bar {
+                /* 顶部仅留 4px */
+                padding-top: 4px !important;
+                /* 底部严格贴合安全区,不加任何额外间距 */
+                padding-bottom: env(safe-area-inset-bottom) !important; 
+            }
+        }
+
+        .my-rank-bar .rank { font-size: 16px; }
+        /* 稍微缩小底部头像以节省高度 */
+        .my-rank-bar .avatar { width: 30px; height: 30px; border-width: 2px; }
+        .my-rank-bar .name { font-size: 14px; }
             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);
         }
-        .loading-mask.show { display: flex; }
+        .loading-mask.show { display: none !important; }
         .loading-spinner {
             border: 4px solid rgba(255,255,255,0.3);
             border-top-color: #fdcb6e;
@@ -304,7 +341,7 @@
     <!-- 底部我的排名 -->
     <div class="my-rank-bar">
         <div class="rank" id="myRankNum">--</div>
-        <div class="avatar" id="myAvatar" style="border:2px solid #fdcb6e"><img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Me" alt=""></div>
+        <div class="avatar" id="myAvatar" style="border:2px solid #fdcb6e; overflow:hidden;"></div>
         <div class="info"><div class="name" id="myName" style="color:white">我</div><div class="team" id="myTeam" style="color:#b2bec3">正在加载...</div></div>
         <div class="my-score" id="myScoreValue">--</div>
     </div>
@@ -334,7 +371,7 @@
     </div>
 
     <!-- 加载中遮罩 -->
-    <div class="loading-mask" id="loadingMask">
+    <div class="loading-mask" id="loadingMask" style="display: none !important;">
         <div class="loading-spinner"></div>
         <div>数据加载中...</div>
     </div>
@@ -398,9 +435,19 @@
         }
 
         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 = encodeURIComponent(seedBase + (salt || ''));
-            return `<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=${seed}" alt="">`;
+            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) {
@@ -480,14 +527,19 @@
         function renderPodium(list, tabType) {
             const type = tabType || state.activeTab || 'score';
             podiumWrap.innerHTML = '';
-            if (!list || list.length === 0) {
-                podiumWrap.innerHTML = '<div style="color:#fff;">暂无榜单数据</div>';
-                return;
+            
+            // 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;
+                });
             }
-            // Ensure we have at least 3 elements, fill with null if less
-            const p1 = list[0]; // Actual Rank 1
-            const p2 = list[1]; // Actual Rank 2
-            const p3 = list[2]; // Actual Rank 3
+
+            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 = [
@@ -498,21 +550,20 @@
 
             podiumItems.forEach(itemConfig => {
                 const person = itemConfig.person;
-                if (!person) return; // Skip if no person for this position
-
                 const col = document.createElement('div');
                 col.className = 'p-col ' + itemConfig.className;
                 
-                const name = person.nickName || person.name || person.userName || '选手';
-                const score = person.score != null ? person.score : (person.inRankNum != null ? person.inRankNum : '--');
-                const rankNum = person.rankNum; // Use actual rank from item
+                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:#ddd;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#666;font-size:12px;font-weight:bold;">?</div>';
 
-                // Only the actual first place gets the crown
-                const isActualFirst = (person === p1); 
+                // 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">${buildAvatar(name, rankNum)}</div>
+                    <div class="p-img">${avatarContent}</div>
                     <div class="p-box">
                         <div class="p-name">${name}</div>
                         <div class="p-score">${score}</div>
@@ -520,7 +571,9 @@
                 `;
                 podiumWrap.appendChild(col);
             });
-            const remaining = list.slice(3);
+
+            // 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 {
@@ -629,14 +682,21 @@
         }
 
         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';
             const ym = getYearMonth();
-            state.currentYM = ym;
-            state.months = getRecentMonths(3);
+            
+            // 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 });
@@ -650,8 +710,47 @@
                 catch (err) { console.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 {
-                const monthRank = await API.request('MonthRankDetailQuery', { year: ym.year, month: ym.month, dispArrStr: 'grad,mapNum' });
+                // 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([
@@ -664,27 +763,31 @@
                         safeCall(() => API.getCardConfig(ecId, 'rank'))
                     ]);
                 } else {
-                    base = { ecName: `${ym.month}月挑战赛` };
-                    currentMonth = { monthRs: [{ month: ym.month, realNum: 0, targetNum: 4 }] };
+                    base = { ecName: `${targetYM.month}月挑战赛` };
+                    currentMonth = { monthRs: [{ month: targetYM.month, realNum: 0, targetNum: 4 }] };
                     allMonths = [];
                     myRank = null;
                     myScore = null;
                     userInfo = null;
                     cardConfig = null;
                 }
-                const monthGrad = monthRank && monthRank.gradRs ? monthRank.gradRs : [];
-                const monthMap = monthRank && monthRank.mapNumRs ? monthRank.mapNumRs : [];
-                state.scoreList = monthGrad;
-                state.venueList = monthMap;
+
                 const selfRow = monthGrad.find && monthGrad.find(item => item.isSelf === 1);
-                if (!base && monthGrad.length) base = { ecName: `${ym.month}月挑战赛` };
+                if (!base && monthGrad.length) base = { ecName: `${targetYM.month}月挑战赛` };
                 state.currentMonthData = currentMonth || null;
                 state.allMonthsData = allMonths || null;
-                const prog = findMonthProgress(ym.year, ym.month, state.currentMonthData, state.allMonthsData);
-                document.getElementById('currentMonthText').innerText = `${ym.month}月挑战赛`;
+                
+                // 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'));
+                
                 let hasRenderedMyInfo = false;
                 if (allowLogin && myRank) {
                     const rankVal = Number(myRank.rankNum);
@@ -702,11 +805,9 @@
                     renderMyInfo(myRank, myScore, userInfo);
                 }
                 renderRules(cardConfig);
-            } catch (err) {
-                console.error('[Init] 加载失败', err);
-                rankListEl.innerHTML = '<div style="padding:20px; color:#d63031;">数据加载失败,请重试</div>';
             } finally {
                 setLoading(false);
+                loadingMask.classList.remove('show'); // Force remove show class
             }
         }
 

+ 61 - 176
card/sdk/index.html

@@ -2,15 +2,10 @@
 <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>11月挑战赛 - 智能缩放版</title>
-    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+    <title>月度排行榜</title>
+    <link href="./css/all.min.css" rel="stylesheet">
     <style>
-        :root {
-            --primary-purple: #593259;
-            --primary-orange: #ffeaa7;
-        }
-
         * {
             box-sizing: border-box;
             margin: 0;
@@ -21,228 +16,118 @@
         }
 
         html, body {
-            margin: 0;
-            padding: 0;
-            width: 100vw;
-            height: 100vh;
+            width: 100%;
+            height: 100%;
             overflow: hidden;
-            background-color: transparent !important;
-            background: rgba(0, 0, 0, 0) !important;
-        }
-
-        body {
+            background: transparent;
             display: flex;
             justify-content: center;
             align-items: center;
         }
 
-        /* 2. 外层容器 */
+        /* 容器:100% 充满嵌入框 */
         .card-container {
-            width: 96%;
-            height: 96%;
-            /* 圆角也需要自适应:最大24px,或者视口最小边的6% */
-            border-radius: min(24px, 6vmin);
+            width: 100%;
+            height: 100%;
+            border-radius: 20px;
             overflow: hidden;
             position: relative;
             cursor: pointer;
-            
-            background: linear-gradient(to bottom, 
-                            rgba(162, 155, 254, 0.2) 0%, 
-                            rgba(45, 52, 54, 0.95) 100%), 
-                        url('./bg.jpg?w=800') center/cover no-repeat;
-            
-            box-shadow: 0 15px 40px rgba(0, 0, 0, 0.4);
-            
-            transform: translateZ(0);
-            -webkit-mask-image: -webkit-radial-gradient(white, black);
+            /* 背景图 */
+            background: url('./card.png') center/cover no-repeat;
         }
 
-        /* 3. 内层毛玻璃 */
-        .glass-layer {
+        .card-container:active {
+            transform: scale(0.98);
+            transition: transform 0.1s;
+        }
+
+        .content-layer {
             position: absolute;
-            top: 4%; left: 4%; right: 4%; bottom: 4%;
-            
-            /* 圆角自适应 */
-            border-radius: min(20px, 5vmin);
-            
-            border: 1.5px solid rgba(255, 255, 255, 0.3);
-            border-bottom: 1.5px solid rgba(255, 255, 255, 0.1);
-            
-            background: linear-gradient(to bottom, 
-                rgba(255, 255, 255, 0.15) 0%, 
-                rgba(255, 255, 255, 0.02) 50%, 
-                rgba(0, 0, 0, 0.1) 100%
-            );
-            backdrop-filter: blur(6px);
-            -webkit-backdrop-filter: blur(6px);
-            
+            top: 0; left: 0; width: 100%; height: 100%;
             display: flex;
             flex-direction: column;
             align-items: center;
-            justify-content: flex-start;
-            text-align: center;
-            
-            box-shadow: inset 0 0 15px rgba(255,255,255,0.05);
-        }
-
-        .card-container:active {
-            transform: scale(0.98);
-            transition: transform 0.1s;
+            z-index: 2;
         }
 
-        /* 顶部年份 */
-        .tag-year {
-            /* 字体大小:最大20px,缩小时占视口最小边的5.5% */
-            font-size: min(30px, 7.5vmin); 
-            font-weight: 400;
-            letter-spacing: 1px;
-            color: #fff;
-            background: rgba(0, 0, 0, 0.2);
+        /* 1. 年份标签 - 修改:去背景、放大字体、单位改 vmin */
+        .year-tag {
+            /* 距离顶部约 14% */
+            margin-top: 13%; 
             
-            /* Padding 使用 em 单位,随字体大小缩放 */
-            padding: 0.2em 0.8em;
+            /* vmin 单位:确保随容器大小自动缩放 */
+            font-size: 8vmin; /* 字体加大 */
+            font-weight: 800;
+            color: #fff;
+            letter-spacing: 2px;
             
-            border-radius: 16px;
-            border: 1px solid rgba(255,255,255,0.2);
-            position: absolute;
+            /* 去掉背景和边框 */
+            background: none;
+            border: none;
+            padding: 0;
             
-            /* 距离顶部的距离也自适应 */
-            top: min(10px, 2vh); 
-            z-index: 10;
+            /* 增加投影以保证清晰度 */
+            text-shadow: 0 2px 4px rgba(0,0,0,0.5);
         }
 
-        /* 核心数字:11 */
-        .title-month {
-            /* 字体大小:最大80px,缩小时占视口最小边的22% */
-            font-size: min(140px, 32vmin); 
+        /* 2. 月份数字 - 修改:单位改 vmin */
+        .month-num {
+            /* 字体极大,随容器缩放 */
+            font-size: 32vmin; 
             font-weight: 900;
             line-height: 1;
+            margin-top: 9%;
             
-            background: linear-gradient(to bottom, #ffffff 10%, #ffd700 50%, #ff9f43 100%);
+            /* 金色渐变字 */
+            background: linear-gradient(to bottom, #ffffff 20%, #ffd700 60%, #ff9f43 100%);
             -webkit-background-clip: text;
             -webkit-text-fill-color: transparent;
             
-            filter: drop-shadow(0 4px 0px rgba(0, 0, 0, 0.2));
-            
-            /* 顶部间距:最大55px,缩小时占视口高度的12% */
-            margin-top: min(75px, 16vh); 
-            z-index: 2;
+            filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
         }
 
-        /* 月度挑战赛 */
-        .title-sub {
-            /* 字体大小:最大16px,缩小时占视口最小边的4.5% */
-            font-size: min(20px, 6.5vmin); 
-            font-weight: 800;
-            color: rgba(255, 255, 255, 0.95);
-            letter-spacing: 2px;
-            text-transform: uppercase;
-            text-shadow: 0 1px 2px rgba(0,0,0,0.5);
+        /* 3. 奖杯动画图标 - 修改:大幅下移 */
+        .trophy-icon {
+            /* 修改:往下移动 (从原来的 4% 增加到 15%) */
+            margin-top: 13%; 
             
-            margin-top: min(5px, 1vh); 
-            margin-bottom: auto; 
-        }
-
-        /* 奖杯图标 */
-        .deco-icon {
-            /* 底部间距:最大22px,缩小时占视口高度的4% */
-            margin-bottom: min(22px, 8vh);
+            /* 图标大小也随容器缩放 */
+            width: 15vmin; 
+            height: 15vmin;
             
-            /* 宽高:最大40px,缩小时占视口最小边的11% */
-            width: min(40px, 11vmin); 
-            height: min(40px, 11vmin);
-            
-            border-radius: 50%;
             display: flex; justify-content: center; align-items: center;
-            background: rgba(255,255,255,0.1);
-            border: 1px solid rgba(255,255,255,0.2);
-            box-shadow: 0 5px 15px rgba(0,0,0,0.3);
-            
             animation: trophy-pulse 2s infinite ease-in-out;
         }
 
-        .deco-icon i {
-            /* 图标大小:最大20px,随容器缩小 */
-            font-size: min(20px, 5.5vmin);
+        .trophy-icon i {
+            /* 图标字体大小随容器缩放 */
+            font-size: 10vmin; 
             color: #ffd700; 
             filter: drop-shadow(0 0 5px rgba(253, 203, 110, 0.8));
         }
 
-        /* 查看排行按钮 */
-        .action-btn {
-            /* 底部间距:最大20px,缩小时自适应 */
-            margin-bottom: min(30px, 6vh);
-            
-            /* 宽度:最大110px,缩小时占视口宽度的30% */
-            width: min(180px, 50vmin); 
-            
-            padding: min(8px, 2vh) 0;
-            border-radius: 25px;
-            border: none;
-            
-            background: linear-gradient(90deg, #ffeaa7 0%, #fab1a0 100%);
-            color: #593259;
-            
-            /* 字体:最大12px */
-            font-size: min(18px, 5.5vmin);
-            font-weight: 900;
-            
-            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
-            
-            display: flex;
-            align-items: center;
-            justify-content: center;
-            gap: 4px;
-            z-index: 10;
-        }
-        
-        .action-btn i { font-size: 0.8em; }
-
         @keyframes trophy-pulse {
-            0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(253, 203, 110, 0.2); }
-            50% { transform: scale(1.15); box-shadow: 0 0 15px 5px rgba(253, 203, 110, 0.4); }
-            100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(253, 203, 110, 0); }
-        }
-
-        /* 顶部反光 */
-        .shine {
-            position: absolute;
-            top: -20px; left: -20px;
-            width: 150px; height: 100px;
-            background: radial-gradient(circle, rgba(255,255,255,0.3) 0%, transparent 70%);
-            z-index: 3;
-            pointer-events: none;
-            filter: blur(20px);
+            0% { transform: scale(1); filter: drop-shadow(0 0 0 rgba(253, 203, 110, 0)); }
+            50% { transform: scale(1.2); filter: drop-shadow(0 0 8px rgba(253, 203, 110, 0.6)); }
+            100% { transform: scale(1); filter: drop-shadow(0 0 0 rgba(253, 203, 110, 0)); }
         }
 
     </style>
 </head>
 <body>
 
-    <!-- 外层容器 -->
     <div class="card-container" onclick="redirectToDetail()">
-        
-        <!-- 内层容器 -->
-        <div class="glass-layer">
-            <div class="shine"></div>
-            
-            <div class="tag-year">2025 年</div>
-            
-            <div class="title-month">11</div>
-            <div class="title-sub">月度挑战赛</div>
-            
-            <div class="deco-icon">
+        <div class="content-layer">
+            <div class="year-tag">2025年</div>
+            <div class="month-num">12</div>
+            <div class="trophy-icon">
                 <i class="fa-solid fa-trophy"></i>
             </div>
-
-            <button class="action-btn" onclick="redirectToDetail()">
-                查看排行 <i class="fa-solid fa-arrow-right"></i>
-            </button>
         </div>
-        
     </div>
 
-    <script src="./bridge.js"></script>
+      <script src="./bridge.js"></script>
     <script>
         function getQueryParam(name) {
             const params = new URLSearchParams(window.location.search);

文件差异内容过多而无法显示
+ 0 - 0
card/sdk/js/multiavatar.min.js


+ 29 - 0
card/sdk/old/AGENTS.md

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

+ 148 - 0
card/sdk/old/API.md

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

+ 425 - 0
card/sdk/old/API_SERVER.md

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

+ 55 - 0
card/sdk/old/GUIDE.md

@@ -0,0 +1,55 @@
+# 第三方 H5 对接指南
+
+## 1. 目录结构说明
+
+交付包包含以下核心文件:
+
+*   `bridge.js`: **核心通信库**,封装了与 App 的交互,必须在所有页面引入。
+*   `api.js`: **后端 API 库**,封装了所有服务器请求接口。
+*   `mock_flutter.js`: **调试工具**,仅在本地开发使用。
+*   `demo_project/`: **完整示例项目**,包含首页、排行榜、报名等真实场景代码。
+*   `API.md`: 详细的 App 交互接口文档 (Bridge)。
+*   `API_SERVER.md`: 后端服务器接口文档 (API)。
+
+## 2. 开发流程
+
+### 第一步:参考 Demo
+建议直接打开 `demo_project/index.html` (本地双击即可运行),体验完整的交互流程。您可以参考其中的 `rank.html` 和 `signup.html` 来构建您的业务页面。
+
+### 第二步:初始化 SDK
+在您的 H5 项目(无论是 Vue, React 还是原生 HTML)的入口 HTML 文件中引入核心库:
+
+```html
+<script src="./bridge.js"></script>
+<script src="./api.js"></script>
+```
+
+### 第三步:开启调试模式 (Mock)
+为了方便在 Chrome/Edge 浏览器中开发,请在 `bridge.js` 之前引入 `mock_flutter.js`。
+此外,在初始化 API 时,如果是本地环境,建议开启 Mock 模式:
+
+```javascript
+// 检测是否本地开发环境
+var isLocal = window.location.protocol === 'file:' || window.location.hostname === 'localhost';
+
+API.init({
+    token: '...', 
+    useMock: isLocal // 本地开发自动开启 Mock,直接返回模拟数据
+});
+```
+
+### 第四步:打包交付
+**关键步骤**:
+在构建生产环境包(Build)之前,请**移除**对 `mock_flutter.js` 的引用。
+App 环境会自动注入真实的通信通道。
+
+## 3. 交付物清单
+
+请向我们提供:
+1.  构建后的静态资源包 (dist 目录)。
+2.  部署说明(如果需要特殊的 Nginx 配置)。
+
+## 4. 注意事项
+
+*   **OSS 图片**: 后端接口返回的图片路径通常不带域名,请使用 `API.getOssUrl()` 获取基础域名并进行拼接。
+*   **Token**: 在 App 环境中,H5 页面加载时 URL 参数中通常会携带 `token`。如果 URL 中没有,请使用 `Bridge.getToken()` 获取。

+ 323 - 0
card/sdk/old/api.js

@@ -0,0 +1,323 @@
+(function (window) {
+    'use strict';
+
+    /**
+     * 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,
+            coiName: '个人组'
+        },
+        '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",
+                popupRuleConfig: { height: "60%", theme: "light" },
+                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' } } }
+                ]
+            })
+        },
+        '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' }
+            ]
+        }
+    };
+
+    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) console.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) {
+                    console.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;
+                        
+                        console.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]); }
+
+            console.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) {
+                console.log('[API] Response:', endpoint, res);
+                if (res.code === 0) return res.data;
+                if (res.code === 401 || res.statusCode === 401) {
+                    console.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) { console.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 }); }
+    };
+
+    window.API = API;
+
+})(window);

二进制
card/sdk/old/bg.jpg


+ 326 - 0
card/sdk/old/bridge.js

@@ -0,0 +1,326 @@
+(function (window) {
+  'use strict';
+
+  /**
+   * 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 || {};
+      console.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 {
+             console.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 {
+               console.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 {
+               console.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:
+          console.warn('[Bridge] No legacy fallback for action:', action);
+      }
+
+      if (url) {
+        console.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) {
+        console.warn('[Bridge] urlAddVer error:', e);
+      }
+      console.log("[Bridge] urlAddVer newUrl:", newUrl);
+      return newUrl;
+    },
+
+    /**
+     * 导航到APP内的某个页面或执行APP内部的某些功能
+     */
+    appAction: function(url, actType) {
+      actType = actType || "";
+      console.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) {
+        console.log('[Bridge] Received token:', token);
+        if (this._tokenCallback) {
+            this._tokenCallback(token);
+        }
+    }
+  };
+
+  window.Bridge = Bridge;
+
+})(window);

+ 219 - 0
card/sdk/old/demo.html

@@ -0,0 +1,219 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>ColorMapRun SDK 全功能测试台</title>
+    <style>
+        body { font-family: sans-serif; padding: 20px; background: #f0f2f5; display: flex; gap: 20px; }
+        .sidebar { width: 300px; flex-shrink: 0; display: flex; flex-direction: column; gap: 10px; height: 90vh; overflow-y: auto; padding-right: 10px; }
+        .main { flex: 1; display: flex; flex-direction: column; height: 90vh; }
+        
+        h2, h3 { margin-top: 0; color: #333; }
+        h3 { font-size: 16px; margin-bottom: 10px; padding-bottom: 5px; border-bottom: 2px solid #007aff; display: inline-block; }
+        
+        .group { margin-bottom: 20px; background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.05); }
+        .group-title { font-weight: bold; margin-bottom: 10px; color: #555; }
+        
+        .btn-grid { display: grid; grid-template-columns: 1fr; gap: 8px; }
+        button { padding: 8px 12px; font-size: 13px; border: 1px solid #ddd; background: #fff; border-radius: 4px; cursor: pointer; text-align: left; transition: all 0.2s; }
+        button:hover { background: #f0f8ff; border-color: #007aff; color: #007aff; }
+        button.active { background: #007aff; color: white; border-color: #007aff; }
+        
+        .log-area { flex: 1; background: #1e1e1e; color: #0f0; padding: 15px; border-radius: 8px; font-family: 'Consolas', monospace; font-size: 13px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; }
+        .log-entry { margin-bottom: 10px; border-bottom: 1px dashed #444; padding-bottom: 5px; }
+        .log-time { color: #888; font-size: 11px; }
+        .log-label { color: #fff; font-weight: bold; }
+        .log-error { color: #ff4d4f; }
+    </style>
+
+    <!-- SDK -->
+    <script src="./mock_flutter.js"></script> <!-- 确保 mock_flutter.js 在 bridge.js 和 api.js 之前引入,以便正确模拟全局对象 -->
+    <script src="./bridge.js"></script>
+    <script src="./api.js"></script>
+</head>
+<body>
+
+    <div class="sidebar">
+        <h2>SDK 测试台</h2>
+        <div style="font-size: 12px; color: #666; margin-bottom: 10px;">当前环境: <span id="envInfo">检测中...</span></div>
+        <button onclick="clearLog()" style="text-align: center; margin-bottom: 10px;">🗑️ 清空日志</button>
+
+        <!-- App Bridge -->
+        <div class="group">
+            <div class="group-title">📱 App 交互 (Bridge)</div>
+            <div class="btn-grid">
+                <button onclick="run('Bridge.openMap(latitude, longitude, name)', Bridge.openMap, 39.9, 116.4, '北京')">打开地图</button>
+                <button onclick="run('Bridge.openMatch(id, type)', Bridge.openMatch, MOCK_MCID, 1)">打开赛事详情</button>
+                <button onclick="run('Bridge.openActivityList(id, mapName)', Bridge.openActivityList, 202, '奥体')">打开活动列表</button>
+                <button onclick="run('Bridge.shareWx({title, url, image, scene})', Bridge.shareWx, {title:'Demo分享', url:location.href, image:'https://picsum.photos/100/100'})">微信分享</button>
+                <button onclick="run('Bridge.launchWxMini(username, path)', Bridge.launchWxMini, 'gh_bea09156da8d', 'pages/index/index')">打开小程序</button>
+                <button onclick="run('Bridge.saveImage(base64Str)', Bridge.saveImage, 'data:image/png;base64,iVBORw0KGgoAAA...')">保存图片</button>
+                <button onclick="run('Bridge.back()', Bridge.back)">返回上一页</button>
+                <button onclick="run('Bridge.toHome()', Bridge.toHome)">返回App首页</button>
+                <button onclick="run('Bridge.toLogin()', Bridge.toLogin)">跳转登录</button>
+                <button onclick="run('Bridge.setTitle(title)', Bridge.setTitle, '新标题')">设置标题</button>
+                <button onclick="getToken()">获取 Token (Bridge)</button>
+            </div>
+        </div>
+
+        <!-- API: Config -->
+        <div class="group">
+            <div class="group-title">1. 配置与基础</div>
+            <div class="btn-grid">
+                <button onclick="api('API.getCardBase(ecId, pageName)', API.getCardBase, MOCK_ECID, 'index')">getCardBase</button>
+                <button onclick="api('API.getCardConfig(ecId, pageName)', API.getCardConfig, MOCK_ECID, 'index')">getCardConfig</button>
+                <button onclick="api('API.getUserConfig(ecId, pageName)', API.getUserConfig, MOCK_ECID, 'index')">getUserConfig</button>
+            </div>
+        </div>
+
+        <!-- API: Activity -->
+        <div class="group">
+            <div class="group-title">2. 活动与报名</div>
+            <div class="btn-grid">
+                <button onclick="api('API.getCardDetail(ecId)', API.getCardDetail, MOCK_ECID)">getCardDetail</button>
+                <button onclick="api('API.getMatchRsDetail(ecId)', API.getMatchRsDetail, MOCK_ECID)">getMatchRsDetail</button>
+                <button onclick="api('API.getUserJoinStatus(ecId)', API.getUserJoinStatus, MOCK_ECID)">getUserJoinStatus</button>
+                <button onclick="api('API.isNewUserInCardComp(ecId)', API.isNewUserInCardComp, MOCK_ECID)">isNewUserInCardComp</button>
+                <button onclick="api('API.getOnlineMcSignUpDetail(ecId)', API.getOnlineMcSignUpDetail, MOCK_ECID)">getOnlineMcSignUpDetail</button>
+                <button onclick="api('API.signUpOnline(mcId, coiId, selectTeam, nickName)', API.signUpOnline, MOCK_MCID, 0, 0, '测试用户')">signUpOnline (报名)</button>
+                <button onclick="api('API.isAllowMcSignUp(ecId)', API.isAllowMcSignUp, MOCK_ECID)">isAllowMcSignUp</button>
+            </div>
+        </div>
+
+        <!-- API: Rank -->
+        <div class="group">
+            <div class="group-title">3. 排名与成就</div>
+            <div class="btn-grid">
+                <button onclick="api('API.getRankDetail(mcIdListStr, mcType, dispArrStr)', API.getRankDetail, String(MOCK_MCID), 1, 'total')">getRankDetail</button>
+                <button onclick="api('API.getUserCurrentRank(ecId)', API.getUserCurrentRank, MOCK_ECID)">getUserCurrentRank</button>
+                <button onclick="api('API.getCurrentMonthlyChallenge()', API.getCurrentMonthlyChallenge)">getCurrentMonthlyChallenge</button>
+                <button onclick="api('API.getMonthlyChallenge()', API.getMonthlyChallenge)">getMonthlyChallenge</button>
+                <button onclick="api('API.getMonthRankDetail()', API.getMonthRankDetail)">getMonthRankDetail</button>
+                <button onclick="api('API.getAchievement()', API.getAchievement)">getAchievement</button>
+                <button onclick="api('API.getCompStatistic(ecId)', API.getCompStatistic, MOCK_ECID)">getCompStatistic</button>
+            </div>
+        </div>
+
+        <!-- API: Exchange -->
+        <div class="group">
+            <div class="group-title">4. 积分与兑换</div>
+            <div class="btn-grid">
+                <button onclick="api('API.getScore(ecId)', API.getScore, MOCK_ECID)">getScore</button>
+                <button onclick="api('API.getGoodsList(ecId)', API.getGoodsList, MOCK_ECID)">getGoodsList</button>
+                <button onclick="api('API.getGoodsDetail(goodsId)', API.getGoodsDetail, 1)">getGoodsDetail</button>
+                <button onclick="api('API.exchangeGoods(ecId, goodsId)', API.exchangeGoods, MOCK_ECID, 1)">exchangeGoods</button>
+                <button onclick="api('API.getExchangeList(ecId)', API.getExchangeList, MOCK_ECID)">getExchangeList</button>
+                <button onclick="api('API.getExchangeDetail(ecId, exchangeId)', API.getExchangeDetail, MOCK_ECID, 1)">getExchangeDetail</button>
+            </div>
+        </div>
+
+        <!-- API: Message -->
+        <div class="group">
+            <div class="group-title">5. 消息与通知</div>
+            <div class="btn-grid">
+                <button onclick="api('API.getUnReadMessages(relationId, relationType)', API.getUnReadMessages, MOCK_ECID, 2)">getUnReadMessages</button>
+                <button onclick="api('API.readMessage(mqIdListStr)', API.readMessage, '1,2,3')">readMessage</button>
+                <button onclick="api('API.getWarnMessage(ecId)', API.getWarnMessage, MOCK_ECID)">getWarnMessage</button>
+            </div>
+        </div>
+
+        <!-- API: Cert -->
+        <div class="group">
+            <div class="group-title">6. 电子证书</div>
+            <div class="btn-grid">
+                <button onclick="api('API.getCertStyle(ecId)', API.getCertStyle, MOCK_ECID)">getCertStyle</button>
+                <button onclick="api('API.getUserBaseInCertificate(ecId)', API.getUserBaseInCertificate, MOCK_ECID)">getUserBaseInCertificate</button>
+                <button onclick="api('API.createCertificate({ecId, certStyleId, ...})', API.createCertificate, {ecId:MOCK_ECID, certStyleId:1, userName:'测试用户'})">createCertificate</button>
+            </div>
+        </div>
+
+        <!-- API: Other -->
+        <div class="group">
+            <div class="group-title">7. 其他</div>
+            <div class="btn-grid">
+                <button onclick="api('API.getUserInfo()', API.getUserInfo)">getUserInfo</button>
+                <button onclick="api('API.getMapList(ecId)', API.getMapList, MOCK_ECID)">getMapList</button>
+                <button onclick="api('API.getGrids(ecId)', API.getGrids, MOCK_ECID)">getGrids</button>
+            </div>
+        </div>
+    </div>
+
+    <div class="main">
+        <h3>执行日志</h3>
+        <div class="log-area" id="log">
+            <div class="log-entry" style="color: #aaa;">等待操作...</div>
+        </div>
+    </div>
+
+    <script>
+        var TOKEN = '96ba3c924394934f7d30fa869a94ce0d'; 
+        var MOCK_ECID = 1; // 模拟卡片/活动ID (ecId)
+        var MOCK_MCID = 101; // 模拟赛事ID (mcId)
+
+        var isLocal = window.location.protocol === 'file:' || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
+        
+        document.getElementById('envInfo').innerText = isLocal ? '本地开发 (Mock ON)' : '生产环境';
+
+        // 初始化 API SDK
+        API.init({
+            token: TOKEN,
+            useMock: isLocal
+        });
+
+        // ===========================================
+        // 日志功能
+        // ===========================================
+        function log(label, data, isError = false) {
+            var el = document.getElementById('log');
+            var div = document.createElement('div');
+            div.className = 'log-entry';
+            var time = new Date().toLocaleTimeString();
+            var content = typeof data === 'object' ? JSON.stringify(data, null, 2) : data;
+            div.innerHTML = `<div class="log-time">[${time}]</div><div class="log-label ${isError?'log-error':''}">${label}</div><div>${content}</div>`;
+            el.prepend(div);
+        }
+
+        function clearLog() {
+            document.getElementById('log').innerHTML = '';
+        }
+
+        // ===========================================
+        // Bridge 调用通用函数
+        // fnDesc: Bridge 方法的描述字符串,如 'Bridge.openMap(latitude, longitude, name)'
+        // fn: 实际要调用的 Bridge 方法
+        // ...args: 实际参数
+        // ===========================================
+        function run(fnDesc, fn, ...args) {
+            log('调用 ' + fnDesc, '参数: ' + JSON.stringify(args));
+            fn.apply(Bridge, args);
+        }
+
+        function getToken() {
+            log('调用 Bridge.getToken', '等待 App 回调 Token...');
+            Bridge.onToken(function(t) {
+                log('✅ Bridge 回调成功', 'Token: ' + t);
+            });
+            Bridge.getToken();
+        }
+
+        // ===========================================
+        // API 调用通用函数
+        // fnDesc: API 方法的描述字符串,如 'API.getCardDetail(ecId)'
+        // fn: 实际要调用的 API 方法
+        // ...args: 实际参数
+        // ===========================================
+        function api(fnDesc, fn, ...args) {
+            log('请求 ' + fnDesc, '实际参数: ' + JSON.stringify(args));
+            
+            fn.apply(API, args)
+                .then(function(res) {
+                    log('✅ 成功', res);
+                })
+                .catch(function(err) {
+                    log('❌ 失败', err.message || err, true);
+                });
+        }
+    </script>
+</body>
+</html>

+ 1 - 1
card/sdk/old/detail.html

@@ -384,7 +384,7 @@
         }
 
         function handleBack() {
-            if (window.Bridge && Bridge.back) Bridge.toHome();
+            if (window.Bridge && Bridge.toHome) Bridge.toHome();
             else window.history.back(); // Fallback for web environment
         }
 

+ 135 - 119
card/sdk/old/index.html

@@ -3,7 +3,7 @@
 <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>11月挑战赛 - 最终封面</title>
+    <title>11月挑战赛 - 智能缩放版</title>
     <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
     <style>
         :root {
@@ -11,11 +11,6 @@
             --primary-orange: #ffeaa7;
         }
 
-        html, body { /* Ensure html also covers full height */
-            height: 100%;
-            width: 100%;
-        }
-
         * {
             box-sizing: border-box;
             margin: 0;
@@ -25,204 +20,228 @@
             user-select: none;
         }
 
-        /* 1. 背景层:全屏铺满 */
         html, body {
             margin: 0;
             padding: 0;
-            height: 100%;
-            width: 100%;
-            /* 确保背景透明,显示 WebView 底色 */
-            background: transparent !important;
+            width: 100vw;
+            height: 100vh;
+            overflow: hidden;
+            background-color: transparent !important;
+            background: rgba(0, 0, 0, 0) !important;
         }
 
         body {
             display: flex;
             justify-content: center;
             align-items: center;
-            overflow: hidden; /* 禁止滚动 */
         }
 
-        /* 2. 毛玻璃卡片层:作为页面主体内容,处理圆角和背景 */
-        .glass-card {
-            width: 100%;
-            height: 100%;
-            
-            /* 核心:在这里设置圆角 */
-            border-radius: 40px; 
-            /* 核心:裁剪溢出内容,确保圆角生效 */
+        /* 2. 外层容器 */
+        .card-container {
+            width: 96%;
+            height: 96%;
+            /* 圆角也需要自适应:最大24px,或者视口最小边的6% */
+            border-radius: min(24px, 6vmin);
             overflow: hidden;
+            position: relative;
+            cursor: pointer;
             
-            /* 背景样式 */
-            background: linear-gradient(to bottom, rgba(162, 155, 254, 0.2) 0%, rgba(45, 52, 54, 0.95) 100%), 
-                        url('https://img.freepik.com/free-vector/silhouette-trail-runner-running-forest-at-night_105940-705.jpg?w=800') center/cover no-repeat;
-            background-color: #2d3436;
+            background: linear-gradient(to bottom, 
+                            rgba(162, 155, 254, 0.2) 0%, 
+                            rgba(45, 52, 54, 0.95) 100%), 
+                        url('./bg.jpg?w=800') center/cover no-repeat;
             
-            /* 边框和阴影 */
-            border: 2px solid rgba(255, 255, 255, 0.25);
-            border-bottom: 2px solid rgba(255, 255, 255, 0.1);
-            box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
+            box-shadow: 0 15px 40px rgba(0, 0, 0, 0.4);
+            
+            transform: translateZ(0);
+            -webkit-mask-image: -webkit-radial-gradient(white, black);
+        }
+
+        /* 3. 内层毛玻璃 */
+        .glass-layer {
+            position: absolute;
+            top: 4%; left: 4%; right: 4%; bottom: 4%;
+            
+            /* 圆角自适应 */
+            border-radius: min(20px, 5vmin);
+            
+            border: 1.5px solid rgba(255, 255, 255, 0.3);
+            border-bottom: 1.5px solid rgba(255, 255, 255, 0.1);
+            
+            background: linear-gradient(to bottom, 
+                rgba(255, 255, 255, 0.15) 0%, 
+                rgba(255, 255, 255, 0.02) 50%, 
+                rgba(0, 0, 0, 0.1) 100%
+            );
+            backdrop-filter: blur(6px);
+            -webkit-backdrop-filter: blur(6px);
             
-            position: relative;
             display: flex;
             flex-direction: column;
             align-items: center;
-            justify-content: center;
+            justify-content: flex-start;
             text-align: center;
-            cursor: pointer;
             
-            backdrop-filter: blur(10px);
-            -webkit-backdrop-filter: blur(10px);
+            box-shadow: inset 0 0 15px rgba(255,255,255,0.05);
         }
 
-        .glass-card:active {
-            transform: scale(0.99);
+        .card-container:active {
+            transform: scale(0.98);
             transition: transform 0.1s;
         }
 
         /* 顶部年份 */
         .tag-year {
-            font-size: 4vmin;
-            font-weight: 800;
-            letter-spacing: 0.5vmin;
+            /* 字体大小:最大20px,缩小时占视口最小边的5.5% */
+            font-size: min(30px, 7.5vmin); 
+            font-weight: 400;
+            letter-spacing: 1px;
             color: #fff;
-            margin-bottom: 2vh;
-            background: rgba(255, 255, 255, 0.2);
-            padding: 1.5vmin 4vmin;
-            border-radius: 6vmin;
-            text-shadow: 0 1px 2px rgba(0,0,0,0.3);
-            border: 1px solid rgba(255,255,255,0.3);
-            box-shadow: 0 4px 15px rgba(0,0,0,0.2);
+            background: rgba(0, 0, 0, 0.2);
+            
+            /* Padding 使用 em 单位,随字体大小缩放 */
+            padding: 0.2em 0.8em;
+            
+            border-radius: 16px;
+            border: 1px solid rgba(255,255,255,0.2);
+            position: absolute;
+            
+            /* 距离顶部的距离也自适应 */
+            top: min(10px, 2vh); 
+            z-index: 10;
         }
 
-        /* 月份数字 - 超大号金色 */
+        /* 核心数字:11 */
         .title-month {
-            font-size: 35vmin;
+            /* 字体大小:最大80px,缩小时占视口最小边的22% */
+            font-size: min(140px, 32vmin); 
             font-weight: 900;
             line-height: 1;
             
-            /* 金色金属渐变 */
-            background: linear-gradient(to bottom, 
-                #ffffff 10%, 
-                #ffd700 50%, 
-                #ff9f43 100%
-            );
+            background: linear-gradient(to bottom, #ffffff 10%, #ffd700 50%, #ff9f43 100%);
             -webkit-background-clip: text;
             -webkit-text-fill-color: transparent;
             
-            filter: drop-shadow(0 4px 0px rgba(255, 215, 0, 0.3));
+            filter: drop-shadow(0 4px 0px rgba(0, 0, 0, 0.2));
             
-            margin: 1vh 0;
+            /* 顶部间距:最大55px,缩小时占视口高度的12% */
+            margin-top: min(75px, 16vh); 
             z-index: 2;
         }
 
+        /* 月度挑战赛 */
         .title-sub {
-            font-size: 4.5vmin;
+            /* 字体大小:最大16px,缩小时占视口最小边的4.5% */
+            font-size: min(20px, 6.5vmin); 
             font-weight: 800;
             color: rgba(255, 255, 255, 0.95);
-            letter-spacing: 1vmin;
+            letter-spacing: 2px;
             text-transform: uppercase;
-            margin-top: -1vh;
-            text-shadow: 0 2px 4px rgba(0,0,0,0.6);
+            text-shadow: 0 1px 2px rgba(0,0,0,0.5);
+            
+            margin-top: min(5px, 1vh); 
+            margin-bottom: auto; 
         }
 
-        /* 底部图标容器 */
+        /* 奖杯图标 */
         .deco-icon {
-            margin-top: 5vh;
-            width: 18vmin; height: 18vmin;
+            /* 底部间距:最大22px,缩小时占视口高度的4% */
+            margin-bottom: min(22px, 8vh);
+            
+            /* 宽高:最大40px,缩小时占视口最小边的11% */
+            width: min(40px, 11vmin); 
+            height: min(40px, 11vmin);
+            
             border-radius: 50%;
             display: flex; justify-content: center; align-items: center;
             background: rgba(255,255,255,0.1);
             border: 1px solid rgba(255,255,255,0.2);
-            box-shadow: 0 10px 30px rgba(0,0,0,0.3);
+            box-shadow: 0 5px 15px rgba(0,0,0,0.3);
+            
             animation: trophy-pulse 2s infinite ease-in-out;
         }
 
         .deco-icon i {
-            font-size: 9vmin;
+            /* 图标大小:最大20px,随容器缩小 */
+            font-size: min(20px, 5.5vmin);
             color: #ffd700; 
             filter: drop-shadow(0 0 5px rgba(253, 203, 110, 0.8));
         }
 
-        /* ================= 新增:查看排行按钮 ================= */
+        /* 查看排行按钮 */
         .action-btn {
-            position: absolute;
-            bottom: 8%; /* 距离底部的位置 */
+            /* 底部间距:最大20px,缩小时自适应 */
+            margin-bottom: min(30px, 6vh);
             
-            /* 按钮尺寸与样式 */
-            padding: 3vmin 8vmin;
-            border-radius: 10vmin;
-            border: none;
+            /* 宽度:最大110px,缩小时占视口宽度的30% */
+            width: min(180px, 50vmin); 
             
-            /* 渐变背景:浅金 -> 珊瑚色 */
-            background: linear-gradient(135deg, #ffeaa7 0%, #fab1a0 100%);
+            padding: min(8px, 2vh) 0;
+            border-radius: 25px;
+            border: none;
             
-            /* 文字样式:深紫色,高对比度 */
+            background: linear-gradient(90deg, #ffeaa7 0%, #fab1a0 100%);
             color: #593259;
-            font-size: 4vmin;
+            
+            /* 字体:最大12px */
+            font-size: min(18px, 5.5vmin);
             font-weight: 900;
-            letter-spacing: 1px;
             
-            /* 阴影与光泽 */
-            box-shadow: 0 10px 25px rgba(255, 118, 117, 0.4), 
-                        inset 0 1px 0 rgba(255,255,255,0.4);
+            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
             
             display: flex;
             align-items: center;
-            gap: 2vmin;
-            
+            justify-content: center;
+            gap: 4px;
             z-index: 10;
-            animation: btn-float 3s infinite ease-in-out;
-        }
-
-        /* 按钮悬浮动画 */
-        @keyframes btn-float {
-            0%, 100% { transform: translateY(0); }
-            50% { transform: translateY(-5px); }
         }
+        
+        .action-btn i { font-size: 0.8em; }
 
         @keyframes trophy-pulse {
-            0% { transform: scale(1) translateY(0); box-shadow: 0 0 0 0 rgba(253, 203, 110, 0.2); }
-            50% { transform: scale(1.1) translateY(-5px); box-shadow: 0 0 30px 10px rgba(253, 203, 110, 0.4); }
-            100% { transform: scale(1) translateY(0); box-shadow: 0 0 0 0 rgba(253, 203, 110, 0); }
+            0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(253, 203, 110, 0.2); }
+            50% { transform: scale(1.15); box-shadow: 0 0 15px 5px rgba(253, 203, 110, 0.4); }
+            100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(253, 203, 110, 0); }
         }
 
-        /* 顶部反光效果 */
+        /* 顶部反光 */
         .shine {
             position: absolute;
-            top: -15%; left: -15%;
-            width: 60%; height: 40%;
-            background: radial-gradient(circle, rgba(255,255,255,0.4) 0%, transparent 70%);
+            top: -20px; left: -20px;
+            width: 150px; height: 100px;
+            background: radial-gradient(circle, rgba(255,255,255,0.3) 0%, transparent 70%);
             z-index: 3;
             pointer-events: none;
-            filter: blur(30px);
-            opacity: 0.6;
+            filter: blur(20px);
         }
 
     </style>
 </head>
 <body>
 
-    <!-- 点击整个区域都能跳转 -->
-    <div class="glass-card" onclick="redirectToDetail()">
-        <div class="shine"></div>
-        
-        <div class="tag-year">2025 年</div>
-        
-        <div class="title-month">11</div>
-        <div class="title-sub">月度挑战赛</div>
+    <!-- 外层容器 -->
+    <div class="card-container" onclick="redirectToDetail()">
         
-        <div class="deco-icon">
-            <i class="fa-solid fa-trophy"></i>
-        </div>
+        <!-- 内层容器 -->
+        <div class="glass-layer">
+            <div class="shine"></div>
+            
+            <div class="tag-year">2025 年</div>
+            
+            <div class="title-month">11</div>
+            <div class="title-sub">月度挑战赛</div>
+            
+            <div class="deco-icon">
+                <i class="fa-solid fa-trophy"></i>
+            </div>
 
-        <!-- 替代原来的文字提示,使用显眼的按钮 -->
-        <button class="action-btn">
-            查看排行 <i class="fa-solid fa-arrow-right"></i>
-        </button>
+            <button class="action-btn" onclick="redirectToDetail()">
+                查看排行 <i class="fa-solid fa-arrow-right"></i>
+            </button>
+        </div>
+        
     </div>
 
-    <!-- 本地调试保留 mock_flutter.js,正式接入时移除 -->
-    <script src="./mock_flutter.js"></script>
     <script src="./bridge.js"></script>
     <script>
         function getQueryParam(name) {
@@ -247,17 +266,14 @@
                 detailUrl += `?${queryParams.join('&')}`;
             }
             
-            // Add full=true for Flutter interception
             detailUrl += (detailUrl.includes('?') ? '&' : '?') + 'full=true';
 
-            console.log("Navigating from sdk/index.html to:", detailUrl);
+            console.log("Navigating to:", detailUrl);
             
-            // Use Bridge.appAction for navigation
             if (window.Bridge && window.Bridge.appAction) {
                 Bridge.appAction(detailUrl);
             } else {
-                console.error("Bridge or Bridge.appAction is not defined. Falling back to window.location.href.");
-                window.location.href = detailUrl; // Fallback for pure browser environment
+                window.location.href = detailUrl;
             }
         }
     </script>

+ 281 - 0
card/sdk/old/index2.html

@@ -0,0 +1,281 @@
+<!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>11月挑战赛 - 智能缩放版</title>
+    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
+    <style>
+        :root {
+            --primary-purple: #593259;
+            --primary-orange: #ffeaa7;
+        }
+
+        * {
+            box-sizing: border-box;
+            margin: 0;
+            padding: 0;
+            font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
+            -webkit-tap-highlight-color: transparent;
+            user-select: none;
+        }
+
+        html, body {
+            margin: 0;
+            padding: 0;
+            width: 100vw;
+            height: 100vh;
+            overflow: hidden;
+            background-color: transparent !important;
+            background: rgba(0, 0, 0, 0) !important;
+        }
+
+        body {
+            display: flex;
+            justify-content: center;
+            align-items: center;
+        }
+
+        /* 2. 外层容器 */
+        .card-container {
+            width: 96%;
+            height: 96%;
+            /* 圆角也需要自适应:最大24px,或者视口最小边的6% */
+            border-radius: min(24px, 6vmin);
+            overflow: hidden;
+            position: relative;
+            cursor: pointer;
+            
+            background: linear-gradient(to bottom, 
+                            rgba(162, 155, 254, 0.2) 0%, 
+                            rgba(45, 52, 54, 0.95) 100%), 
+                        url('./bg.jpg?w=800') center/cover no-repeat;
+            
+            box-shadow: 0 15px 40px rgba(0, 0, 0, 0.4);
+            
+            transform: translateZ(0);
+            -webkit-mask-image: -webkit-radial-gradient(white, black);
+        }
+
+        /* 3. 内层毛玻璃 */
+        .glass-layer {
+            position: absolute;
+            top: 4%; left: 4%; right: 4%; bottom: 4%;
+            
+            /* 圆角自适应 */
+            border-radius: min(20px, 5vmin);
+            
+            border: 1.5px solid rgba(255, 255, 255, 0.3);
+            border-bottom: 1.5px solid rgba(255, 255, 255, 0.1);
+            
+            background: linear-gradient(to bottom, 
+                rgba(255, 255, 255, 0.15) 0%, 
+                rgba(255, 255, 255, 0.02) 50%, 
+                rgba(0, 0, 0, 0.1) 100%
+            );
+           /* backdrop-filter: blur(6px);
+            -webkit-backdrop-filter: blur(6px);
+            */
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            justify-content: flex-start;
+            text-align: center;
+            
+            box-shadow: inset 0 0 15px rgba(255,255,255,0.05);
+        }
+
+        .card-container:active {
+            transform: scale(0.98);
+            transition: transform 0.1s;
+        }
+
+        /* 顶部年份 */
+        .tag-year {
+            /* 字体大小:最大20px,缩小时占视口最小边的5.5% */
+            font-size: min(30px, 7.5vmin); 
+            font-weight: 400;
+            letter-spacing: 1px;
+            color: #fff;
+            background: rgba(0, 0, 0, 0.2);
+            
+            /* Padding 使用 em 单位,随字体大小缩放 */
+            padding: 0.2em 0.8em;
+            
+            border-radius: 16px;
+            border: 1px solid rgba(255,255,255,0.2);
+            position: absolute;
+            
+            /* 距离顶部的距离也自适应 */
+            top: min(10px, 2vh); 
+            z-index: 10;
+        }
+
+        /* 核心数字:11 */
+        .title-month {
+            /* 字体大小:最大80px,缩小时占视口最小边的22% */
+            font-size: min(140px, 32vmin); 
+            font-weight: 900;
+            line-height: 1;
+            
+            background: linear-gradient(to bottom, #ffffff 10%, #ffd700 50%, #ff9f43 100%);
+            -webkit-background-clip: text;
+            -webkit-text-fill-color: transparent;
+            
+            filter: drop-shadow(0 4px 0px rgba(0, 0, 0, 0.2));
+            
+            /* 顶部间距:最大55px,缩小时占视口高度的12% */
+            margin-top: min(75px, 16vh); 
+            z-index: 2;
+        }
+
+        /* 月度挑战赛 */
+        .title-sub {
+            /* 字体大小:最大16px,缩小时占视口最小边的4.5% */
+            font-size: min(20px, 6.5vmin); 
+            font-weight: 800;
+            color: rgba(255, 255, 255, 0.95);
+            letter-spacing: 2px;
+            text-transform: uppercase;
+            text-shadow: 0 1px 2px rgba(0,0,0,0.5);
+            
+            margin-top: min(5px, 1vh); 
+            margin-bottom: auto; 
+        }
+
+        /* 奖杯图标 */
+        .deco-icon {
+            /* 底部间距:最大22px,缩小时占视口高度的4% */
+            margin-bottom: min(22px, 8vh);
+            
+            /* 宽高:最大40px,缩小时占视口最小边的11% */
+            width: min(40px, 11vmin); 
+            height: min(40px, 11vmin);
+            
+            border-radius: 50%;
+            display: flex; justify-content: center; align-items: center;
+            background: rgba(255,255,255,0.1);
+            border: 1px solid rgba(255,255,255,0.2);
+            box-shadow: 0 5px 15px rgba(0,0,0,0.3);
+            
+            animation: trophy-pulse 2s infinite ease-in-out;
+        }
+
+        .deco-icon i {
+            /* 图标大小:最大20px,随容器缩小 */
+            font-size: min(20px, 5.5vmin);
+            color: #ffd700; 
+            filter: drop-shadow(0 0 5px rgba(253, 203, 110, 0.8));
+        }
+
+        /* 查看排行按钮 */
+        .action-btn {
+            /* 底部间距:最大20px,缩小时自适应 */
+            margin-bottom: min(30px, 6vh);
+            
+            /* 宽度:最大110px,缩小时占视口宽度的30% */
+            width: min(180px, 50vmin); 
+            
+            padding: min(8px, 2vh) 0;
+            border-radius: 25px;
+            border: none;
+            
+            background: linear-gradient(90deg, #ffeaa7 0%, #fab1a0 100%);
+            color: #593259;
+            
+            /* 字体:最大12px */
+            font-size: min(18px, 5.5vmin);
+            font-weight: 900;
+            
+            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
+            
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            gap: 4px;
+            z-index: 10;
+        }
+        
+        .action-btn i { font-size: 0.8em; }
+
+        @keyframes trophy-pulse {
+            0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(253, 203, 110, 0.2); }
+            50% { transform: scale(1.15); box-shadow: 0 0 15px 5px rgba(253, 203, 110, 0.4); }
+            100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(253, 203, 110, 0); }
+        }
+
+        /* 顶部反光 
+        .shine {
+            position: absolute;
+            top: -20px; left: -20px;
+            width: 150px; height: 100px;
+            background: radial-gradient(circle, rgba(255,255,255,0.3) 0%, transparent 70%);
+            z-index: 3;
+            pointer-events: none;
+            filter: blur(20px);
+        }
+*/
+    </style>
+</head>
+<body>
+
+    <!-- 外层容器 -->
+    <div class="card-container" onclick="redirectToDetail()">
+        
+        <!-- 内层容器 -->
+        <div class="glass-layer">
+            <div class="shine"></div>
+            
+            <div class="tag-year">2025 年</div>
+            
+            <div class="title-month">11</div>
+            <div class="title-sub">月度挑战赛</div>
+            
+            <div class="deco-icon">
+                <i class="fa-solid fa-trophy"></i>
+            </div>
+
+            <button class="action-btn" onclick="redirectToDetail()">
+                查看排行 <i class="fa-solid fa-arrow-right"></i>
+            </button>
+        </div>
+        
+    </div>
+
+    <script src="./bridge.js"></script>
+    <script>
+        function getQueryParam(name) {
+            const params = new URLSearchParams(window.location.search);
+            return params.get(name);
+        }
+
+        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';
+
+            console.log("Navigating to:", detailUrl);
+            
+            if (window.Bridge && window.Bridge.appAction) {
+                Bridge.appAction(detailUrl);
+            } else {
+                window.location.href = detailUrl;
+            }
+        }
+    </script>
+</body>
+</html>

+ 127 - 0
card/sdk/old/mock_flutter.js

@@ -0,0 +1,127 @@
+(function (window) {
+    'use strict';
+  
+    console.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;
+  
+      console.group('%c [MockFlutter] 收到 App 指令 ', 'color: #1aad19; font-weight: bold;');
+      console.log('Action:', action);
+      console.log('Data:', data);
+      console.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':
+          console.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':
+            console.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');
+                } else {
+                    console.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':
+            console.log(`[模拟App] ShowToast: ${data.title} (icon: ${data.icon})`);
+            break;
+        case 'showLoading':
+            console.log(`[模拟App] ShowLoading: ${data.title}`);
+            break;
+        case 'hideLoading':
+            console.log(`[模拟App] HideLoading`);
+            break;
+        case 'showModal':
+            const confirmed = confirm(`[模拟App] Modal: ${data.title}\n${data.content || ''}`);
+            console.log(`[模拟App] Modal result: ${confirmed ? 'Confirm' : 'Cancel'}`);
+            break;
+
+        default:
+          console.log(`[模拟App] 收到未知指令: ${action}, 数据: `, data);
+          // alert(`[模拟App] 收到未知指令: ${action}`);
+      }
+    };
+  
+    // 2. 模拟旧版注入对象 (防止报错,并使用可读中文)
+    if (!window.share_wx) {
+        window.share_wx = {
+            postMessage: function(jsonStr) {
+                console.log('[MockFlutter] share_wx.postMessage 收到:', jsonStr);
+                alert('[模拟App/旧通道] 微信分享:\n' + jsonStr);
+            }
+        };
+    }
+    
+    if (!window.wx_launch_mini) {
+        window.wx_launch_mini = {
+            postMessage: function(jsonStr) {
+                console.log('[MockFlutter] wx_launch_mini.postMessage 收到:', jsonStr);
+                alert('[模拟App/旧通道] 打开小程序:\n' + jsonStr);
+            }
+        };
+    }
+
+    if (!window.save_base64) {
+        window.save_base64 = {
+            postMessage: function(base64Str) {
+                console.log('[MockFlutter] save_base64.postMessage 收到 (Base64长度):', base64Str ? base64Str.length : '0');
+                alert(`[模拟App/旧通道] 保存图片 (Base64长度: ${base64Str ? base64Str.length : '0'})`);
+            }
+        };
+    }
+  
+    // 3. 模拟 UserAgent (可选)
+    // 某些逻辑可能会检查 navigator.userAgent,这里可以根据需要注入,但目前 SDK 不依赖这个判断
+  
+  })(window);

+ 736 - 0
card/sdk/old2/detail.html

@@ -0,0 +1,736 @@
+<!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 {
+            --primary-purple: #593259; 
+            --primary-orange: #fdcb6e;
+            --primary-red: #d63031;
+            --text-dark: #2d3436;
+            --footer-bg: #483055;
+        }
+
+        * {
+            box-sizing: border-box;
+            margin: 0;
+            padding: 0;
+            font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
+            -webkit-tap-highlight-color: transparent;
+            user-select: none;
+        }
+
+        body {
+            background: #f5f6fa;
+            width: 100%;
+            height: 100vh;
+            overflow: hidden;
+            display: flex;
+            flex-direction: column;
+        }
+
+        /* 顶部 Header */
+        .header-area {
+            height: 280px; 
+            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));
+            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; }
+        .icon-btn {
+            width: 36px; height: 36px; 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);
+        }
+        .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); }
+
+        /* 仪表盘卡片 */
+        .dashboard-card {
+            margin-top: 25px;
+            background: rgba(0, 0, 0, 0.4); 
+            backdrop-filter: blur(10px);
+            border-radius: 20px;
+            padding: 15px 20px;
+            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-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.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; }
+        
+        .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-active { 
+            position: absolute; top: 50%; left: 10px; 
+            width: 25%; 
+            height: 4px; 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; 
+            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; 
+            transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); 
+        }
+        
+        .trophy-item.active { 
+            background: #fff; border-color: var(--primary-orange); color: var(--primary-orange); 
+            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.active { 
+            background: linear-gradient(135deg, #f1c40f, #e67e22); 
+            color: white; border: none; 
+            transform: scale(1.3); 
+            box-shadow: 0 0 20px rgba(241, 196, 15, 0.6);
+            animation: pulseTrophy 2s infinite;
+        }
+
+        @keyframes pulseTrophy {
+            0% { box-shadow: 0 0 0 0 rgba(241, 196, 15, 0.7); }
+            70% { box-shadow: 0 0 0 10px rgba(241, 196, 15, 0); }
+            100% { box-shadow: 0 0 0 0 rgba(241, 196, 15, 0); }
+        }
+
+        /* 领奖台 */
+        .podium-wrap {
+            height: 140px;
+            display: flex; justify-content: center; align-items: flex-end; 
+            margin-top: -60px; 
+            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: -15px; }
+        .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 img { width: 100%; height: 100%; object-fit: cover; }
+
+        .crown {
+            position: absolute; top: -38px; color: #f1c40f; font-size: 32px;
+            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); }
+        }
+
+        .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; }
+
+        /* 列表容器 */
+        .list-container {
+            flex: 1; background: white; border-radius: 24px 24px 0 0;
+            padding: 0 20px 120px 20px; 
+            overflow-y: auto; 
+            margin-top: -10px;
+            box-shadow: 0 -5px 20px rgba(0,0,0,0.05);
+            position: relative; z-index: 8; 
+            -webkit-overflow-scrolling: touch; 
+        }
+        
+        .tabs { 
+            display: flex; justify-content: center; gap: 15px; 
+            position: sticky; top: 0; background: white; z-index: 9;
+            padding-top: 20px; padding-bottom: 10px;
+        }
+        
+        .tab { padding: 8px 20px; border-radius: 20px; font-size: 14px; color: #636e72; background: #f1f2f6; cursor: pointer; transition: 0.2s; }
+        .tab.active { background: var(--text-dark); color: #fdcb6e; font-weight: bold; }
+        
+        .list-item { display: flex; align-items: center; padding: 12px 0; border-bottom: 1px solid #f1f2f6; }
+        .rank { width: 30px; text-align: center; font-weight: bold; color: #b2bec3; font-style: italic;}
+        .avatar { width: 40px; height: 40px; border-radius: 50%; margin: 0 12px; background: #eee; overflow: hidden;}
+        .avatar img { width: 100%; height: 100%; object-fit: cover;}
+        .info { flex: 1; }
+        .name { font-size: 14px; color: #2d3436; font-weight: bold; }
+        .team { font-size: 11px; color: #636e72; display: flex; align-items: center; gap: 4px; }
+        .score { font-size: 16px; font-weight: bold; color: var(--primary-purple); }
+
+        /* 底部我的排名 */
+        .my-rank-bar {
+            position: fixed; bottom: 0; left: 0; width: 100%; height: 55px; 
+            background: var(--footer-bg); color: white;
+            display: flex; align-items: center; padding: 0 20px;
+            padding-bottom: env(safe-area-inset-bottom);
+            box-sizing: border-box; 
+            border-radius: 24px 24px 0 0; box-shadow: 0 -5px 20px rgba(0,0,0,0.2); z-index: 99; 
+        }
+        .my-rank-bar .rank { font-size: 16px; }
+        .my-rank-bar .avatar { width: 34px; height: 34px; border-width: 2px; }
+        .my-rank-bar .name { font-size: 14px; }
+        .my-rank-bar .team { font-size: 10px; }
+        .my-score { font-size: 18px; font-weight: bold; color: #ffffff !important; margin-left: auto; }
+
+        /* 演示按钮样式 (模态框内) */
+        .demo-section {
+            margin: 20px 0 0 0;
+            border-top: 1px dashed #ddd;
+            padding-top: 15px;
+        }
+        .demo-label { font-size: 12px; color: #999; margin-bottom: 10px; }
+        .demo-controls {
+            display: flex; justify-content: center; gap: 10px;
+        }
+        .demo-btn {
+            background: #eee; border: none; padding: 6px 12px; border-radius: 8px; font-size: 12px; color: #555; cursor: pointer;
+        }
+        .demo-btn:active { background: #ddd; color: #000; }
+
+        /* 下拉菜单 & 模态框 */
+        .dropdown { position: absolute; top: 70px; left: 50%; transform: translateX(-50%); width: 200px; background: white; border-radius: 12px; box-shadow: 0 10px 50px rgba(0,0,0,0.4); display: none; flex-direction: column; overflow: hidden; z-index: 200; }
+        .dropdown.show { display: flex; animation: dropIn 0.2s ease-out; }
+        .dd-item { padding: 12px; color: #636e72; font-size: 14px; text-align: center; border-bottom: 1px solid #f1f2f6; cursor: pointer; }
+        .dd-active { color: var(--primary-purple); font-weight: bold; background: #f9f0ff; }
+        @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.6); 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: white; border-radius: 24px; padding: 25px; text-align: center; transform: scale(0.8); transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); border: 4px solid var(--primary-purple); box-shadow: 0 10px 30px rgba(0,0,0,0.3); }
+        .modal-mask.show .modal-body { transform: scale(1); }
+        .m-close { background: var(--primary-purple); color: white; padding: 10px 25px; border-radius: 20px; border: none; font-weight: bold; cursor: pointer; margin-top: 15px; }
+        .rule-box { text-align: left; background: #f9f4ff; border: 1px solid #e3d7ff; border-radius: 12px; padding: 12px; color: #4b3a67; font-size: 13px; line-height: 1.6; box-shadow: inset 0 1px 0 rgba(255,255,255,0.7); }
+        .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(--primary-orange); margin-top: 2px; }
+
+        /* 加载遮罩 */
+        .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);
+        }
+        .loading-mask.show { display: flex; }
+        .loading-spinner {
+            border: 4px solid rgba(255,255,255,0.3);
+            border-top-color: #fdcb6e;
+            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:white"></i></div>
+            <div class="month-select" onclick="toggleDropdown()">
+                <span id="currentMonthText">11月挑战赛</span> <i class="fa-solid fa-caret-down"></i>
+            </div>
+            <div class="icon-btn" onclick="openModal()"><i class="fa-solid fa-question" style="color:white"></i></div>
+        </div>
+
+        <!-- 仪表盘卡片 -->
+        <div class="dashboard-card">
+            <div class="dash-header">
+                <div class="dash-title">
+                    <i class="fa-solid fa-medal dash-icon"></i> 定向达人
+                </div>
+                <div class="dash-badge" id="dashBadge">挑战 1 / 4</div>
+            </div>
+
+            <div class="trophy-track-container">
+                <div class="track-line-bg"></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" style="border:2px solid #fdcb6e"><img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Me" alt=""></div>
+        <div class="info"><div class="name" id="myName" style="color:white">我</div><div class="team" id="myTeam" style="color:#b2bec3">正在加载...</div></div>
+        <div class="my-score" id="myScoreValue">--</div>
+    </div>
+
+    <!-- 模态框 -->
+    <div class="modal-mask" id="infoModal">
+        <div class="modal-body">
+                        <h3 style="color:#593259; margin-bottom:15px;">📜 规则说明</h3>
+            <div id="ruleContent" class="rule-box">
+                <div class="rule-item"><i class="fa-solid fa-bullseye"></i><div><strong>积分规则:</strong> 在指定公园找到打卡点,耗时越短积分越高。</div></div>
+                <div class="rule-item"><i class="fa-solid fa-map-location-dot"></i><div><strong>场地排行:</strong> 按解锁的公园场地数量排名。</div></div>
+                <div class="rule-item"><i class="fa-solid fa-trophy"></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
+        };
+
+        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';
+            loadingMask.classList.toggle('show', isLoading);
+        }
+
+        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 ? '挑战成功' : `挑战 ${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 = '';
+            if (!list || list.length === 0) {
+                podiumWrap.innerHTML = '<div style="color:#fff;">暂无榜单数据</div>';
+                if (type === 'venue') state.venueListRendered = [];
+                else state.scoreListRendered = [];
+                if (state.activeTab === type) renderRankList([]);
+                return;
+            }
+            // Ensure we have at least 3 elements, fill with null if less
+            const p1 = list[0]; // Actual Rank 1
+            const p2 = list[1]; // Actual Rank 2
+            const p3 = list[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;
+                if (!person) return; // Skip if no person for this position
+
+                const col = document.createElement('div');
+                col.className = 'p-col ' + itemConfig.className;
+                
+                const name = person.nickName || person.name || person.userName || '选手';
+                const score = person.score != null ? person.score : (person.inRankNum != null ? person.inRankNum : '--');
+                const rankNum = person.rankNum; // Use actual rank from item
+
+                // Only the actual first place gets the crown
+                const isActualFirst = (person === p1); 
+
+                col.innerHTML = `
+                    ${isActualFirst ? '<div class="crown"><i class="fa-solid fa-crown"></i></div>' : ''}
+                    <div class="p-img">${buildAvatar(name, rankNum)}</div>
+                    <div class="p-box">
+                        <div class="p-name">${name}</div>
+                        <div class="p-score">${score}</div>
+                    </div>
+                `;
+                podiumWrap.appendChild(col);
+            });
+            const remaining = 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:#b2bec3; text-align:center;">暂无数据</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-user-group"></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');
+        }
+
+        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) {
+                console.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) {
+                console.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() {
+            const ecId = getQuery('ecId') || '4';
+            const token = getQuery('token');
+            let baseUrl = getQuery('baseUrl') || undefined;
+            const env = (getQuery('env') || '').toLowerCase();
+            const useMock = env === 'mock';
+            const ym = getYearMonth();
+            state.currentYM = ym;
+            state.months = getRecentMonths(3);
+            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) { console.warn('[Optional API] ignore error', err); return null; }
+            }
+
+            try {
+                const monthRank = await API.request('MonthRankDetailQuery', { year: ym.year, month: ym.month, dispArrStr: 'grad,mapNum' });
+                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: `${ym.month}月挑战赛` };
+                    currentMonth = { monthRs: [{ month: ym.month, realNum: 0, targetNum: 4 }] };
+                    allMonths = [];
+                    myRank = null;
+                    myScore = null;
+                    userInfo = null;
+                    cardConfig = null;
+                }
+                const monthGrad = monthRank && monthRank.gradRs ? monthRank.gradRs : [];
+                const monthMap = monthRank && monthRank.mapNumRs ? monthRank.mapNumRs : [];
+                state.scoreList = monthGrad;
+                state.venueList = monthMap;
+                const selfRow = monthGrad.find && monthGrad.find(item => item.isSelf === 1);
+                if (!base && monthGrad.length) base = { ecName: `${ym.month}月挑战赛` };
+                state.currentMonthData = currentMonth || null;
+                state.allMonthsData = allMonths || null;
+                const prog = findMonthProgress(ym.year, ym.month, state.currentMonthData, state.allMonthsData);
+                document.getElementById('currentMonthText').innerText = `${ym.month}月挑战赛`;
+                renderBadge(prog.realNum, prog.targetNum);
+                renderPodium(state.scoreList, 'score');
+                switchTab(state.activeTab, document.querySelector('.tab.active'));
+                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);
+            } catch (err) {
+                console.error('[Init] 加载失败', err);
+                rankListEl.innerHTML = '<div style="padding:20px; color:#d63031;">数据加载失败,请重试</div>';
+            } finally {
+                setLoading(false);
+            }
+        }
+
+        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>
+
+

+ 166 - 0
card/sdk/old2/index.html

@@ -0,0 +1,166 @@
+<!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>月度排行榜</title>
+    <link href="./css/all.min.css" rel="stylesheet">
+    <style>
+        * {
+            box-sizing: border-box;
+            margin: 0;
+            padding: 0;
+            font-family: 'Segoe UI', 'Microsoft YaHei', 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;
+        }
+
+        /* 容器:100% 充满嵌入框 */
+        .card-container {
+            width: 100%;
+            height: 100%;
+            border-radius: 20px;
+            overflow: hidden;
+            position: relative;
+            cursor: pointer;
+            /* 背景图 */
+            background: url('./card.png') center/cover no-repeat;
+        }
+
+        .card-container:active {
+            transform: scale(0.98);
+            transition: transform 0.1s;
+        }
+
+        .content-layer {
+            position: absolute;
+            top: 0; left: 0; width: 100%; height: 100%;
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            z-index: 2;
+        }
+
+        /* 1. 年份标签 - 修改:去背景、放大字体、单位改 vmin */
+        .year-tag {
+            /* 距离顶部约 14% */
+            margin-top: 13%; 
+            
+            /* vmin 单位:确保随容器大小自动缩放 */
+            font-size: 8vmin; /* 字体加大 */
+            font-weight: 800;
+            color: #fff;
+            letter-spacing: 2px;
+            
+            /* 去掉背景和边框 */
+            background: none;
+            border: none;
+            padding: 0;
+            
+            /* 增加投影以保证清晰度 */
+            text-shadow: 0 2px 4px rgba(0,0,0,0.5);
+        }
+
+        /* 2. 月份数字 - 修改:单位改 vmin */
+        .month-num {
+            /* 字体极大,随容器缩放 */
+            font-size: 32vmin; 
+            font-weight: 900;
+            line-height: 1;
+            margin-top: 9%;
+            
+            /* 金色渐变字 */
+            background: linear-gradient(to bottom, #ffffff 20%, #ffd700 60%, #ff9f43 100%);
+            -webkit-background-clip: text;
+            -webkit-text-fill-color: transparent;
+            
+            filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
+        }
+
+        /* 3. 奖杯动画图标 - 修改:大幅下移 */
+        .trophy-icon {
+            /* 修改:往下移动 (从原来的 4% 增加到 15%) */
+            margin-top: 13%; 
+            
+            /* 图标大小也随容器缩放 */
+            width: 15vmin; 
+            height: 15vmin;
+            
+            display: flex; justify-content: center; align-items: center;
+            animation: trophy-pulse 2s infinite ease-in-out;
+        }
+
+        .trophy-icon i {
+            /* 图标字体大小随容器缩放 */
+            font-size: 10vmin; 
+            color: #ffd700; 
+            filter: drop-shadow(0 0 5px rgba(253, 203, 110, 0.8));
+        }
+
+        @keyframes trophy-pulse {
+            0% { transform: scale(1); filter: drop-shadow(0 0 0 rgba(253, 203, 110, 0)); }
+            50% { transform: scale(1.2); filter: drop-shadow(0 0 8px rgba(253, 203, 110, 0.6)); }
+            100% { transform: scale(1); filter: drop-shadow(0 0 0 rgba(253, 203, 110, 0)); }
+        }
+
+    </style>
+</head>
+<body>
+
+    <div class="card-container" onclick="redirectToDetail()">
+        <div class="content-layer">
+            <div class="year-tag">2025年</div>
+            <div class="month-num">12</div>
+            <div class="trophy-icon">
+                <i class="fa-solid fa-trophy"></i>
+            </div>
+        </div>
+    </div>
+
+      <script src="./bridge.js"></script>
+    <script>
+        function getQueryParam(name) {
+            const params = new URLSearchParams(window.location.search);
+            return params.get(name);
+        }
+
+        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';
+
+            console.log("Navigating to:", detailUrl);
+            
+            if (window.Bridge && window.Bridge.appAction) {
+                Bridge.appAction(detailUrl);
+            } else {
+                window.location.href = detailUrl;
+            }
+        }
+    </script>
+</body>
+</html>

二进制
card/sdk/webfonts/fa-brands-400.woff2


二进制
card/sdk/webfonts/fa-regular-400.woff2


二进制
card/sdk/webfonts/fa-solid-900.woff2


部分文件因为文件数量过多而无法显示