feat(HEL-79): 手机端 P2a 表格引擎+六个池/榜列表页

- 新增 StickyHScrollTable:冻结表头/首列、横滑、pan-x 触控
- 涨停池/炸板池/跌停池/昨日涨停/涨停表现/人气榜接真数据(沿用电脑接口)
- 功能页顶栏返回+标题+日期按钮;底部日期抽屉(日历网格+快捷胶囊)
- 点名称/代码弹底部 DetailSheet(走 /api/stock/{code}/preview)
- 加载骨架/空态/错误态按动效规范;列配置进 nav.config
- 其余 6 个行情页仍为占位(归 P2b)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
施工员
2026-08-24 11:59:07 +08:00
co-authored by multica-agent
parent a92caa4f3f
commit 38f307d591
5 changed files with 1757 additions and 1 deletions
+150
View File
@@ -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" }
]
}
}
}
}
};
+728
View File
@@ -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;
}
}
+2
View File
@@ -7,6 +7,7 @@
<title>小白复盘</title>
<link rel="stylesheet" href="css/tokens.css">
<link rel="stylesheet" href="css/shell.css">
<link rel="stylesheet" href="css/features.css">
</head>
<body>
<div id="m-app" data-theme="light">
@@ -38,6 +39,7 @@
<script src="js/api.js"></script>
<script src="js/session.js"></script>
<script src="js/router.js"></script>
<script src="js/pages.js"></script>
<script src="js/boot.js"></script>
</body>
</html>
+870
View File
@@ -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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch];
});
}
function formatNumber(value, digits) {
return new Intl.NumberFormat("zh-CN", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(number(value));
}
function changeClass(value) {
const n = number(value);
return n > 0 ? "up" : n < 0 ? "down" : "";
}
function streakLabel(streak) {
const value = Math.max(1, number(streak));
return value === 1 ? "首板" : value + "板";
}
function displayCompactDate(value) {
const text = String(value || "").replaceAll("-", "");
if (text.length !== 8) return value || "--";
return text.slice(0, 4) + "-" + text.slice(4, 6) + "-" + text.slice(6, 8);
}
function localDateString(value) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return year + "-" + month + "-" + day;
}
function todayString() {
return localDateString(new Date());
}
function parseLocalDate(value) {
const parts = String(value || "").split("-").map(Number);
return new Date(parts[0], (parts[1] || 1) - 1, parts[2] || 1);
}
function addDays(value, delta) {
const date = parseLocalDate(value);
date.setDate(date.getDate() + delta);
return localDateString(date);
}
function previousWeekday(value) {
let date = parseLocalDate(value);
date.setDate(date.getDate() - 1);
while (date.getDay() === 0 || date.getDay() === 6) date.setDate(date.getDate() - 1);
return localDateString(date);
}
const ICONS = {
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
inbox: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
};
function icon(name, size) {
const body = ICONS[name] || "";
return '<svg width="' + (size || 16) + '" height="' + (size || 16) + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + body + "</svg>";
}
/* ---------------------------------------------------------------- state */
const state = {
key: "",
requestedDate: "",
dashboard: null,
popularity: null,
popularitySource: "combined",
seq: 0,
calCursor: null,
};
let sheetToken = 0;
function pageConfig(key) {
const cfg = global.MobileNav && global.MobileNav.tableColumns ? global.MobileNav.tableColumns[key] : null;
return cfg || null;
}
function columnsFor(cfg) {
if (cfg.columns) return cfg.columns[state.popularitySource] || cfg.columns.combined;
return cfg;
}
/* ---------------------------------------------------------------- cells */
function columnWidth(col) {
if (col.width) return col.width;
if (col.type === "rank") return 40;
if (col.type === "stock") return 96;
if (col.wide) return 140;
if (col.type === "text" || col.type === "concepts" || col.type === "outcome" || col.type === "dual") return 96;
return 72;
}
function colAlign(col) {
if (col.type === "rank") return "m-align-center";
if (["change", "price", "rate", "money", "gap", "int", "streak", "advance", "height", "move"].indexOf(col.type) >= 0) return "m-align-right";
return "m-align-left";
}
function stockCell(row) {
return '<span class="m-stock">' +
'<span class="m-stock-name">' + escapeHtml(row.name || "--") + "</span>" +
'<span class="m-stock-code">' + escapeHtml(row.code || "") + "</span>" +
"</span>";
}
function rankCell(row, index) {
const value = number(row.rank) > 0 ? number(row.rank) : index + 1;
const hot = index < 3 ? '<span class="m-rank-hot">热</span>' : "";
return '<span class="m-rank' + (index < 3 ? " is-top" : "") + '"><b>' + value + "</b>" + hot + "</span>";
}
function streakCell(value) {
const n = number(value);
if (n <= 0) return "";
const high = n >= 4 ? " is-high" : "";
return '<span class="m-pill' + high + '">' + streakLabel(n) + "</span>";
}
function numCell(value, digits, signed) {
if (value == null || value === "") return '<span class="m-num"></span>';
const n = number(value);
const sign = signed && n > 0 ? "+" : "";
return '<span class="m-num">' + sign + formatNumber(n, digits) + "</span>";
}
function changeCell(value) {
if (value == null || value === "") return '<span class="m-num"></span>';
const n = number(value);
return '<span class="m-num ' + changeClass(n) + '">' + (n > 0 ? "+" : "") + formatNumber(n, 2) + "</span>";
}
function intCell(value, hideZero) {
const n = Math.round(number(value));
if (hideZero && n === 0) return '<span class="m-num"></span>';
if (value == null || value === "") return '<span class="m-num"></span>';
return '<span class="m-num">' + n.toLocaleString("zh-CN") + "</span>";
}
function advanceCell(value) {
const n = number(value);
const cls = n === 0 ? "is-neutral" : n < 20 ? "is-warning" : "is-active";
return '<span class="m-num ' + cls + '">' + formatNumber(n, 1) + "</span>";
}
function outcomeCell(value) {
const map = { "晋级": "advance", "断板": "fail", "炸板": "broken", "跌停": "down" };
const cls = map[value] || "fail";
return '<span class="m-tag m-tag--' + cls + '">' + escapeHtml(value || "") + "</span>";
}
function heightCell(value) {
const n = number(value);
if (n <= 0) return "";
return '<span class="m-pill m-pill--plain">' + n + "</span>";
}
function moveCell(value) {
const n = value == null ? null : number(value);
if (n == null) return '<span class="m-num is-new">新</span>';
if (n > 0) return '<span class="m-num up">\u2191' + n + "</span>";
if (n < 0) return '<span class="m-num down">\u2193' + Math.abs(n) + "</span>";
return '<span class="m-num">持平</span>';
}
function conceptsCell(value) {
const list = Array.isArray(value) ? value : [];
const text = list.slice(0, 3).join("、");
return '<span class="m-cell-text" title="' + escapeHtml(list.join("、")) + '">' + escapeHtml(text) + "</span>";
}
function dualCell(value) {
const dual = Boolean(value);
return '<span class="m-tag ' + (dual ? "m-tag--dual" : "") + '">' + (dual ? "双榜共识" : "单榜入选") + "</span>";
}
function textCell(value) {
const text = String(value == null ? "" : value);
return '<span class="m-cell-text" title="' + escapeHtml(text) + '">' + escapeHtml(text) + "</span>";
}
function cellHtml(col, row, index) {
const value = row[col.key];
switch (col.type) {
case "stock": return stockCell(row);
case "rank": return rankCell(row, index);
case "streak": return streakCell(value);
case "change": return changeCell(value);
case "price": return numCell(value, 2);
case "rate": return numCell(value, 2);
case "money": return numCell(value, 2);
case "gap": return numCell(value, 2);
case "int": return intCell(value, col.hideZero);
case "advance": return advanceCell(value);
case "outcome": return outcomeCell(value);
case "height": return heightCell(value);
case "move": return moveCell(value);
case "concepts": return conceptsCell(value);
case "dual": return dualCell(value);
case "text":
default: return textCell(value);
}
}
/* ---------------------------------------------------------------- table */
function buildTable(cols, rows) {
const frozen = cols.frozenColumns || [];
const primary = cols.primaryColumns || [];
const scroll = cols.scrollColumns || [];
const ordered = frozen.concat(primary, scroll);
let left = 0;
const frozenLeft = frozen.map(function (col) {
const offset = left;
left += columnWidth(col);
return offset;
});
function cellOpen(col, index, isHead) {
const isFrozen = index < frozen.length;
const width = columnWidth(col);
const clamp = col.type === "text" || col.type === "concepts" ? ";max-width:" + width + "px" : "";
const style = "min-width:" + width + "px" + clamp + (isFrozen ? ";width:" + width + "px;left:" + frozenLeft[index] + "px" : "");
const cls = (isHead ? "m-th" : "m-td") + " " + colAlign(col) + (isFrozen ? " m-frozen" : "");
return '<' + (isHead ? "th" : "td") + ' class="' + cls + '" style="' + style + '">';
}
const head = ordered.map(function (col, i) {
return cellOpen(col, i, true) + escapeHtml(col.label) + (i < frozen.length ? "</th>" : "</th>");
}).join("");
const body = rows.map(function (row, rIndex) {
const cells = ordered.map(function (col, i) {
return cellOpen(col, i, false) + cellHtml(col, row, rIndex) + "</td>";
}).join("");
const code = /^\d{6}$/.test(String(row.code || "")) ? ' data-code="' + escapeHtml(row.code) + '"' : "";
return "<tr" + code + ">" + cells + "</tr>";
}).join("");
return '<table class="m-table"><thead><tr>' + head + "</tr></thead><tbody>" + body + "</tbody></table>";
}
function skeletonHtml(rowCount) {
const rows = [];
for (let i = 0; i < rowCount; i += 1) {
rows.push('<div class="m-skeleton-row"><span class="m-skeleton-bar"></span></div>');
}
return '<div class="m-skeleton" aria-hidden="true">' + rows.join("") + "</div>";
}
function emptyHtml() {
return '<div class="m-state m-motion-rise-in">' +
'<span class="m-state-icon">' + icon("inbox", 26) + "</span>" +
"<p>该交易日暂无相关数据</p>" +
'<small>可点右上角日期切换交易日</small>' +
"</div>";
}
function errorHtml(message) {
return '<div class="m-state m-state--error m-motion-fade-in">' +
'<p>' + escapeHtml(message || "数据加载失败") + "</p>" +
'<button class="m-btn-primary m-btn-retry" type="button" data-action="retry">重试</button>' +
"</div>";
}
/* ---------------------------------------------------------------- summary */
function summaryCard(label, value, tone) {
const cls = tone === "up" ? " is-up" : tone === "down" ? " is-down" : "";
return '<div class="m-summary-item' + cls + '"><span>' + escapeHtml(label) + "</span><strong>" + escapeHtml(value) + "</strong></div>";
}
function buildSummary(key, cfg) {
const dash = state.dashboard || {};
const overview = dash.overview || {};
const cards = [];
if (cfg.summary === "limit") {
const limits = dash.limits || [];
const maxStreak = limits.reduce(function (m, r) { return Math.max(m, number(r.streak)); }, 0);
const seal = overview.seal_rate != null ? formatNumber(overview.seal_rate, 1) + "%" : "--";
cards.push(summaryCard("涨停家数", limits.length, "up"));
cards.push(summaryCard("最高连板", maxStreak ? maxStreak + " 板" : "--"));
cards.push(summaryCard("封板率", seal));
} else if (cfg.summary === "broken") {
const broken = dash.broken || [];
const limits = dash.limits || [];
const seal = overview.seal_rate != null ? formatNumber(overview.seal_rate, 1) + "%" : "--";
cards.push(summaryCard("炸板家数", broken.length, "down"));
cards.push(summaryCard("涨停家数", overview.limit_up_count != null ? overview.limit_up_count : limits.length, "up"));
cards.push(summaryCard("封板率", seal));
} else if (cfg.summary === "down") {
const downs = dash.down_limits || [];
const maxStreak = downs.reduce(function (m, r) { return Math.max(m, number(r.streak)); }, 0);
const cluster = downSectorCluster(downs);
cards.push(summaryCard("跌停家数", downs.length, "down"));
cards.push(summaryCard("连续跌停", maxStreak ? maxStreak + " 天" : "--"));
cards.push(summaryCard("集中板块", cluster || "--"));
} else if (cfg.summary === "yesterday") {
const rows = dash.yesterday_limits || [];
const total = rows.length;
const advance = rows.filter(function (r) { return r.outcome === "晋级"; }).length;
const positive = rows.filter(function (r) { return number(r.current_change) > 0; }).length;
const risk = rows.filter(function (r) { return ["炸板", "跌停"].indexOf(r.outcome) >= 0; }).length;
const rate = function (v) { return total ? formatNumber(v / total * 100, 1) + "%" : "--"; };
cards.push(summaryCard("晋级率", rate(advance), "up"));
cards.push(summaryCard("兑现率", rate(positive), "up"));
cards.push(summaryCard("亏钱效应", rate(risk), "down"));
} else if (cfg.summary === "performance") {
const up = number(overview.up_count);
const down = number(overview.down_count);
const ratio = up + down > 0 ? formatNumber(up / (up + down) * 100, 1) + "%" : "--";
cards.push(summaryCard("上涨", up + " 家", "up"));
cards.push(summaryCard("下跌", down + " 家", "down"));
cards.push(summaryCard("红盘率", ratio, "up"));
} else {
return "";
}
if (!cards.length) return "";
return '<div class="m-summary">' + cards.join("") + "</div>";
}
function downSectorCluster(rows) {
const counts = {};
rows.forEach(function (row) {
const sector = String(row.sector || "其他").trim() || "其他";
if (sector === "其他") return;
counts[sector] = (counts[sector] || 0) + 1;
});
let cluster = null;
Object.keys(counts).forEach(function (sector) {
if (!cluster || counts[sector] > cluster.count) cluster = { sector: sector, count: counts[sector] };
});
return cluster && cluster.count >= 2 ? cluster.sector + " \u00d7" + cluster.count : "";
}
/* ---------------------------------------------------------------- rows */
function brokenLimitRate(row) {
const name = String(row.name || "").toUpperCase();
const code = String(row.code || "").replace(/\D/g, "");
if (name.indexOf("ST") >= 0) return 10;
if (/^(300|301|688|689)/.test(code)) return 20;
if (/^(4|8|92)/.test(code)) return 30;
return 10;
}
function prepareRows(key) {
const dash = state.dashboard || {};
if (key === "market/limit-up") return dash.limits || [];
if (key === "market/broken") {
return (dash.broken || []).map(function (row) {
row.limitGap = Math.max(0, brokenLimitRate(row) - number(row.change));
return row;
});
}
if (key === "market/limit-down") return dash.down_limits || [];
if (key === "market/yesterday") return dash.yesterday_limits || [];
if (key === "market/performance") return normalizePerformanceRows(dash.limit_performance || []);
if (key === "market/popularity") {
return state.popularity ? (state.popularity[state.popularitySource] || []) : [];
}
return [];
}
function normalizePerformanceRows(rows) {
const groups = {};
(rows || []).forEach(function (row) {
const level = Math.max(1, number(row.level));
const displayLevel = Math.min(level, 5);
const group = groups[displayLevel] || {
level: displayLevel,
label: displayLevel === 1 ? "昨日首板" : displayLevel === 5 ? "昨日5板+" : "昨日" + displayLevel + "板",
count: 0,
advanced: 0,
positive: 0,
changeTotal: 0,
};
const count = number(row.count);
group.count += count;
group.advanced += number(row.advanced);
group.positive += count * number(row.positive_rate) / 100;
group.changeTotal += count * number(row.average_change);
groups[displayLevel] = group;
});
return Object.keys(groups)
.map(Number)
.sort(function (a, b) { return b - a; })
.map(function (level) {
const group = groups[level];
return {
level: group.level,
label: group.label,
count: group.count,
advanced: group.advanced,
advance_rate: group.count ? group.advanced / group.count * 100 : 0,
positive_rate: group.count ? group.positive / group.count * 100 : 0,
average_change: group.count ? group.changeTotal / group.count : 0,
};
});
}
function performanceConclusion() {
const overview = (state.dashboard && state.dashboard.overview) || {};
const up = number(overview.up_count);
const down = number(overview.down_count);
const breadthRate = up + down > 0 ? up / (up + down) * 100 : 50;
const stance = breadthRate < 25 ? "宜守不宜攻" : breadthRate < 45 ? "控制仓位,聚焦核心" : "保持精选,跟随强势梯队";
const phase = overview.sentiment_phase || "观察";
return '<div class="m-conclusion">结论:<b>' + escapeHtml(stance) + "</b>,当前情绪周期「" + escapeHtml(phase) + "」。</div>";
}
/* ---------------------------------------------------------------- render */
function updateHeader(title, dateText) {
if (global.MobileRouter && global.MobileRouter.updateHeader) {
global.MobileRouter.updateHeader({ title: title, back: true, actions: dateButtonHtml(dateText) });
}
}
function dateButtonHtml(dateText) {
return '<button class="m-date-btn" type="button" data-action="date" aria-label="选择日期">' +
icon("calendar", 15) +
'<span class="m-date-btn-text">' + escapeHtml(dateText || state.requestedDate || todayString()) + "</span>" +
"</button>";
}
function sourceTabsHtml(cfg) {
if (!cfg.sources || !cfg.sources.length) return "";
const labels = { combined: "双榜综合", ths: "同花顺", dc: "东方财富" };
return '<div class="m-source-tabs" role="tablist" aria-label="热榜来源">' +
cfg.sources.map(function (source) {
const active = source === state.popularitySource;
return '<button class="m-source-tab' + (active ? " active" : "") + '" type="button" role="tab" aria-selected="' + (active ? "true" : "false") + '" data-action="source" data-source="' + source + '">' + escapeHtml(labels[source] || source) + "</button>";
}).join("") +
"</div>";
}
function renderPage(key) {
const cfg = pageConfig(key);
if (!cfg) return;
state.key = key;
state.requestedDate = todayString();
state.dashboard = null;
state.popularity = null;
state.popularitySource = "combined";
state.calCursor = null;
document.getElementById("m-view").classList.add("m-view-feature");
const title = findLabel(key) || key;
updateHeader(title, state.requestedDate);
const summary = buildSummary(key, cfg);
const sourceTabs = sourceTabsHtml(cfg);
const content = '<div class="m-page" data-page="' + escapeHtml(key) + '">' +
summary +
sourceTabs +
'<div class="m-table-scroll" id="m-table-scroll">' + skeletonHtml(8) + "</div>" +
"</div>";
document.getElementById("m-view").innerHTML = content;
load();
}
function findLabel(key) {
const hubs = global.MobileNav && global.MobileNav.hubs ? global.MobileNav.hubs : {};
for (const hubKey in hubs) {
const items = hubs[hubKey].items || [];
for (const item of items) {
if (item.key === key) return item.label;
}
}
return key;
}
function load() {
const key = state.key;
const cfg = pageConfig(key);
if (!cfg) return;
const seq = ++state.seq;
const requestedDate = state.requestedDate;
const url = key === "market/popularity"
? "/api/popularity?trade_date=" + encodeURIComponent(requestedDate)
: "/api/dashboard?trade_date=" + encodeURIComponent(requestedDate);
global.MobileAPI.request(url).then(function (payload) {
if (seq !== state.seq) return;
if (key === "market/popularity") {
state.popularity = payload;
} else {
state.dashboard = payload;
}
const meta = payload.meta || {};
state.requestedDate = displayCompactDate(meta.requested_date || meta.trade_date || requestedDate);
const title = findLabel(key) || key;
updateHeader(title, state.requestedDate);
renderData(key, cfg);
}).catch(function (error) {
if (seq !== state.seq) return;
renderError(error && error.message ? error.message : "数据加载失败");
});
}
function renderData(key, cfg) {
const cols = columnsFor(cfg);
const rows = prepareRows(key);
const scroll = document.getElementById("m-table-scroll");
if (!scroll) return;
if (!rows.length) {
scroll.innerHTML = emptyHtml();
} else {
scroll.innerHTML = buildTable(cols, rows);
}
const page = document.querySelector(".m-page");
if (!page) return;
let summary = buildSummary(key, cfg);
if (key === "market/performance" && summary) summary += performanceConclusion();
const existingSummary = page.querySelector(".m-summary");
if (existingSummary) {
existingSummary.outerHTML = summary || "";
} else if (summary) {
const tabs = page.querySelector(".m-source-tabs");
const tmp = document.createElement("div");
tmp.innerHTML = summary;
const node = tmp.firstChild;
page.insertBefore(node, tabs || scroll);
}
scroll.classList.remove("m-motion-fade-in");
void scroll.offsetWidth;
scroll.classList.add("m-motion-fade-in");
}
function renderError(message) {
const scroll = document.getElementById("m-table-scroll");
if (scroll) scroll.innerHTML = errorHtml(message);
const page = document.querySelector(".m-page");
if (page) {
const summary = page.querySelector(".m-summary");
if (summary) summary.remove();
}
}
/* ---------------------------------------------------------------- sheets */
function ensureSheetRoot() {
let root = document.getElementById("m-sheet-root");
if (!root) {
root = document.createElement("div");
root.id = "m-sheet-root";
root.className = "m-sheet-root";
document.getElementById("m-app").appendChild(root);
}
return root;
}
function openSheet(content, opts) {
const root = ensureSheetRoot();
sheetToken += 1;
root.innerHTML =
'<div class="m-sheet-backdrop" data-sheet-backdrop></div>' +
'<div class="m-sheet' + (opts && opts.detail ? " m-sheet--detail" : "") + '" role="dialog" aria-modal="true">' +
'<div class="m-sheet-handle" aria-hidden="true"></div>' +
content +
"</div>";
global.requestAnimationFrame(function () { root.classList.add("is-open"); });
bindSheetDrag(root);
}
function closeSheet() {
const root = document.getElementById("m-sheet-root");
if (!root) return;
const token = sheetToken;
root.classList.remove("is-open");
global.setTimeout(function () {
if (sheetToken === token && !root.classList.contains("is-open")) root.innerHTML = "";
}, 340);
}
function bindSheetDrag(root) {
const sheet = root.querySelector(".m-sheet");
const handle = root.querySelector(".m-sheet-handle");
if (!sheet || !handle) return;
let startY = 0;
let dragging = false;
handle.addEventListener("touchstart", function (event) {
dragging = true;
startY = event.touches[0].clientY;
sheet.style.transition = "none";
}, { passive: true });
handle.addEventListener("touchmove", function (event) {
if (!dragging) return;
const dy = event.touches[0].clientY - startY;
if (dy > 0) sheet.style.transform = "translateY(" + dy + "px)";
}, { passive: true });
handle.addEventListener("touchend", function (event) {
if (!dragging) return;
dragging = false;
sheet.style.transition = "";
const dy = event.changedTouches[0].clientY - startY;
if (dy > sheet.offsetHeight / 3) {
closeSheet();
} else {
sheet.style.transform = "";
}
});
}
function selectDate(dateStr) {
if (!dateStr) return;
state.requestedDate = dateStr;
closeSheet();
load();
}
function openDateSheet() {
state.calCursor = {
year: parseLocalDate(state.requestedDate).getFullYear(),
month: parseLocalDate(state.requestedDate).getMonth(),
};
openSheet(
'<div class="m-sheet-head">' +
'<h2>选择日期</h2>' +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button>" +
"</div>" +
'<div class="m-sheet-body" id="m-date-sheet-body"></div>',
{ detail: false }
);
renderDateSheetBody();
}
function renderDateSheetBody() {
const body = document.getElementById("m-date-sheet-body");
if (!body || !state.calCursor) return;
const year = state.calCursor.year;
const month = state.calCursor.month;
const today = todayString();
const now = parseLocalDate(today);
const currentMonth = now.getFullYear() * 12 + now.getMonth();
const cursorMonth = year * 12 + month;
const prevDisabled = cursorMonth <= currentMonth - 12;
const nextDisabled = cursorMonth >= currentMonth;
const quick = [
{ label: "今天", date: todayString() },
{ label: "昨天", date: addDays(today, -1) },
{ label: "前一交易日", date: previousTradeDate() },
];
const quickHtml = quick.map(function (item) {
return '<button class="m-quick" type="button" data-quick-date="' + escapeHtml(item.date) + '">' + escapeHtml(item.label) + "</button>";
}).join("");
body.innerHTML =
'<div class="m-quick-row">' + quickHtml + "</div>" +
'<div class="m-cal-head">' +
'<button class="m-cal-nav" type="button" data-cal-nav="prev"' + (prevDisabled ? " disabled" : "") + " aria-label=\"上个月\">" + icon("chevron-left", 18) + "</button>" +
'<span class="m-cal-title">' + year + " 年 " + (month + 1) + " 月</span>" +
'<button class="m-cal-nav" type="button" data-cal-nav="next"' + (nextDisabled ? " disabled" : "") + " aria-label=\"下个月\">" + icon("chevron-right", 18) + "</button>" +
"</div>" +
'<div class="m-cal-week">' + ["一", "二", "三", "四", "五", "六", "日"].map(function (d) { return "<span>" + d + "</span>"; }).join("") + "</div>" +
'<div class="m-cal-grid">' + calendarCells(year, month) + "</div>";
}
function calendarCells(year, month) {
const first = new Date(year, month, 1);
const startWeekday = (first.getDay() + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const today = todayString();
const cells = [];
for (let i = 0; i < startWeekday; i += 1) cells.push('<span class="m-cal-cell is-empty"></span>');
for (let d = 1; d <= daysInMonth; d += 1) {
const date = new Date(year, month, d);
const dateStr = localDateString(date);
const dow = date.getDay();
const disabled = dow === 0 || dow === 6 || dateStr > today;
const selected = dateStr === state.requestedDate;
cells.push(
'<button class="m-cal-cell' + (selected ? " is-selected" : "") + '" type="button" data-date="' + dateStr + '"' + (disabled ? " disabled" : "") + ">" + d + "</button>"
);
}
return cells.join("");
}
function previousTradeDate() {
const dash = state.dashboard || {};
if (dash.meta && dash.meta.previous_trade_date) return displayCompactDate(dash.meta.previous_trade_date);
if (state.popularity && state.popularity.meta && state.popularity.meta.previous_trade_date) {
return displayCompactDate(state.popularity.meta.previous_trade_date);
}
return previousWeekday(todayString());
}
function openDetailSheet(code) {
openSheet(
'<div class="m-sheet-head">' +
'<div class="m-sheet-head-title"><span class="m-sheet-head-code">' + escapeHtml(code) + '</span></div>' +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button>" +
"</div>" +
'<div class="m-sheet-body" id="m-detail-sheet-body">' +
'<div class="m-skeleton">' +
'<div class="m-skeleton-row"><span class="m-skeleton-bar"></span></div>' +
'<div class="m-skeleton-row"><span class="m-skeleton-bar"></span></div>' +
'<div class="m-skeleton-row"><span class="m-skeleton-bar"></span></div>' +
"</div>" +
"</div>",
{ detail: true }
);
loadDetail(code);
}
function loadDetail(code) {
const token = sheetToken;
global.MobileAPI.request("/api/stock/" + encodeURIComponent(code) + "/preview").then(function (payload) {
if (token !== sheetToken) return;
renderDetail(payload);
}).catch(function (error) {
if (token !== sheetToken) return;
const body = document.getElementById("m-detail-sheet-body");
if (body) body.innerHTML = errorHtml(error && error.message ? error.message : "行情预览加载失败");
});
}
function renderDetail(payload) {
const body = document.getElementById("m-detail-sheet-body");
if (!body) return;
const stock = payload.stock || {};
const prices = payload.prices || [];
const meta = payload.meta || {};
const name = stock.name && stock.name !== "--" ? stock.name : "--";
const price = number(stock.price);
const change = number(stock.change);
const industry = stock.industry && stock.industry !== "其他" ? stock.industry : (stock.sector || "其他");
const spark = sparklineHtml(prices);
body.innerHTML =
'<div class="m-detail-top">' +
'<div class="m-detail-name">' + escapeHtml(name) + '<span class="m-detail-code">' + escapeHtml(stock.code || "") + "</span></div>" +
'<div class="m-detail-quote">' +
'<strong class="m-detail-price ' + changeClass(change) + '">' + (price ? formatNumber(price, 2) : "--") + "</strong>" +
'<span class="m-detail-change ' + changeClass(change) + '">' + (change > 0 ? "+" : "") + formatNumber(change, 2) + "%</span>" +
"</div>" +
'<div class="m-detail-meta">' + escapeHtml(industry) + " · " + escapeHtml(displayCompactDate(meta.trade_date) || "--") + "</div>" +
"</div>" +
spark +
'<div class="m-detail-note">' + escapeHtml(meta.notice || "数据来源:本地行情") + "</div>";
}
function sparklineHtml(prices) {
const closes = (prices || []).map(function (bar) { return number(bar.close); }).filter(function (v) { return v > 0; });
if (closes.length < 2) return "";
const min = Math.min.apply(null, closes);
const max = Math.max.apply(null, closes);
const range = max - min || 1;
const width = 320;
const height = 96;
const step = width / (closes.length - 1);
const points = closes.map(function (value, i) {
const x = Math.round(i * step);
const y = Math.round(height - (value - min) / range * height);
return x + "," + y;
}).join(" ");
const up = closes[closes.length - 1] >= closes[0];
return '<div class="m-detail-chart"><svg viewBox="0 0 ' + width + " " + height + '" preserveAspectRatio="none" aria-hidden="true">' +
'<polyline fill="none" style="stroke:' + (up ? "var(--market-up)" : "var(--market-down)") + '" stroke-width="1.5" points="' + points + '"/>' +
"</svg></div>";
}
/* ---------------------------------------------------------------- events */
function bindEvents() {
window.addEventListener("hashchange", closeSheet);
document.addEventListener("click", function (event) {
const dateBtn = event.target.closest("[data-action=date]");
if (dateBtn) { openDateSheet(); return; }
const retry = event.target.closest("[data-action=retry]");
if (retry) { load(); return; }
const sourceTab = event.target.closest("[data-action=source]");
if (sourceTab) {
state.popularitySource = sourceTab.dataset.source || "combined";
const cfg = pageConfig(state.key);
if (cfg) {
const rows = prepareRows(state.key);
const scroll = document.getElementById("m-table-scroll");
if (scroll) scroll.innerHTML = rows.length ? buildTable(columnsFor(cfg), rows) : emptyHtml();
document.querySelectorAll(".m-source-tab").forEach(function (tab) {
const active = tab.dataset.source === state.popularitySource;
tab.classList.toggle("active", active);
tab.setAttribute("aria-selected", String(active));
});
}
return;
}
const closeBtn = event.target.closest("[data-sheet-close]");
if (closeBtn) { closeSheet(); return; }
const backdrop = event.target.closest("[data-sheet-backdrop]");
if (backdrop) { closeSheet(); return; }
const quick = event.target.closest("[data-quick-date]");
if (quick) { selectDate(quick.dataset.quickDate); return; }
const calNav = event.target.closest("[data-cal-nav]");
if (calNav) {
if (calNav.disabled) return;
const dir = calNav.dataset.calNav === "next" ? 1 : -1;
const monthIndex = state.calCursor.year * 12 + state.calCursor.month + dir;
state.calCursor = { year: Math.floor(monthIndex / 12), month: ((monthIndex % 12) + 12) % 12 };
renderDateSheetBody();
return;
}
const calCell = event.target.closest("[data-date]");
if (calCell) {
if (calCell.disabled) return;
selectDate(calCell.dataset.date);
return;
}
const row = event.target.closest("[data-code]");
if (row) {
const interactive = event.target.closest("button, a, input, select, textarea");
if (!interactive) openDetailSheet(row.dataset.code);
}
});
}
/* ---------------------------------------------------------------- init */
bindEvents();
global.MobilePages = {
render: renderPage,
has: function (key) { return Boolean(pageConfig(key)); },
};
})(window);
+7 -1
View File
@@ -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);