From 38f307d59191a6b0d48c919c799307ced5a99165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=BD=E5=B7=A5=E5=91=98?= <4846a27a-ba87-49c9-939d-7275f63efcc7@agents.multica.local> Date: Mon, 24 Aug 2026 11:59:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(HEL-79):=20=E6=89=8B=E6=9C=BA=E7=AB=AF=20P?= =?UTF-8?q?2a=20=E8=A1=A8=E6=A0=BC=E5=BC=95=E6=93=8E+=E5=85=AD=E4=B8=AA?= =?UTF-8?q?=E6=B1=A0/=E6=A6=9C=E5=88=97=E8=A1=A8=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 StickyHScrollTable:冻结表头/首列、横滑、pan-x 触控 - 涨停池/炸板池/跌停池/昨日涨停/涨停表现/人气榜接真数据(沿用电脑接口) - 功能页顶栏返回+标题+日期按钮;底部日期抽屉(日历网格+快捷胶囊) - 点名称/代码弹底部 DetailSheet(走 /api/stock/{code}/preview) - 加载骨架/空态/错误态按动效规范;列配置进 nav.config - 其余 6 个行情页仍为占位(归 P2b) Co-authored-by: multica-agent --- app/frontend/m/config/nav.config.js | 150 +++++ app/frontend/m/css/features.css | 728 +++++++++++++++++++++++ app/frontend/m/index.html | 2 + app/frontend/m/js/pages.js | 870 ++++++++++++++++++++++++++++ app/frontend/m/js/router.js | 8 +- 5 files changed, 1757 insertions(+), 1 deletion(-) create mode 100644 app/frontend/m/css/features.css create mode 100644 app/frontend/m/js/pages.js diff --git a/app/frontend/m/config/nav.config.js b/app/frontend/m/config/nav.config.js index f5f3d6e..043887c 100644 --- a/app/frontend/m/config/nav.config.js +++ b/app/frontend/m/config/nav.config.js @@ -62,6 +62,156 @@ frozenColumns: ["name", "code"], primaryColumns: [], scrollColumns: [] + }, + + tableColumns: { + "market/limit-up": { + summary: "limit", + frozenColumns: [ + { key: "stock", label: "股票", type: "stock", width: 96 } + ], + primaryColumns: [ + { key: "streak", label: "连板", type: "streak" }, + { key: "change", label: "涨幅%", type: "change" }, + { key: "price", label: "价格", type: "price" } + ], + scrollColumns: [ + { key: "sector", label: "所属板块", type: "text" }, + { key: "first_time", label: "首封", type: "text" }, + { key: "last_time", label: "最后封板", type: "text" }, + { key: "open_times", label: "开板", type: "int" }, + { key: "turnover_rate", label: "换手%", type: "rate" }, + { key: "amount_billion", label: "成交额亿", type: "money" }, + { key: "seal_amount_million", label: "封单额万", type: "int", hideZero: true }, + { key: "reason", label: "涨停原因", type: "text", wide: true } + ] + }, + + "market/broken": { + summary: "broken", + frozenColumns: [ + { key: "stock", label: "股票", type: "stock", width: 96 } + ], + primaryColumns: [ + { key: "change", label: "现价涨幅%", type: "change" }, + { key: "limitGap", label: "距涨停%", type: "gap" }, + { key: "price", label: "价格", type: "price" } + ], + scrollColumns: [ + { key: "sector", label: "所属板块", type: "text" }, + { key: "first_time", label: "首次触板", type: "text" }, + { key: "open_times", label: "开板", type: "int" }, + { key: "turnover_rate", label: "换手%", type: "rate" }, + { key: "amount_billion", label: "成交额亿", type: "money" }, + { key: "reason", label: "炸板原因", type: "text", wide: true } + ] + }, + + "market/limit-down": { + summary: "down", + frozenColumns: [ + { key: "stock", label: "股票", type: "stock", width: 96 } + ], + primaryColumns: [ + { key: "change", label: "跌幅%", type: "change" }, + { key: "price", label: "价格", type: "price" } + ], + scrollColumns: [ + { key: "sector", label: "所属板块", type: "text" }, + { key: "turnover_rate", label: "换手%", type: "rate" }, + { key: "amount_billion", label: "成交额亿", type: "money" }, + { key: "streak", label: "连续跌停", type: "int", hideZero: true }, + { key: "reason", label: "风险线索", type: "text", wide: true } + ] + }, + + "market/yesterday": { + summary: "yesterday", + frozenColumns: [ + { key: "stock", label: "股票", type: "stock", width: 96 } + ], + primaryColumns: [ + { key: "current_change", label: "今日涨幅%", type: "change" }, + { key: "outcome", label: "今日结果", type: "outcome" }, + { key: "prior_streak", label: "昨日高度", type: "int" } + ], + scrollColumns: [ + { key: "current_streak", label: "当前高度", type: "height" }, + { key: "sector", label: "所属板块", type: "text" }, + { key: "reason", label: "涨停逻辑", type: "text", wide: true } + ] + }, + + "market/performance": { + summary: "performance", + frozenColumns: [ + { key: "label", label: "梯队", type: "text", width: 96 } + ], + primaryColumns: [ + { key: "advance_rate", label: "晋级率%", type: "advance" }, + { key: "advanced", label: "晋级", type: "int" }, + { key: "positive_rate", label: "收红率%", type: "rate" } + ], + scrollColumns: [ + { key: "count", label: "样本", type: "int" }, + { key: "average_change", label: "平均涨幅%", type: "change" } + ] + }, + + "market/popularity": { + summary: "popularity", + sources: ["combined", "ths", "dc"], + columns: { + combined: { + frozenColumns: [ + { key: "rank", label: "#", type: "rank", width: 40 }, + { key: "stock", label: "股票", type: "stock", width: 96 } + ], + primaryColumns: [ + { key: "price", label: "最新价", type: "price" }, + { key: "change", label: "涨跌幅%", type: "change" } + ], + scrollColumns: [ + { key: "ths_rank", label: "同花顺", type: "int", hideZero: true }, + { key: "dc_rank", label: "东方财富", type: "int", hideZero: true }, + { key: "rank_change", label: "排名变化", type: "move" }, + { key: "concepts", label: "热门概念", type: "concepts" } + ] + }, + ths: { + frozenColumns: [ + { key: "rank", label: "#", type: "rank", width: 40 }, + { key: "stock", label: "股票", type: "stock", width: 96 } + ], + primaryColumns: [ + { key: "price", label: "最新价", type: "price" }, + { key: "change", label: "涨跌幅%", type: "change" } + ], + scrollColumns: [ + { key: "dc_rank", label: "东方财富", type: "int", hideZero: true }, + { key: "rank_change", label: "排名变化", type: "move" }, + { key: "concepts", label: "热门概念", type: "concepts" }, + { key: "dual_source", label: "榜单状态", type: "dual" } + ] + }, + dc: { + frozenColumns: [ + { key: "rank", label: "#", type: "rank", width: 40 }, + { key: "stock", label: "股票", type: "stock", width: 96 } + ], + primaryColumns: [ + { key: "price", label: "最新价", type: "price" }, + { key: "change", label: "涨跌幅%", type: "change" } + ], + scrollColumns: [ + { key: "ths_rank", label: "同花顺", type: "int", hideZero: true }, + { key: "rank_change", label: "排名变化", type: "move" }, + { key: "concepts", label: "热门概念", type: "concepts" }, + { key: "dual_source", label: "榜单状态", type: "dual" } + ] + } + } + } } }; diff --git a/app/frontend/m/css/features.css b/app/frontend/m/css/features.css new file mode 100644 index 0000000..69c6de1 --- /dev/null +++ b/app/frontend/m/css/features.css @@ -0,0 +1,728 @@ +/* 手机端行情列表页(P2a):表格引擎 + 六个池/榜页 + 抽屉/详情 Sheet */ + +#m-view.m-view-feature { + padding: 0; +} + +.m-page { + display: flex; + flex-direction: column; + height: 100vh; + height: 100dvh; + height: calc(100vh - var(--mobile-header-height) - var(--mobile-tabbar-height) - var(--mobile-safe-bottom)); + height: calc(100dvh - var(--mobile-header-height) - var(--mobile-tabbar-height) - var(--mobile-safe-bottom)); +} + +/* 顶部日期按钮 */ +.m-date-btn { + display: inline-flex; + align-items: center; + gap: 4px; + height: 32px; + padding: 0 8px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--surface); + color: var(--text-secondary); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--motion-press-release) var(--ease-press), + background-color var(--motion-press-release) var(--ease-press), + color var(--motion-pop) var(--ease-press); +} + +.m-date-btn:active { + background: var(--surface-hover); + transform: scale(0.96); + transition-duration: var(--motion-press); +} + +.m-date-btn svg { + flex: 0 0 auto; +} + +.m-date-btn-text { + font-size: var(--font-size-caption); + font-variant-numeric: tabular-nums; +} + +/* 结论条 */ +.m-summary { + display: flex; + gap: 8px; + padding: 12px 12px 8px; + overflow-x: auto; + flex: 0 0 auto; + -webkit-overflow-scrolling: touch; +} + +.m-summary-item { + flex: 0 0 auto; + min-width: 96px; + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + border-radius: 8px; + background: var(--surface); + box-shadow: var(--elevation-card); + transition: background-color var(--motion-pop) var(--ease-press), + border-color var(--motion-pop) var(--ease-press); +} + +.m-summary-item span { + font-size: var(--font-size-caption); + color: var(--text-secondary); +} + +.m-summary-item strong { + font-size: var(--font-size-metric); + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text-primary); +} + +.m-summary-item.is-up strong { color: var(--market-up); } +.m-summary-item.is-down strong { color: var(--market-down); } + +.m-conclusion { + padding: 4px 12px 0; + font-size: var(--font-size-label); + color: var(--text-secondary); + line-height: 1.5; + flex: 0 0 auto; +} + +.m-conclusion b { color: var(--text-primary); } + +/* 人气榜来源切换 */ +.m-source-tabs { + display: flex; + gap: 8px; + padding: 8px 12px; + flex: 0 0 auto; +} + +.m-source-tab { + flex: 1; + height: 36px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--surface); + color: var(--text-secondary); + font-size: var(--font-size-label); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--motion-press-release) var(--ease-press), + background-color var(--motion-press-release) var(--ease-press), + color var(--motion-pop) var(--ease-press); +} + +.m-source-tab:active { + background: var(--surface-hover); + transform: scale(0.96); + transition-duration: var(--motion-press); +} + +.m-source-tab.active { + background: var(--action-soft); + border-color: var(--action); + color: var(--action); + font-weight: 600; +} + +/* 表格横向滚动容器 */ +.m-table-scroll { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + -webkit-overflow-scrolling: touch; + touch-action: pan-x pan-y; + overscroll-behavior: contain; + background: var(--surface); +} + +.m-table { + border-collapse: separate; + border-spacing: 0; + width: max-content; + min-width: 100%; +} + +.m-table th, +.m-table td { + box-sizing: border-box; + padding: 0 8px; + white-space: nowrap; + font-size: var(--font-size-table); + border-bottom: 1px solid var(--border); +} + +.m-table thead th { + position: sticky; + top: 0; + z-index: 2; + height: var(--mobile-table-header-height); + background: var(--surface); + color: var(--text-secondary); + font-size: var(--font-size-caption); + font-weight: 500; + border-bottom: 1px solid var(--border-strong); +} + +.m-table tbody td { + height: var(--mobile-table-row-height); + background: var(--surface); + transition: background-color var(--motion-press-release) var(--ease-press); +} + +.m-table tbody tr:active td { + background: var(--surface-selected); + transition-duration: var(--motion-press); +} + +.m-table .m-frozen { + position: sticky; + z-index: 1; + background: var(--surface); +} + +.m-table thead .m-frozen { + z-index: 3; +} + +.m-table .m-frozen { + box-shadow: 1px 0 0 var(--border-strong), 6px 0 10px -8px rgba(0, 0, 0, 0.28); +} + +.m-table tbody .m-frozen { + box-shadow: 1px 0 0 var(--border-strong), 6px 0 10px -8px rgba(0, 0, 0, 0.18); +} + +.m-align-right { text-align: right; } +.m-align-left { text-align: left; } +.m-align-center { text-align: center; } + +/* 单元格内容 */ +.m-stock { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1px; + max-width: 100%; +} + +.m-stock-name { + font-size: var(--font-size-table); + font-weight: 500; + color: var(--text-primary); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} + +.m-stock-code { + font-size: var(--font-size-aux); + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} + +.m-rank { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 3px; +} + +.m-rank b { + font-size: var(--font-size-table); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.m-rank.is-top b { + font-size: var(--font-size-card-title); + font-weight: 600; + color: var(--warning); +} + +.m-rank-hot { + font-size: 10px; + line-height: 1.4; + color: var(--warning); + background: var(--warning-soft); + border-radius: 4px; + padding: 0 3px; +} + +.m-pill { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + font-size: var(--font-size-aux); + background: var(--action-soft); + color: var(--action); + font-weight: 500; +} + +.m-pill.is-high { + background: var(--market-up-soft); + color: var(--market-up); +} + +.m-pill--plain { + background: var(--surface-muted); + color: var(--text-secondary); +} + +.m-tag { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + font-size: var(--font-size-aux); + font-weight: 500; +} + +.m-tag--advance { background: var(--market-up-soft); color: var(--market-up); } +.m-tag--fail { background: var(--surface-muted); color: var(--text-secondary); } +.m-tag--broken { background: var(--warning-soft); color: var(--warning); } +.m-tag--down { background: var(--market-down-soft); color: var(--market-down); } +.m-tag--dual { background: var(--action-soft); color: var(--action); } + +.m-num { + font-variant-numeric: tabular-nums; + color: var(--text-primary); +} + +.m-num.up { color: var(--market-up); } +.m-num.down { color: var(--market-down); } +.m-num.is-active { color: var(--market-up); font-weight: 600; } +.m-num.is-warning { color: var(--warning); font-weight: 600; } +.m-num.is-neutral { color: var(--text-secondary); font-weight: 600; } +.m-num.is-new { color: var(--action); } + +.m-cell-text { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-primary); +} + +/* 骨架屏 */ +.m-skeleton { + padding: 0; +} + +.m-skeleton-row { + display: flex; + align-items: center; + height: var(--mobile-table-row-height); + padding: 0 12px; + border-bottom: 1px solid var(--border-subtle); +} + +.m-skeleton-bar { + display: block; + width: 100%; + height: 14px; + border-radius: 4px; + background: var(--surface-muted); + animation: m-skeleton-breathe 1200ms ease-in-out infinite; +} + +@keyframes m-skeleton-breathe { + 0% { opacity: 1; } + 50% { opacity: 0.55; } + 100% { opacity: 1; } +} + +/* 空态 / 错误态 */ +.m-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 100%; + padding: 40px 20px; + text-align: center; +} + +.m-state-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + margin-bottom: 4px; + border-radius: 50%; + background: var(--surface-muted); + color: var(--text-tertiary); +} + +.m-state p { + margin: 0; + font-size: var(--font-size-card-title); + font-weight: 600; + color: var(--text-primary); +} + +.m-state small { + font-size: var(--font-size-label); + color: var(--text-secondary); +} + +.m-state--error p { + color: var(--warning); +} + +.m-btn-retry { + width: auto; + min-width: 120px; + padding: 0 24px; + margin-top: 8px; +} + +/* 底部抽屉 / Sheet */ +.m-sheet-root { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 120; +} + +.m-sheet-backdrop { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background: var(--backdrop); + opacity: 0; + transition: opacity 160ms linear; +} + +.m-sheet-root.is-open .m-sheet-backdrop { + opacity: 1; + transition: opacity 200ms linear; +} + +.m-sheet { + position: absolute; + left: 0; + right: 0; + bottom: 0; + max-height: 85vh; + display: flex; + flex-direction: column; + background: var(--surface); + border-radius: var(--mobile-sheet-radius) var(--mobile-sheet-radius) 0 0; + transform: translateY(100%); + transition: transform var(--motion-exit) var(--ease-exit); + padding-bottom: var(--mobile-safe-bottom); +} + +.m-sheet--detail { + height: 85vh; +} + +.m-sheet-root.is-open .m-sheet { + transform: translateY(0); + transition: transform var(--motion-enter) var(--ease-enter); +} + +.m-sheet-handle { + display: flex; + align-items: center; + justify-content: center; + height: 28px; + flex: 0 0 auto; + cursor: grab; +} + +.m-sheet-handle::before { + content: ""; + width: 32px; + height: 4px; + border-radius: 2px; + background: var(--border-strong); +} + +.m-sheet-head { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 52px; + padding: 0 16px 0 20px; + border-bottom: 1px solid var(--border-subtle); + flex: 0 0 auto; +} + +.m-sheet-head h2 { + margin: 0; + font-size: var(--font-size-page-title); + font-weight: 600; +} + +.m-sheet-head-title { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.m-sheet-head-code { + font-size: var(--font-size-label); + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} + +.m-sheet-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--mobile-touch-size); + height: var(--mobile-touch-size); + flex: 0 0 var(--mobile-touch-size); + border: 0; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--motion-press-release) var(--ease-press), + background-color var(--motion-press-release) var(--ease-press); +} + +.m-sheet-close:active { + background: var(--surface-hover); + transform: scale(0.94); + transition-duration: var(--motion-press); +} + +.m-sheet-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 12px 16px 16px; + -webkit-overflow-scrolling: touch; +} + +/* 日期抽屉 */ +.m-quick-row { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.m-quick { + flex: 1; + height: 44px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--surface); + color: var(--text-primary); + font-size: var(--font-size-label); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--motion-press-release) var(--ease-press), + background-color var(--motion-press-release) var(--ease-press); +} + +.m-quick:active { + background: var(--surface-hover); + transform: scale(0.96); + transition-duration: var(--motion-press); +} + +.m-cal-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.m-cal-title { + font-size: var(--font-size-card-title); + font-weight: 600; +} + +.m-cal-nav { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--mobile-touch-size); + height: var(--mobile-touch-size); + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text-primary); + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.m-cal-nav:disabled { + opacity: 0.35; + cursor: default; +} + +.m-cal-nav:active:not(:disabled) { + background: var(--surface-hover); +} + +.m-cal-week { + display: grid; + grid-template-columns: repeat(7, 1fr); + margin-bottom: 4px; +} + +.m-cal-week span { + height: 32px; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-caption); + color: var(--text-tertiary); +} + +.m-cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.m-cal-cell { + height: 44px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text-primary); + font-size: var(--font-size-body); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--motion-press-release) var(--ease-press), + background-color var(--motion-press-release) var(--ease-press); +} + +.m-cal-cell:active:not(:disabled) { + background: var(--surface-hover); + transform: scale(0.94); + transition-duration: var(--motion-press); +} + +.m-cal-cell:disabled { + color: var(--text-tertiary); + opacity: 0.4; + cursor: default; +} + +.m-cal-cell.is-selected { + background: var(--action); + color: var(--text-inverse); + font-weight: 600; +} + +.m-cal-cell.is-empty { + cursor: default; +} + +/* 详情 Sheet */ +.m-detail-top { + display: flex; + flex-direction: column; + gap: 6px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-subtle); +} + +.m-detail-name { + display: flex; + align-items: baseline; + gap: 8px; + font-size: var(--font-size-page-title); + font-weight: 600; +} + +.m-detail-code { + font-size: var(--font-size-label); + color: var(--text-tertiary); + font-weight: 400; + font-variant-numeric: tabular-nums; +} + +.m-detail-quote { + display: flex; + align-items: baseline; + gap: 10px; +} + +.m-detail-price { + font-size: 32px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.m-detail-price.up { color: var(--market-up); } +.m-detail-price.down { color: var(--market-down); } + +.m-detail-change { + font-size: var(--font-size-card-title); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.m-detail-change.up { color: var(--market-up); } +.m-detail-change.down { color: var(--market-down); } + +.m-detail-meta { + font-size: var(--font-size-label); + color: var(--text-secondary); +} + +.m-detail-chart { + margin-top: 12px; + height: 96px; +} + +.m-detail-chart svg { + width: 100%; + height: 100%; + display: block; +} + +.m-detail-note { + margin-top: 12px; + font-size: var(--font-size-aux); + color: var(--text-tertiary); +} + +/* 减少动画降级 */ +@media (prefers-reduced-motion: reduce) { + .m-sheet { + transition: opacity var(--motion-fade) var(--ease-press); + } + + .m-sheet-root .m-sheet { + transform: none; + opacity: 0; + } + + .m-sheet-root.is-open .m-sheet { + transform: none; + opacity: 1; + } + + .m-skeleton-bar { + animation-duration: 2000ms; + } + + .m-date-btn:active, + .m-source-tab:active, + .m-quick:active, + .m-cal-cell:active, + .m-sheet-close:active { + transform: none; + } +} diff --git a/app/frontend/m/index.html b/app/frontend/m/index.html index 039bbc3..8592acf 100644 --- a/app/frontend/m/index.html +++ b/app/frontend/m/index.html @@ -7,6 +7,7 @@ 小白复盘 +
@@ -38,6 +39,7 @@ + diff --git a/app/frontend/m/js/pages.js b/app/frontend/m/js/pages.js new file mode 100644 index 0000000..77c9e03 --- /dev/null +++ b/app/frontend/m/js/pages.js @@ -0,0 +1,870 @@ +(function (global) { + "use strict"; + + /* ---------------------------------------------------------------- helpers */ + + function number(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + + function escapeHtml(value) { + return String(value == null ? "" : value).replace(/[&<>"']/g, function (ch) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]; + }); + } + + function formatNumber(value, digits) { + return new Intl.NumberFormat("zh-CN", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(number(value)); + } + + function changeClass(value) { + const n = number(value); + return n > 0 ? "up" : n < 0 ? "down" : ""; + } + + function streakLabel(streak) { + const value = Math.max(1, number(streak)); + return value === 1 ? "首板" : value + "板"; + } + + function displayCompactDate(value) { + const text = String(value || "").replaceAll("-", ""); + if (text.length !== 8) return value || "--"; + return text.slice(0, 4) + "-" + text.slice(4, 6) + "-" + text.slice(6, 8); + } + + function localDateString(value) { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + return year + "-" + month + "-" + day; + } + + function todayString() { + return localDateString(new Date()); + } + + function parseLocalDate(value) { + const parts = String(value || "").split("-").map(Number); + return new Date(parts[0], (parts[1] || 1) - 1, parts[2] || 1); + } + + function addDays(value, delta) { + const date = parseLocalDate(value); + date.setDate(date.getDate() + delta); + return localDateString(date); + } + + function previousWeekday(value) { + let date = parseLocalDate(value); + date.setDate(date.getDate() - 1); + while (date.getDay() === 0 || date.getDay() === 6) date.setDate(date.getDate() - 1); + return localDateString(date); + } + + const ICONS = { + calendar: '', + close: '', + "chevron-left": '', + "chevron-right": '', + inbox: '', + }; + + function icon(name, size) { + const body = ICONS[name] || ""; + return '"; + } + + /* ---------------------------------------------------------------- state */ + + const state = { + key: "", + requestedDate: "", + dashboard: null, + popularity: null, + popularitySource: "combined", + seq: 0, + calCursor: null, + }; + + let sheetToken = 0; + + function pageConfig(key) { + const cfg = global.MobileNav && global.MobileNav.tableColumns ? global.MobileNav.tableColumns[key] : null; + return cfg || null; + } + + function columnsFor(cfg) { + if (cfg.columns) return cfg.columns[state.popularitySource] || cfg.columns.combined; + return cfg; + } + + /* ---------------------------------------------------------------- cells */ + + function columnWidth(col) { + if (col.width) return col.width; + if (col.type === "rank") return 40; + if (col.type === "stock") return 96; + if (col.wide) return 140; + if (col.type === "text" || col.type === "concepts" || col.type === "outcome" || col.type === "dual") return 96; + return 72; + } + + function colAlign(col) { + if (col.type === "rank") return "m-align-center"; + if (["change", "price", "rate", "money", "gap", "int", "streak", "advance", "height", "move"].indexOf(col.type) >= 0) return "m-align-right"; + return "m-align-left"; + } + + function stockCell(row) { + return '' + + '' + escapeHtml(row.name || "--") + "" + + '' + escapeHtml(row.code || "") + "" + + ""; + } + + function rankCell(row, index) { + const value = number(row.rank) > 0 ? number(row.rank) : index + 1; + const hot = index < 3 ? '' : ""; + return '' + value + "" + hot + ""; + } + + function streakCell(value) { + const n = number(value); + if (n <= 0) return ""; + const high = n >= 4 ? " is-high" : ""; + return '' + streakLabel(n) + ""; + } + + function numCell(value, digits, signed) { + if (value == null || value === "") return ''; + const n = number(value); + const sign = signed && n > 0 ? "+" : ""; + return '' + sign + formatNumber(n, digits) + ""; + } + + function changeCell(value) { + if (value == null || value === "") return ''; + const n = number(value); + return '' + (n > 0 ? "+" : "") + formatNumber(n, 2) + ""; + } + + function intCell(value, hideZero) { + const n = Math.round(number(value)); + if (hideZero && n === 0) return ''; + if (value == null || value === "") return ''; + return '' + n.toLocaleString("zh-CN") + ""; + } + + function advanceCell(value) { + const n = number(value); + const cls = n === 0 ? "is-neutral" : n < 20 ? "is-warning" : "is-active"; + return '' + formatNumber(n, 1) + ""; + } + + function outcomeCell(value) { + const map = { "晋级": "advance", "断板": "fail", "炸板": "broken", "跌停": "down" }; + const cls = map[value] || "fail"; + return '' + escapeHtml(value || "") + ""; + } + + function heightCell(value) { + const n = number(value); + if (n <= 0) return ""; + return '' + n + ""; + } + + function moveCell(value) { + const n = value == null ? null : number(value); + if (n == null) return ''; + if (n > 0) return '\u2191' + n + ""; + if (n < 0) return '\u2193' + Math.abs(n) + ""; + return '持平'; + } + + function conceptsCell(value) { + const list = Array.isArray(value) ? value : []; + const text = list.slice(0, 3).join("、"); + return '' + escapeHtml(text) + ""; + } + + function dualCell(value) { + const dual = Boolean(value); + return '' + (dual ? "双榜共识" : "单榜入选") + ""; + } + + function textCell(value) { + const text = String(value == null ? "" : value); + return '' + escapeHtml(text) + ""; + } + + function cellHtml(col, row, index) { + const value = row[col.key]; + switch (col.type) { + case "stock": return stockCell(row); + case "rank": return rankCell(row, index); + case "streak": return streakCell(value); + case "change": return changeCell(value); + case "price": return numCell(value, 2); + case "rate": return numCell(value, 2); + case "money": return numCell(value, 2); + case "gap": return numCell(value, 2); + case "int": return intCell(value, col.hideZero); + case "advance": return advanceCell(value); + case "outcome": return outcomeCell(value); + case "height": return heightCell(value); + case "move": return moveCell(value); + case "concepts": return conceptsCell(value); + case "dual": return dualCell(value); + case "text": + default: return textCell(value); + } + } + + /* ---------------------------------------------------------------- table */ + + function buildTable(cols, rows) { + const frozen = cols.frozenColumns || []; + const primary = cols.primaryColumns || []; + const scroll = cols.scrollColumns || []; + const ordered = frozen.concat(primary, scroll); + + let left = 0; + const frozenLeft = frozen.map(function (col) { + const offset = left; + left += columnWidth(col); + return offset; + }); + + function cellOpen(col, index, isHead) { + const isFrozen = index < frozen.length; + const width = columnWidth(col); + const clamp = col.type === "text" || col.type === "concepts" ? ";max-width:" + width + "px" : ""; + const style = "min-width:" + width + "px" + clamp + (isFrozen ? ";width:" + width + "px;left:" + frozenLeft[index] + "px" : ""); + const cls = (isHead ? "m-th" : "m-td") + " " + colAlign(col) + (isFrozen ? " m-frozen" : ""); + return '<' + (isHead ? "th" : "td") + ' class="' + cls + '" style="' + style + '">'; + } + + const head = ordered.map(function (col, i) { + return cellOpen(col, i, true) + escapeHtml(col.label) + (i < frozen.length ? "" : ""); + }).join(""); + + const body = rows.map(function (row, rIndex) { + const cells = ordered.map(function (col, i) { + return cellOpen(col, i, false) + cellHtml(col, row, rIndex) + ""; + }).join(""); + const code = /^\d{6}$/.test(String(row.code || "")) ? ' data-code="' + escapeHtml(row.code) + '"' : ""; + return "" + cells + ""; + }).join(""); + + return '' + head + "" + body + "
"; + } + + function skeletonHtml(rowCount) { + const rows = []; + for (let i = 0; i < rowCount; i += 1) { + rows.push('
'); + } + return '"; + } + + function emptyHtml() { + return '
' + + '' + icon("inbox", 26) + "" + + "

该交易日暂无相关数据

" + + '可点右上角日期切换交易日' + + "
"; + } + + function errorHtml(message) { + return '
' + + '

' + escapeHtml(message || "数据加载失败") + "

" + + '' + + "
"; + } + + /* ---------------------------------------------------------------- summary */ + + function summaryCard(label, value, tone) { + const cls = tone === "up" ? " is-up" : tone === "down" ? " is-down" : ""; + return '
' + escapeHtml(label) + "" + escapeHtml(value) + "
"; + } + + function buildSummary(key, cfg) { + const dash = state.dashboard || {}; + const overview = dash.overview || {}; + const cards = []; + + if (cfg.summary === "limit") { + const limits = dash.limits || []; + const maxStreak = limits.reduce(function (m, r) { return Math.max(m, number(r.streak)); }, 0); + const seal = overview.seal_rate != null ? formatNumber(overview.seal_rate, 1) + "%" : "--"; + cards.push(summaryCard("涨停家数", limits.length, "up")); + cards.push(summaryCard("最高连板", maxStreak ? maxStreak + " 板" : "--")); + cards.push(summaryCard("封板率", seal)); + } else if (cfg.summary === "broken") { + const broken = dash.broken || []; + const limits = dash.limits || []; + const seal = overview.seal_rate != null ? formatNumber(overview.seal_rate, 1) + "%" : "--"; + cards.push(summaryCard("炸板家数", broken.length, "down")); + cards.push(summaryCard("涨停家数", overview.limit_up_count != null ? overview.limit_up_count : limits.length, "up")); + cards.push(summaryCard("封板率", seal)); + } else if (cfg.summary === "down") { + const downs = dash.down_limits || []; + const maxStreak = downs.reduce(function (m, r) { return Math.max(m, number(r.streak)); }, 0); + const cluster = downSectorCluster(downs); + cards.push(summaryCard("跌停家数", downs.length, "down")); + cards.push(summaryCard("连续跌停", maxStreak ? maxStreak + " 天" : "--")); + cards.push(summaryCard("集中板块", cluster || "--")); + } else if (cfg.summary === "yesterday") { + const rows = dash.yesterday_limits || []; + const total = rows.length; + const advance = rows.filter(function (r) { return r.outcome === "晋级"; }).length; + const positive = rows.filter(function (r) { return number(r.current_change) > 0; }).length; + const risk = rows.filter(function (r) { return ["炸板", "跌停"].indexOf(r.outcome) >= 0; }).length; + const rate = function (v) { return total ? formatNumber(v / total * 100, 1) + "%" : "--"; }; + cards.push(summaryCard("晋级率", rate(advance), "up")); + cards.push(summaryCard("兑现率", rate(positive), "up")); + cards.push(summaryCard("亏钱效应", rate(risk), "down")); + } else if (cfg.summary === "performance") { + const up = number(overview.up_count); + const down = number(overview.down_count); + const ratio = up + down > 0 ? formatNumber(up / (up + down) * 100, 1) + "%" : "--"; + cards.push(summaryCard("上涨", up + " 家", "up")); + cards.push(summaryCard("下跌", down + " 家", "down")); + cards.push(summaryCard("红盘率", ratio, "up")); + } else { + return ""; + } + + if (!cards.length) return ""; + return '
' + cards.join("") + "
"; + } + + function downSectorCluster(rows) { + const counts = {}; + rows.forEach(function (row) { + const sector = String(row.sector || "其他").trim() || "其他"; + if (sector === "其他") return; + counts[sector] = (counts[sector] || 0) + 1; + }); + let cluster = null; + Object.keys(counts).forEach(function (sector) { + if (!cluster || counts[sector] > cluster.count) cluster = { sector: sector, count: counts[sector] }; + }); + return cluster && cluster.count >= 2 ? cluster.sector + " \u00d7" + cluster.count : ""; + } + + /* ---------------------------------------------------------------- rows */ + + function brokenLimitRate(row) { + const name = String(row.name || "").toUpperCase(); + const code = String(row.code || "").replace(/\D/g, ""); + if (name.indexOf("ST") >= 0) return 10; + if (/^(300|301|688|689)/.test(code)) return 20; + if (/^(4|8|92)/.test(code)) return 30; + return 10; + } + + function prepareRows(key) { + const dash = state.dashboard || {}; + if (key === "market/limit-up") return dash.limits || []; + if (key === "market/broken") { + return (dash.broken || []).map(function (row) { + row.limitGap = Math.max(0, brokenLimitRate(row) - number(row.change)); + return row; + }); + } + if (key === "market/limit-down") return dash.down_limits || []; + if (key === "market/yesterday") return dash.yesterday_limits || []; + if (key === "market/performance") return normalizePerformanceRows(dash.limit_performance || []); + if (key === "market/popularity") { + return state.popularity ? (state.popularity[state.popularitySource] || []) : []; + } + return []; + } + + function normalizePerformanceRows(rows) { + const groups = {}; + (rows || []).forEach(function (row) { + const level = Math.max(1, number(row.level)); + const displayLevel = Math.min(level, 5); + const group = groups[displayLevel] || { + level: displayLevel, + label: displayLevel === 1 ? "昨日首板" : displayLevel === 5 ? "昨日5板+" : "昨日" + displayLevel + "板", + count: 0, + advanced: 0, + positive: 0, + changeTotal: 0, + }; + const count = number(row.count); + group.count += count; + group.advanced += number(row.advanced); + group.positive += count * number(row.positive_rate) / 100; + group.changeTotal += count * number(row.average_change); + groups[displayLevel] = group; + }); + return Object.keys(groups) + .map(Number) + .sort(function (a, b) { return b - a; }) + .map(function (level) { + const group = groups[level]; + return { + level: group.level, + label: group.label, + count: group.count, + advanced: group.advanced, + advance_rate: group.count ? group.advanced / group.count * 100 : 0, + positive_rate: group.count ? group.positive / group.count * 100 : 0, + average_change: group.count ? group.changeTotal / group.count : 0, + }; + }); + } + + function performanceConclusion() { + const overview = (state.dashboard && state.dashboard.overview) || {}; + const up = number(overview.up_count); + const down = number(overview.down_count); + const breadthRate = up + down > 0 ? up / (up + down) * 100 : 50; + const stance = breadthRate < 25 ? "宜守不宜攻" : breadthRate < 45 ? "控制仓位,聚焦核心" : "保持精选,跟随强势梯队"; + const phase = overview.sentiment_phase || "观察"; + return '
结论:' + escapeHtml(stance) + ",当前情绪周期「" + escapeHtml(phase) + "」。
"; + } + + /* ---------------------------------------------------------------- render */ + + function updateHeader(title, dateText) { + if (global.MobileRouter && global.MobileRouter.updateHeader) { + global.MobileRouter.updateHeader({ title: title, back: true, actions: dateButtonHtml(dateText) }); + } + } + + function dateButtonHtml(dateText) { + return '"; + } + + function sourceTabsHtml(cfg) { + if (!cfg.sources || !cfg.sources.length) return ""; + const labels = { combined: "双榜综合", ths: "同花顺", dc: "东方财富" }; + return '
' + + cfg.sources.map(function (source) { + const active = source === state.popularitySource; + return '"; + }).join("") + + "
"; + } + + function renderPage(key) { + const cfg = pageConfig(key); + if (!cfg) return; + state.key = key; + state.requestedDate = todayString(); + state.dashboard = null; + state.popularity = null; + state.popularitySource = "combined"; + state.calCursor = null; + + document.getElementById("m-view").classList.add("m-view-feature"); + const title = findLabel(key) || key; + updateHeader(title, state.requestedDate); + + const summary = buildSummary(key, cfg); + const sourceTabs = sourceTabsHtml(cfg); + const content = '
' + + summary + + sourceTabs + + '
' + skeletonHtml(8) + "
" + + "
"; + document.getElementById("m-view").innerHTML = content; + + load(); + } + + function findLabel(key) { + const hubs = global.MobileNav && global.MobileNav.hubs ? global.MobileNav.hubs : {}; + for (const hubKey in hubs) { + const items = hubs[hubKey].items || []; + for (const item of items) { + if (item.key === key) return item.label; + } + } + return key; + } + + function load() { + const key = state.key; + const cfg = pageConfig(key); + if (!cfg) return; + const seq = ++state.seq; + const requestedDate = state.requestedDate; + const url = key === "market/popularity" + ? "/api/popularity?trade_date=" + encodeURIComponent(requestedDate) + : "/api/dashboard?trade_date=" + encodeURIComponent(requestedDate); + + global.MobileAPI.request(url).then(function (payload) { + if (seq !== state.seq) return; + if (key === "market/popularity") { + state.popularity = payload; + } else { + state.dashboard = payload; + } + const meta = payload.meta || {}; + state.requestedDate = displayCompactDate(meta.requested_date || meta.trade_date || requestedDate); + const title = findLabel(key) || key; + updateHeader(title, state.requestedDate); + renderData(key, cfg); + }).catch(function (error) { + if (seq !== state.seq) return; + renderError(error && error.message ? error.message : "数据加载失败"); + }); + } + + function renderData(key, cfg) { + const cols = columnsFor(cfg); + const rows = prepareRows(key); + const scroll = document.getElementById("m-table-scroll"); + if (!scroll) return; + + if (!rows.length) { + scroll.innerHTML = emptyHtml(); + } else { + scroll.innerHTML = buildTable(cols, rows); + } + + const page = document.querySelector(".m-page"); + if (!page) return; + + let summary = buildSummary(key, cfg); + if (key === "market/performance" && summary) summary += performanceConclusion(); + + const existingSummary = page.querySelector(".m-summary"); + if (existingSummary) { + existingSummary.outerHTML = summary || ""; + } else if (summary) { + const tabs = page.querySelector(".m-source-tabs"); + const tmp = document.createElement("div"); + tmp.innerHTML = summary; + const node = tmp.firstChild; + page.insertBefore(node, tabs || scroll); + } + + scroll.classList.remove("m-motion-fade-in"); + void scroll.offsetWidth; + scroll.classList.add("m-motion-fade-in"); + } + + function renderError(message) { + const scroll = document.getElementById("m-table-scroll"); + if (scroll) scroll.innerHTML = errorHtml(message); + const page = document.querySelector(".m-page"); + if (page) { + const summary = page.querySelector(".m-summary"); + if (summary) summary.remove(); + } + } + + /* ---------------------------------------------------------------- sheets */ + + function ensureSheetRoot() { + let root = document.getElementById("m-sheet-root"); + if (!root) { + root = document.createElement("div"); + root.id = "m-sheet-root"; + root.className = "m-sheet-root"; + document.getElementById("m-app").appendChild(root); + } + return root; + } + + function openSheet(content, opts) { + const root = ensureSheetRoot(); + sheetToken += 1; + root.innerHTML = + '
' + + '"; + global.requestAnimationFrame(function () { root.classList.add("is-open"); }); + bindSheetDrag(root); + } + + function closeSheet() { + const root = document.getElementById("m-sheet-root"); + if (!root) return; + const token = sheetToken; + root.classList.remove("is-open"); + global.setTimeout(function () { + if (sheetToken === token && !root.classList.contains("is-open")) root.innerHTML = ""; + }, 340); + } + + function bindSheetDrag(root) { + const sheet = root.querySelector(".m-sheet"); + const handle = root.querySelector(".m-sheet-handle"); + if (!sheet || !handle) return; + let startY = 0; + let dragging = false; + handle.addEventListener("touchstart", function (event) { + dragging = true; + startY = event.touches[0].clientY; + sheet.style.transition = "none"; + }, { passive: true }); + handle.addEventListener("touchmove", function (event) { + if (!dragging) return; + const dy = event.touches[0].clientY - startY; + if (dy > 0) sheet.style.transform = "translateY(" + dy + "px)"; + }, { passive: true }); + handle.addEventListener("touchend", function (event) { + if (!dragging) return; + dragging = false; + sheet.style.transition = ""; + const dy = event.changedTouches[0].clientY - startY; + if (dy > sheet.offsetHeight / 3) { + closeSheet(); + } else { + sheet.style.transform = ""; + } + }); + } + + function selectDate(dateStr) { + if (!dateStr) return; + state.requestedDate = dateStr; + closeSheet(); + load(); + } + + function openDateSheet() { + state.calCursor = { + year: parseLocalDate(state.requestedDate).getFullYear(), + month: parseLocalDate(state.requestedDate).getMonth(), + }; + openSheet( + '
' + + '

选择日期

' + + '" + + "
" + + '
', + { detail: false } + ); + renderDateSheetBody(); + } + + function renderDateSheetBody() { + const body = document.getElementById("m-date-sheet-body"); + if (!body || !state.calCursor) return; + const year = state.calCursor.year; + const month = state.calCursor.month; + const today = todayString(); + const now = parseLocalDate(today); + const currentMonth = now.getFullYear() * 12 + now.getMonth(); + const cursorMonth = year * 12 + month; + const prevDisabled = cursorMonth <= currentMonth - 12; + const nextDisabled = cursorMonth >= currentMonth; + + const quick = [ + { label: "今天", date: todayString() }, + { label: "昨天", date: addDays(today, -1) }, + { label: "前一交易日", date: previousTradeDate() }, + ]; + + const quickHtml = quick.map(function (item) { + return '"; + }).join(""); + + body.innerHTML = + '
' + quickHtml + "
" + + '
' + + '" + + '' + year + " 年 " + (month + 1) + " 月" + + '" + + "
" + + '
' + ["一", "二", "三", "四", "五", "六", "日"].map(function (d) { return "" + d + ""; }).join("") + "
" + + '
' + calendarCells(year, month) + "
"; + } + + function calendarCells(year, month) { + const first = new Date(year, month, 1); + const startWeekday = (first.getDay() + 6) % 7; + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const today = todayString(); + const cells = []; + for (let i = 0; i < startWeekday; i += 1) cells.push(''); + for (let d = 1; d <= daysInMonth; d += 1) { + const date = new Date(year, month, d); + const dateStr = localDateString(date); + const dow = date.getDay(); + const disabled = dow === 0 || dow === 6 || dateStr > today; + const selected = dateStr === state.requestedDate; + cells.push( + '" + ); + } + return cells.join(""); + } + + function previousTradeDate() { + const dash = state.dashboard || {}; + if (dash.meta && dash.meta.previous_trade_date) return displayCompactDate(dash.meta.previous_trade_date); + if (state.popularity && state.popularity.meta && state.popularity.meta.previous_trade_date) { + return displayCompactDate(state.popularity.meta.previous_trade_date); + } + return previousWeekday(todayString()); + } + + function openDetailSheet(code) { + openSheet( + '
' + + '
' + escapeHtml(code) + '
' + + '" + + "
" + + '
' + + '
' + + '
' + + '
' + + '
' + + "
" + + "
", + { detail: true } + ); + loadDetail(code); + } + + function loadDetail(code) { + const token = sheetToken; + global.MobileAPI.request("/api/stock/" + encodeURIComponent(code) + "/preview").then(function (payload) { + if (token !== sheetToken) return; + renderDetail(payload); + }).catch(function (error) { + if (token !== sheetToken) return; + const body = document.getElementById("m-detail-sheet-body"); + if (body) body.innerHTML = errorHtml(error && error.message ? error.message : "行情预览加载失败"); + }); + } + + function renderDetail(payload) { + const body = document.getElementById("m-detail-sheet-body"); + if (!body) return; + const stock = payload.stock || {}; + const prices = payload.prices || []; + const meta = payload.meta || {}; + const name = stock.name && stock.name !== "--" ? stock.name : "--"; + const price = number(stock.price); + const change = number(stock.change); + const industry = stock.industry && stock.industry !== "其他" ? stock.industry : (stock.sector || "其他"); + + const spark = sparklineHtml(prices); + + body.innerHTML = + '
' + + '
' + escapeHtml(name) + '' + escapeHtml(stock.code || "") + "
" + + '
' + + '' + (price ? formatNumber(price, 2) : "--") + "" + + '' + (change > 0 ? "+" : "") + formatNumber(change, 2) + "%" + + "
" + + '
' + escapeHtml(industry) + " · " + escapeHtml(displayCompactDate(meta.trade_date) || "--") + "
" + + "
" + + spark + + '
' + escapeHtml(meta.notice || "数据来源:本地行情") + "
"; + } + + function sparklineHtml(prices) { + const closes = (prices || []).map(function (bar) { return number(bar.close); }).filter(function (v) { return v > 0; }); + if (closes.length < 2) return ""; + const min = Math.min.apply(null, closes); + const max = Math.max.apply(null, closes); + const range = max - min || 1; + const width = 320; + const height = 96; + const step = width / (closes.length - 1); + const points = closes.map(function (value, i) { + const x = Math.round(i * step); + const y = Math.round(height - (value - min) / range * height); + return x + "," + y; + }).join(" "); + const up = closes[closes.length - 1] >= closes[0]; + return '
"; + } + + /* ---------------------------------------------------------------- events */ + + function bindEvents() { + window.addEventListener("hashchange", closeSheet); + + document.addEventListener("click", function (event) { + const dateBtn = event.target.closest("[data-action=date]"); + if (dateBtn) { openDateSheet(); return; } + + const retry = event.target.closest("[data-action=retry]"); + if (retry) { load(); return; } + + const sourceTab = event.target.closest("[data-action=source]"); + if (sourceTab) { + state.popularitySource = sourceTab.dataset.source || "combined"; + const cfg = pageConfig(state.key); + if (cfg) { + const rows = prepareRows(state.key); + const scroll = document.getElementById("m-table-scroll"); + if (scroll) scroll.innerHTML = rows.length ? buildTable(columnsFor(cfg), rows) : emptyHtml(); + document.querySelectorAll(".m-source-tab").forEach(function (tab) { + const active = tab.dataset.source === state.popularitySource; + tab.classList.toggle("active", active); + tab.setAttribute("aria-selected", String(active)); + }); + } + return; + } + + const closeBtn = event.target.closest("[data-sheet-close]"); + if (closeBtn) { closeSheet(); return; } + + const backdrop = event.target.closest("[data-sheet-backdrop]"); + if (backdrop) { closeSheet(); return; } + + const quick = event.target.closest("[data-quick-date]"); + if (quick) { selectDate(quick.dataset.quickDate); return; } + + const calNav = event.target.closest("[data-cal-nav]"); + if (calNav) { + if (calNav.disabled) return; + const dir = calNav.dataset.calNav === "next" ? 1 : -1; + const monthIndex = state.calCursor.year * 12 + state.calCursor.month + dir; + state.calCursor = { year: Math.floor(monthIndex / 12), month: ((monthIndex % 12) + 12) % 12 }; + renderDateSheetBody(); + return; + } + + const calCell = event.target.closest("[data-date]"); + if (calCell) { + if (calCell.disabled) return; + selectDate(calCell.dataset.date); + return; + } + + const row = event.target.closest("[data-code]"); + if (row) { + const interactive = event.target.closest("button, a, input, select, textarea"); + if (!interactive) openDetailSheet(row.dataset.code); + } + }); + } + + /* ---------------------------------------------------------------- init */ + + bindEvents(); + + global.MobilePages = { + render: renderPage, + has: function (key) { return Boolean(pageConfig(key)); }, + }; +})(window); diff --git a/app/frontend/m/js/router.js b/app/frontend/m/js/router.js index 6f912c9..c838386 100644 --- a/app/frontend/m/js/router.js +++ b/app/frontend/m/js/router.js @@ -223,6 +223,10 @@ } function renderFeature(key) { + if (global.MobilePages && global.MobilePages.has(key)) { + global.MobilePages.render(key); + return; + } const title = findFeatureLabel(key) || key; updateHeader({ title: title, back: true }); document.getElementById("m-view").innerHTML = placeholderHtml(title, "该功能页将在后续批次实现。"); @@ -338,6 +342,7 @@ function render(motion) { const route = currentRoute(); + document.getElementById("m-view").classList.remove("m-view-feature"); if (route.name === "home") { replace(DEFAULT_HASH); return; @@ -412,6 +417,7 @@ replace: replace, back: back, render: render, - currentRoute: currentRoute + currentRoute: currentRoute, + updateHeader: updateHeader }; })(window);