feat(HEL-84): 手机端列表排序 + 详情页双图/自选 + 顶栏七项数据栏

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
施工员
2026-08-24 17:05:58 +08:00
co-authored by multica-agent
parent 22c0e1d85c
commit 322328aa7c
2 changed files with 840 additions and 189 deletions
+352 -52
View File
@@ -46,44 +46,108 @@
font-variant-numeric: tabular-nums;
}
/* 结论条 */
.m-summary {
display: flex;
gap: 8px;
padding: 12px 12px 8px;
overflow-x: auto;
/* 顶栏七项单行数据栏 */
.m-top {
flex: 0 0 auto;
-webkit-overflow-scrolling: touch;
}
.m-summary-item {
flex: 0 0 auto;
min-width: 96px;
.m-strip {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
align-items: stretch;
margin: 10px 12px;
height: 54px;
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);
overflow: hidden;
}
.m-summary-item span {
font-size: var(--font-size-caption);
.m-strip-cell {
position: relative;
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
padding: 0 2px;
}
.m-strip-cell + .m-strip-cell::before {
content: "";
position: absolute;
left: 0;
top: 13px;
bottom: 13px;
width: 1px;
background: var(--border);
}
.m-strip-label {
font-size: 10px;
font-weight: 400;
line-height: 1;
color: var(--text-secondary);
white-space: nowrap;
}
.m-summary-item strong {
font-size: var(--font-size-metric);
.m-strip-value {
display: inline-flex;
align-items: baseline;
gap: 2px;
font-size: 12px;
font-weight: 600;
line-height: 1.15;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
white-space: nowrap;
}
.m-summary-item.is-up strong { color: var(--market-up); }
.m-summary-item.is-down strong { color: var(--market-down); }
.m-strip-value b { font-weight: 600; }
.m-strip-phase {
font-size: 9px;
font-weight: 400;
color: var(--text-tertiary);
}
.m-strip-unit {
font-size: 9.5px;
font-weight: 400;
color: var(--text-tertiary);
}
.m-strip-up .m-strip-value { color: var(--market-up); }
.m-strip-down .m-strip-value { color: var(--market-down); }
.m-strip-warn .m-strip-value { color: var(--warning); }
.m-strip-sm .m-strip-value { font-size: 11px; }
.m-strip-sm .m-strip-unit { font-size: 8.5px; }
.m-strip--skeleton .m-strip-cell { justify-content: center; }
.m-strip-skeleton-bar {
width: 60%;
height: 20px;
border-radius: 4px;
background: var(--surface-muted);
animation: m-skeleton-breathe 1200ms ease-in-out infinite;
}
@media (max-width: 359px) {
.m-strip {
margin-left: 10px;
margin-right: 10px;
}
.m-strip-label { font-size: 9.5px; }
.m-strip-value { font-size: 10px; }
.m-strip-phase { font-size: 8px; }
.m-strip-unit { font-size: 9px; }
.m-strip-sm .m-strip-value { font-size: 9px; }
.m-strip-sm .m-strip-unit { font-size: 8px; }
}
.m-conclusion {
padding: 4px 12px 0;
@@ -204,6 +268,49 @@
.m-align-left { text-align: left; }
.m-align-center { text-align: center; }
/* 列头排序 */
.m-th-inner {
display: inline-flex;
align-items: center;
gap: 3px;
max-width: 100%;
}
.m-th.m-sortable {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.m-th.m-sortable:active {
background: var(--surface-hover);
}
.m-sort {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
line-height: 0;
color: var(--text-tertiary);
opacity: 0.38;
}
.m-sort svg {
display: block;
}
.m-th.is-sorted .m-th-inner {
color: var(--action);
font-weight: 600;
}
.m-th.is-sorted .m-sort {
color: var(--action);
opacity: 1;
}
/* 单元格内容 */
.m-stock {
display: flex;
@@ -474,19 +581,6 @@
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;
@@ -636,6 +730,68 @@
}
/* 详情 Sheet */
.m-detail-head-left {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
gap: 8px;
overflow: hidden;
}
.m-detail-head-name {
font-size: var(--font-size-page-title);
font-weight: 600;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.m-detail-head-code {
font-size: var(--font-size-label);
color: var(--text-tertiary);
font-weight: 400;
font-variant-numeric: tabular-nums;
}
.m-detail-head-right {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
}
.m-detail-star {
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-tertiary);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--motion-press-release) var(--ease-press),
color var(--motion-pop) var(--ease-press),
background-color var(--motion-press-release) var(--ease-press);
}
.m-detail-star:active {
background: var(--surface-hover);
transform: scale(0.94);
transition-duration: var(--motion-press);
}
.m-detail-star.is-added {
color: var(--warning);
}
.m-detail-star:disabled {
opacity: 0.5;
cursor: default;
}
.m-detail-top {
display: flex;
flex-direction: column;
@@ -644,21 +800,6 @@
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;
@@ -688,9 +829,54 @@
color: var(--text-secondary);
}
.m-detail-tabs {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 0 8px;
}
.m-detail-tab {
height: 32px;
padding: 0 14px;
border: 1px solid var(--border-strong);
border-radius: 999px;
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-detail-tab:active {
background: var(--surface-hover);
transform: scale(0.96);
transition-duration: var(--motion-press);
}
.m-detail-tab.active {
background: var(--action-soft);
border-color: var(--action);
color: var(--action);
font-weight: 600;
}
.m-detail-chart-caption {
margin-left: auto;
font-size: var(--font-size-aux);
color: var(--text-tertiary);
text-align: right;
}
.m-detail-chart {
margin-top: 12px;
height: 96px;
position: relative;
height: var(--mobile-chart-height);
border-radius: 8px;
background: var(--surface-subtle);
overflow: hidden;
}
.m-detail-chart svg {
@@ -705,6 +891,118 @@
color: var(--text-tertiary);
}
/* 行情图 SVG */
.m-chart-empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-label);
color: var(--text-tertiary);
}
.m-chart-line {
fill: none;
}
.m-chart-price {
stroke: var(--action);
stroke-width: 1.5;
}
.m-chart-avg {
stroke: var(--warning);
stroke-width: 1;
}
.m-chart-prev-close {
stroke: var(--text-tertiary);
stroke-width: 1;
stroke-dasharray: 4 3;
opacity: 0.6;
}
.m-chart-axis {
font-size: 10px;
fill: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
.m-chart-axis.is-up { fill: var(--market-up); }
.m-chart-axis.is-down { fill: var(--market-down); }
.m-chart-candle {
stroke-width: 1;
}
.m-chart-candle.is-up {
stroke: var(--chart-up);
fill: var(--chart-up);
}
.m-chart-candle.is-down {
stroke: var(--chart-down);
fill: var(--chart-down);
}
.m-chart-ma {
fill: none;
stroke-width: 1.2;
}
.m-chart-ma.is-ma5 { stroke: var(--warning); }
.m-chart-ma.is-ma10 { stroke: var(--action); }
.m-chart-legend {
position: absolute;
top: 6px;
left: 10px;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 10px;
color: var(--text-secondary);
pointer-events: none;
}
.m-chart-legend-ma5::before,
.m-chart-legend-ma10::before {
content: "";
display: inline-block;
width: 10px;
height: 2px;
margin-right: 3px;
border-radius: 1px;
vertical-align: middle;
}
.m-chart-legend-ma5::before { background: var(--warning); }
.m-chart-legend-ma10::before { background: var(--action); }
/* 自选提示条 */
.m-toast {
position: absolute;
left: 50%;
bottom: 72px;
transform: translateX(-50%) translateY(8px);
padding: 10px 16px;
border-radius: 8px;
background: var(--text-primary);
color: var(--canvas);
font-size: var(--font-size-label);
opacity: 0;
pointer-events: none;
white-space: nowrap;
z-index: 130;
transition: opacity var(--motion-fade) var(--ease-press),
transform var(--motion-fade) var(--ease-press);
}
.m-toast.is-visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* 减少动画降级 */
@media (prefers-reduced-motion: reduce) {
.m-sheet {
@@ -729,7 +1027,9 @@
.m-source-tab:active,
.m-quick:active,
.m-cal-cell:active,
.m-sheet-close:active {
.m-sheet-close:active,
.m-detail-tab:active,
.m-detail-star:active {
transform: none;
}
}
+488 -137
View File
@@ -79,6 +79,25 @@
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>";
}
const TRIANGLE_UP = '<svg width="6" height="6" viewBox="0 0 8 8" aria-hidden="true"><path d="M4 1.6 6.9 6.4H1.1Z" fill="currentColor"/></svg>';
const TRIANGLE_DOWN = '<svg width="6" height="6" viewBox="0 0 8 8" aria-hidden="true"><path d="M4 6.4 1.1 1.6h5.8Z" fill="currentColor"/></svg>';
function sortIndicatorHtml(active) {
if (active) {
const up = state.sort.dir === "asc";
return '<span class="m-sort" aria-hidden="true">' + (up ? TRIANGLE_UP : TRIANGLE_DOWN) + "</span>";
}
return '<span class="m-sort m-sort--dual" aria-hidden="true">' + TRIANGLE_UP + TRIANGLE_DOWN + "</span>";
}
function starIcon(filled) {
const d = "M12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2";
if (filled) {
return '<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"><path d="' + d + '" fill="currentColor" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>';
}
return '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true"><path d="' + d + '"/></svg>';
}
/* ---------------------------------------------------------------- state */
const state = {
@@ -89,6 +108,8 @@
popularitySource: "combined",
seq: 0,
calCursor: null,
sort: { key: "", dir: null },
detail: null,
};
let sheetToken = 0;
@@ -103,6 +124,72 @@
return cfg;
}
function orderedColumns(cols) {
return (cols.frozenColumns || []).concat(cols.primaryColumns || [], cols.scrollColumns || []);
}
function findColumn(cols, key) {
const ordered = orderedColumns(cols);
for (let i = 0; i < ordered.length; i += 1) {
if (ordered[i].key === key) return ordered[i];
}
return null;
}
function isNumericType(type) {
return ["rank", "change", "price", "rate", "money", "gap", "int", "streak", "advance", "height", "move"].indexOf(type) >= 0;
}
function sortableColumn(col) {
return col.type !== "stock";
}
function resetSortIfInvalid(cols) {
const active = state.sort.key;
if (active && !findColumn(cols, active)) {
state.sort = { key: "", dir: null };
}
}
function sortedRows(rows, cols) {
resetSortIfInvalid(cols);
const key = state.sort.key;
const dir = state.sort.dir;
if (!key || !dir) return rows;
const col = findColumn(cols, key);
if (!col || !sortableColumn(col)) return rows;
const numeric = isNumericType(col.type);
const factor = dir === "asc" ? 1 : -1;
return rows.slice().sort(function (a, b) {
if (numeric) {
const av = number(a[key]);
const bv = number(b[key]);
if (av === bv) return 0;
return (av - bv) * factor;
}
const av = String(a[key] == null ? "" : a[key]);
const bv = String(b[key] == null ? "" : b[key]);
return av.localeCompare(bv, "zh-CN") * factor;
});
}
function toggleSort(key) {
const cfg = pageConfig(state.key);
if (!cfg) return;
const cols = columnsFor(cfg);
const col = findColumn(cols, key);
if (!col || !sortableColumn(col)) return;
const firstDir = isNumericType(col.type) ? "desc" : "asc";
if (state.sort.key !== key) {
state.sort = { key: key, dir: firstDir };
} else if (state.sort.dir === firstDir) {
state.sort = { key: key, dir: firstDir === "desc" ? "asc" : "desc" };
} else {
state.sort = { key: "", dir: null };
}
renderTableBody();
}
/* ---------------------------------------------------------------- cells */
function columnWidth(col) {
@@ -240,17 +327,27 @@
return offset;
});
function cellOpen(col, index, isHead) {
function cellOpen(col, index, isHead, extraCls, extraAttrs) {
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 cls = (isHead ? "m-th" : "m-td") + " " + colAlign(col) + (isFrozen ? " m-frozen" : "") + (extraCls ? " " + extraCls : "");
return '<' + (isHead ? "th" : "td") + ' class="' + cls + '" style="' + style + '"' + (extraAttrs || "") + '>';
}
const head = ordered.map(function (col, i) {
return cellOpen(col, i, true) + escapeHtml(col.label) + (i < frozen.length ? "</th>" : "</th>");
const sortable = sortableColumn(col);
let extraCls = "";
let extraAttrs = "";
let indicator = "";
if (sortable) {
const active = state.sort.key === col.key && Boolean(state.sort.dir);
extraCls = " m-sortable" + (active ? " is-sorted" : "");
extraAttrs = ' data-sort-key="' + escapeHtml(col.key) + '" aria-sort="' + (active ? (state.sort.dir === "asc" ? "ascending" : "descending") : "none") + '"';
indicator = sortIndicatorHtml(active);
}
return cellOpen(col, i, true, extraCls, extraAttrs) + '<span class="m-th-inner">' + escapeHtml(col.label) + indicator + "</span></th>";
}).join("");
const body = rows.map(function (row, rIndex) {
@@ -287,76 +384,62 @@
"</div>";
}
/* ---------------------------------------------------------------- summary */
/* ---------------------------------------------------------------- strip */
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 "";
function stripSkeleton() {
const cells = [];
for (let i = 0; i < 7; i += 1) {
cells.push('<div class="m-strip-cell"><span class="m-strip-skeleton-bar"></span></div>');
}
if (!cards.length) return "";
return '<div class="m-summary">' + cards.join("") + "</div>";
return '<div class="m-strip m-strip--skeleton" aria-hidden="true">' + cells.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 : "";
function amountLabel(amount) {
if (amount == null || amount === "") return { value: "--", unit: "", compact: false };
const n = number(amount);
if (n >= 10000) {
return { value: formatNumber(n / 10000, 2), unit: "万亿", compact: n >= 100000 };
}
return { value: Math.round(n).toLocaleString("zh-CN"), unit: "亿", compact: false };
}
function stripCell(label, valueHtml, tone, extraCls) {
return '<div class="m-strip-cell' + (tone ? " m-strip-" + tone : "") + (extraCls ? " " + extraCls : "") + '">' +
'<span class="m-strip-label">' + escapeHtml(label) + "</span>" +
'<span class="m-strip-value">' + valueHtml + "</span></div>";
}
function intOrDash(value) {
return value == null ? "--" : Math.round(number(value)).toLocaleString("zh-CN");
}
function buildStrip() {
const dash = state.dashboard || {};
if (!state.dashboard) return stripSkeleton();
const overview = dash.overview || {};
const limits = dash.limits || [];
const maxStreak = limits.reduce(function (m, r) { return Math.max(m, number(r.streak)); }, 0);
const score = overview.sentiment_score != null ? Math.round(number(overview.sentiment_score)) : null;
const phase = overview.sentiment_phase || "";
const emotionHtml = (score == null ? "--" : "<b>" + score + "</b>") +
(phase ? '<i class="m-strip-phase">' + escapeHtml(phase) + "</i>" : "");
const amount = amountLabel(overview.amount_billion);
const amountHtml = "<b>" + escapeHtml(amount.value) + "</b>" +
(amount.unit ? '<i class="m-strip-unit">' + escapeHtml(amount.unit) + "</i>" : "");
const seal = overview.seal_rate != null ? formatNumber(overview.seal_rate, 1) + "%" : "--";
const cells = [];
cells.push(stripCell("情绪", emotionHtml, "", ""));
cells.push(stripCell("涨停", "<b>" + intOrDash(overview.limit_up_count) + "</b>", "up", ""));
cells.push(stripCell("跌停", "<b>" + intOrDash(overview.limit_down_count) + "</b>", "down", ""));
cells.push(stripCell("炸板", "<b>" + intOrDash(overview.broken_count) + "</b>", "warn", ""));
cells.push(stripCell("封板率", "<b>" + seal + "</b>", "", ""));
cells.push(stripCell("成交额", amountHtml, "", amount.compact ? "m-strip-sm" : ""));
cells.push(stripCell("最高连板", "<b>" + (maxStreak > 0 ? maxStreak : "--") + "</b>", "up", ""));
return '<div class="m-strip">' + cells.join("") + "</div>";
}
/* ---------------------------------------------------------------- rows */
@@ -470,15 +553,16 @@
state.popularity = null;
state.popularitySource = "combined";
state.calCursor = null;
state.sort = { key: "", dir: null };
state.detail = 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 +
'<div class="m-top">' + buildStrip() + "</div>" +
sourceTabs +
'<div class="m-table-scroll" id="m-table-scroll">' + skeletonHtml(8) + "</div>" +
"</div>";
@@ -504,18 +588,26 @@
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);
const popularity = key === "market/popularity";
global.MobileAPI.request(url).then(function (payload) {
const dashboardUrl = "/api/dashboard?trade_date=" + encodeURIComponent(requestedDate);
const popUrl = "/api/popularity?trade_date=" + encodeURIComponent(requestedDate);
const request = popularity
? Promise.all([global.MobileAPI.request(popUrl), global.MobileAPI.request(dashboardUrl)])
: global.MobileAPI.request(dashboardUrl).then(function (payload) { return [payload]; });
request.then(function (results) {
if (seq !== state.seq) return;
if (key === "market/popularity") {
state.popularity = payload;
let meta = {};
if (popularity) {
state.popularity = results[0];
state.dashboard = results[1];
meta = (results[0] && results[0].meta) || {};
} else {
state.dashboard = payload;
state.dashboard = results[0];
meta = (results[0] && results[0].meta) || {};
}
const meta = payload.meta || {};
state.requestedDate = displayCompactDate(meta.requested_date || meta.trade_date || requestedDate);
const title = findLabel(key) || key;
updateHeader(title, state.requestedDate);
@@ -527,8 +619,16 @@
}
function renderData(key, cfg) {
renderTableBody();
renderTopArea(key);
}
function renderTableBody() {
const key = state.key;
const cfg = pageConfig(key);
if (!cfg) return;
const cols = columnsFor(cfg);
const rows = prepareRows(key);
const rows = sortedRows(prepareRows(key), cols);
const scroll = document.getElementById("m-table-scroll");
if (!scroll) return;
@@ -538,35 +638,40 @@
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 renderTopArea(key) {
const page = document.querySelector(".m-page");
if (!page) return;
let top = page.querySelector(".m-top");
let html = buildStrip();
if (key === "market/performance") html += performanceConclusion();
if (!top) {
top = document.createElement("div");
top.className = "m-top";
const tabs = page.querySelector(".m-source-tabs");
const scroll = document.getElementById("m-table-scroll");
page.insertBefore(top, tabs || scroll);
}
top.innerHTML = html;
const strip = top.querySelector(".m-strip");
if (strip) {
strip.classList.remove("m-motion-fade-in");
void strip.offsetWidth;
strip.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();
const top = page.querySelector(".m-top");
if (top) top.remove();
}
}
@@ -722,10 +827,15 @@
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 class="m-sheet-head m-detail-head">' +
'<div class="m-detail-head-left">' +
'<span class="m-detail-head-name">--</span>' +
'<span class="m-detail-head-code">' + escapeHtml(code) + "</span>" +
"</div>" +
'<div class="m-detail-head-right">' +
'<button class="m-detail-star" type="button" data-action="watch" aria-label="加入自选" disabled>' + starIcon(false) + "</button>" +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button>" +
"</div></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>' +
@@ -750,50 +860,287 @@
});
}
function chartCaptionHtml(tab) {
const detail = state.detail;
const payload = detail && detail.payload ? detail.payload : {};
const meta = payload.meta || {};
if (tab === "daily") {
const bars = (payload.prices || []).slice(-48);
const last = bars.length ? bars[bars.length - 1].trade_date : "";
return "日线 · 近48根 · 至 " + (displayCompactDate(last) || "--");
}
const d = displayCompactDate(meta.intraday_trade_date) || displayCompactDate(meta.trade_date);
return "分时 · " + (d || "--");
}
function switchChartTab(tab) {
const detail = state.detail;
if (!detail || !detail.payload) return;
detail.tab = tab;
const payload = detail.payload;
const meta = payload.meta || {};
const chart = document.getElementById("m-detail-chart");
if (chart) {
if (tab === "daily") {
chart.innerHTML = dailyChart(payload);
} else if (meta.intraday_status === "available") {
chart.innerHTML = intradayChart(payload);
} else {
chart.innerHTML = emptyChart(meta.intraday_notice || "分时数据暂不可用");
}
}
const caption = document.getElementById("m-detail-caption");
if (caption) caption.textContent = chartCaptionHtml(tab);
document.querySelectorAll(".m-detail-tab").forEach(function (btn) {
const active = btn.dataset.chartTab === tab;
btn.classList.toggle("active", active);
btn.setAttribute("aria-selected", String(active));
});
}
function updateStarButton() {
const btn = document.querySelector(".m-detail-star");
if (!btn) return;
const watched = Boolean(state.detail && state.detail.watchlist);
btn.classList.toggle("is-added", watched);
btn.setAttribute("aria-label", watched ? "移出自选" : "加入自选");
btn.setAttribute("aria-pressed", String(watched));
btn.innerHTML = starIcon(watched);
btn.disabled = false;
}
function toggleWatch() {
const detail = state.detail;
if (!detail || !detail.code) return;
const btn = document.querySelector(".m-detail-star");
const adding = !detail.watchlist;
const code = detail.code;
const url = adding ? "/api/watchlist" : "/api/watchlist/" + encodeURIComponent(code);
const method = adding ? "POST" : "DELETE";
const body = adding ? { code: code, name: detail.name || code, sector: detail.sector || "" } : null;
if (btn) btn.disabled = true;
global.MobileAPI.request(url, method, body).then(function () {
detail.watchlist = adding;
updateStarButton();
showToast(adding ? "已加入自选" : "已移出自选");
}).catch(function () {
updateStarButton();
showToast("操作失败,请重试");
});
}
function showToast(message) {
const root = document.getElementById("m-sheet-root");
if (!root) return;
let toast = document.getElementById("m-toast");
if (!toast) {
toast = document.createElement("div");
toast.id = "m-toast";
toast.className = "m-toast";
root.appendChild(toast);
}
toast.textContent = message;
toast.classList.remove("is-visible");
void toast.offsetWidth;
toast.classList.add("is-visible");
global.clearTimeout(toast._timer);
toast._timer = global.setTimeout(function () {
toast.classList.remove("is-visible");
}, 1500);
}
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);
state.detail = {
code: stock.code || "",
name: name === "--" ? "" : name,
sector: industry === "其他" ? "" : industry,
watchlist: Boolean(stock.watchlist),
tab: "intraday",
payload: payload,
};
const headName = document.querySelector(".m-detail-head-name");
const headCode = document.querySelector(".m-detail-head-code");
if (headName) headName.textContent = name;
if (headCode) headCode.textContent = stock.code || "";
updateStarButton();
const intradayOk = meta.intraday_status === "available";
const chartHtml = intradayOk ? intradayChart(payload) : emptyChart(meta.intraday_notice || "分时数据暂不可用");
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 class="m-detail-meta">' + escapeHtml(industry) + " · " + escapeHtml(displayCompactDate(meta.trade_date) || "--") + " 收盘</div>" +
"</div>" +
spark +
'<div class="m-detail-note">' + escapeHtml(meta.notice || "数据来源:本地行情") + "</div>";
'<div class="m-detail-tabs" role="tablist" aria-label="行情图类型">' +
'<button class="m-detail-tab active" type="button" role="tab" aria-selected="true" data-chart-tab="intraday">分时</button>' +
'<button class="m-detail-tab" type="button" role="tab" aria-selected="false" data-chart-tab="daily">日线</button>' +
'<span class="m-detail-chart-caption" id="m-detail-caption">' + escapeHtml(chartCaptionHtml("intraday")) + "</span>" +
"</div>" +
'<div class="m-detail-chart" id="m-detail-chart">' + chartHtml + "</div>" +
'<div class="m-detail-note">点按图面可读数值 · 数据来源:本地行情</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;
/* ---------------------------------------------------------------- charts */
function emptyChart(message) {
return '<div class="m-chart-empty">' + escapeHtml(message || "数据暂不可用") + "</div>";
}
function svgAxisText(x, y, anchor, cls, content) {
return '<text x="' + x + '" y="' + y + '" text-anchor="' + anchor + '" class="m-chart-axis ' + cls + '">' + escapeHtml(content) + "</text>";
}
function dateMMDD(value) {
const s = String(value || "");
return s.length >= 10 ? s.slice(5, 10) : s;
}
function movingAverage(values, period) {
const out = [];
for (let i = 0; i < values.length; i += 1) {
if (i < period - 1) { out.push(null); continue; }
let sum = 0;
for (let j = i - period + 1; j <= i; j += 1) sum += values[j];
out.push(sum / period);
}
return out;
}
function maPath(ma, xf, yf, cls) {
const pts = [];
for (let i = 0; i < ma.length; i += 1) {
if (ma[i] == null) continue;
pts.push(xf(i).toFixed(1) + " " + yf(ma[i]).toFixed(1));
}
return pts.length > 1 ? '<polyline class="m-chart-ma ' + cls + '" points="' + pts.join(" ") + '"/>' : "";
}
function intradayChart(payload) {
const W = 360, H = 240, padL = 8, padR = 52, padT = 10, padB = 22;
const pw = W - padL - padR;
const ph = H - padT - padB;
const points = payload.intraday || [];
const meta = payload.meta || {};
const prevClose = number(meta.intraday_previous_close) || (points.length ? number(points[0].close) : 0);
if (!points.length || prevClose <= 0) {
return emptyChart(meta.intraday_notice || "分时数据暂不可用");
}
let maxPct = 2;
points.forEach(function (p) {
const cp = number(p.close);
const ap = number(p.average);
if (cp > 0) maxPct = Math.max(maxPct, Math.abs((cp - prevClose) / prevClose * 100));
if (ap > 0) maxPct = Math.max(maxPct, Math.abs((ap - prevClose) / prevClose * 100));
});
maxPct = Math.max(0.01, Math.ceil(maxPct * 100) / 100);
function x(i) {
return padL + (points.length <= 1 ? pw / 2 : i / (points.length - 1) * pw);
}
function y(close) {
const pct = (close - prevClose) / prevClose * 100;
return padT + (maxPct - pct) / (2 * maxPct) * ph;
}
const pricePath = points.map(function (p, i) {
return (i ? "L" : "M") + x(i).toFixed(1) + " " + y(number(p.close)).toFixed(1);
}).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>";
const avgPts = [];
points.forEach(function (p, i) {
const a = number(p.average);
if (a <= 0) return;
avgPts.push((avgPts.length ? "L" : "M") + x(i).toFixed(1) + " " + y(a).toFixed(1));
});
const avgPath = avgPts.join(" ");
const y0 = y(prevClose).toFixed(1);
const right = W - 4;
return '<svg viewBox="0 0 ' + W + " " + H + '" preserveAspectRatio="none" aria-hidden="true">' +
'<line class="m-chart-prev-close" x1="' + padL + '" y1="' + y0 + '" x2="' + (W - padR) + '" y2="' + y0 + '"/>' +
'<path class="m-chart-line m-chart-avg" d="' + avgPath + '"/>' +
'<path class="m-chart-line m-chart-price" d="' + pricePath + '"/>' +
svgAxisText(right, padT + 8, "end", "is-up", "+" + maxPct.toFixed(2) + "%") +
svgAxisText(right, padT + ph / 2 + 3, "end", "", "0.00%") +
svgAxisText(right, padT + ph, "end", "is-down", "-" + maxPct.toFixed(2) + "%") +
svgAxisText(padL, H - 6, "start", "", "09:30") +
svgAxisText(padL + pw / 2, H - 6, "middle", "", "11:30/13:00") +
svgAxisText(padL + pw, H - 6, "end", "", "15:00") +
"</svg>";
}
function dailyChart(payload) {
const W = 360, H = 240, padL = 8, padR = 52, padT = 10, padB = 22;
const pw = W - padL - padR;
const ph = H - padT - padB;
const prices = (payload.prices || []).slice(-48);
if (prices.length < 2) return emptyChart("日线数据暂不可用");
const closes = prices.map(function (b) { return number(b.close); });
const highs = prices.map(function (b) { return number(b.high); });
const lows = prices.map(function (b) { return number(b.low); });
let max = Math.max.apply(null, highs);
let min = Math.min.apply(null, lows);
if (max - min <= 0) { max += 1; min -= 1; }
const padRange = (max - min) * 0.08;
max += padRange;
min -= padRange;
function x(i) { return padL + (i + 0.5) / prices.length * pw; }
function y(v) { return padT + (max - v) / (max - min) * ph; }
const candleW = Math.max(2, Math.min(7, pw / prices.length * 0.7));
const candles = prices.map(function (b, i) {
const up = number(b.close) >= number(b.open);
const cls = "m-chart-candle" + (up ? " is-up" : " is-down");
const cx = x(i).toFixed(1);
const bodyTop = y(Math.max(number(b.open), number(b.close))).toFixed(1);
const bodyH = Math.max(1, Math.abs(y(number(b.open)) - y(number(b.close)))).toFixed(1);
const bx = (x(i) - candleW / 2).toFixed(1);
return '<line class="' + cls + '" x1="' + cx + '" y1="' + y(number(b.high)).toFixed(1) + '" x2="' + cx + '" y2="' + y(number(b.low)).toFixed(1) + '"/>' +
'<rect class="' + cls + '" x="' + bx + '" y="' + bodyTop + '" width="' + candleW.toFixed(1) + '" height="' + bodyH + '"/>';
}).join("");
const ma5 = movingAverage(closes, 5);
const ma10 = movingAverage(closes, 10);
const firstDate = prices[0].trade_date;
const midDate = prices[Math.floor(prices.length / 2)].trade_date;
const lastDate = prices[prices.length - 1].trade_date;
const right = W - 4;
return '<div class="m-chart-legend" aria-hidden="true">' +
'<span class="m-chart-legend-ma5">MA5</span>' +
'<span class="m-chart-legend-ma10">MA10</span>' +
"</div>" +
'<svg viewBox="0 0 ' + W + " " + H + '" preserveAspectRatio="none" aria-hidden="true">' +
candles +
maPath(ma5, x, y, "is-ma5") +
maPath(ma10, x, y, "is-ma10") +
svgAxisText(right, padT + 8, "end", "is-up", formatNumber(max, 2)) +
svgAxisText(right, padT + ph, "end", "is-down", formatNumber(min, 2)) +
svgAxisText(padL, H - 6, "start", "", dateMMDD(firstDate)) +
svgAxisText(padL + pw / 2, H - 6, "middle", "", dateMMDD(midDate)) +
svgAxisText(padL + pw, H - 6, "end", "", dateMMDD(lastDate)) +
"</svg>";
}
/* ---------------------------------------------------------------- events */
@@ -808,20 +1155,24 @@
const retry = event.target.closest("[data-action=retry]");
if (retry) { load(); return; }
const sortHead = event.target.closest("[data-sort-key]");
if (sortHead) { toggleSort(sortHead.dataset.sortKey); return; }
const watchBtn = event.target.closest("[data-action=watch]");
if (watchBtn) { toggleWatch(); return; }
const chartTab = event.target.closest("[data-chart-tab]");
if (chartTab) { switchChartTab(chartTab.dataset.chartTab); 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));
});
}
renderTableBody();
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;
}