Files
xiaobaifupan/app/frontend/m/js/pages.js
T

2141 lines
91 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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>";
}
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 = {
key: "",
requestedDate: "",
dashboard: null,
popularity: null,
popularitySource: "combined",
seq: 0,
calCursor: null,
sort: { key: "", dir: null },
detail: null,
sentimentHistory: null,
sentimentRange: 20,
rotation: null,
rotationSelectedSector: "",
rotationSelectedDate: "",
rotationMembers: null,
auction: null,
auctionDataset: "focus",
themes: null,
themesDetail: null,
themesSelectedCode: "",
dragon: null,
dragonProfiles: null,
dragonViewMode: "daily",
dragonSelectedTrader: "",
};
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;
}
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", "score"].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) {
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", "score"].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 scoreCell(value) {
if (value == null || value === "") return '<span class="m-num"></span>';
return '<span class="m-num">' + formatNumber(number(value), 1) + "</span>";
}
function expectationCell(value) {
if (value == null || value === "") return "";
const map = { "超预期": "above", "符合预期": "matched", "低于预期": "below", "竞价一字": "one" };
return '<span class="m-tag m-tag--exp-' + (map[value] || "matched") + '">' + escapeHtml(value) + "</span>";
}
function directionCell(value) {
if (value == null || value === "") return "";
const map = { "买入": "up", "卖出": "down", "持平": "flat" };
return '<span class="m-tag m-tag--dir-' + (map[value] || "flat") + '">' + escapeHtml(value) + "</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 "score": return scoreCell(value);
case "expectation": return expectationCell(value);
case "direction": return directionCell(value);
case "text":
default: return textCell(value);
}
}
/* ---------------------------------------------------------------- table */
function buildTable(cols, rows, opts) {
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, 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" : "") + (extraCls ? " " + extraCls : "");
return '<' + (isHead ? "th" : "td") + ' class="' + cls + '" style="' + style + '"' + (extraAttrs || "") + '>';
}
const head = ordered.map(function (col, i) {
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) {
const cells = ordered.map(function (col, i) {
return cellOpen(col, i, false) + cellHtml(col, row, rIndex) + "</td>";
}).join("");
const linkable = !(opts && opts.noLink);
const code = linkable && /^\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>";
}
/* ---------------------------------------------------------------- strip */
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>');
}
return '<div class="m-strip m-strip--skeleton" aria-hidden="true">' + cells.join("") + "</div>";
}
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 */
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) {
if (isComplexPage(key)) {
COMPLEX_PAGES[key](key);
const loader = COMPLEX_LOADERS[key];
if (loader) loader();
return;
}
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;
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 sourceTabs = sourceTabsHtml(cfg);
const content = '<div class="m-page" data-page="' + escapeHtml(key) + '">' +
'<div class="m-top">' + buildStrip() + "</div>" +
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 popularity = key === "market/popularity";
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;
let meta = {};
if (popularity) {
state.popularity = results[0];
state.dashboard = results[1];
meta = (results[0] && results[0].meta) || {};
} else {
state.dashboard = results[0];
meta = (results[0] && results[0].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) {
renderTableBody();
renderTopArea(key);
}
function renderTableBody() {
const key = state.key;
const cfg = pageConfig(key);
if (!cfg) return;
const cols = columnsFor(cfg);
const rows = sortedRows(prepareRows(key), cols);
const scroll = document.getElementById("m-table-scroll");
if (!scroll) return;
if (!rows.length) {
scroll.innerHTML = emptyHtml();
} else {
scroll.innerHTML = buildTable(cols, rows);
}
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") || document.getElementById("m-scroll");
if (scroll) scroll.innerHTML = errorHtml(message);
const page = document.querySelector(".m-page");
if (page) {
const top = page.querySelector(".m-top");
if (top) top.remove();
}
}
/* ---------------------------------------------------------------- complex pages (P2b) */
function nextSeq() {
return ++state.seq;
}
function columnsForKey(subKey) {
const cfg = global.MobileNav && global.MobileNav.tableColumns ? global.MobileNav.tableColumns[subKey] : null;
return cfg || null;
}
function tableScrollHtml(subKey, rows, opts) {
const cols = columnsForKey(subKey);
if (!cols) return emptyHtml();
if (!rows || !rows.length) return emptyHtml();
return '<div class="m-table-wrap">' + buildTable(cols, rows, opts || {}) + "</div>";
}
function complexFrame(key, bodyHtml) {
return '<div class="m-page" data-page="' + escapeHtml(key) + '">' + bodyHtml + "</div>";
}
function complexScroll(bodyHtml) {
return '<div class="m-scroll" id="m-scroll">' + bodyHtml + "</div>";
}
function resetComplexState(key) {
state.key = key;
state.requestedDate = todayString();
state.dashboard = null;
state.popularity = null;
state.calCursor = null;
state.sort = { key: "", dir: null };
state.detail = null;
state.sentimentHistory = null;
state.rotation = null;
state.rotationSelectedSector = "";
state.rotationSelectedDate = "";
state.rotationMembers = null;
state.auction = null;
state.auctionDataset = "focus";
state.themes = null;
state.themesDetail = null;
state.themesSelectedCode = "";
state.dragon = null;
state.dragonProfiles = null;
state.dragonViewMode = "daily";
state.dragonSelectedTrader = "";
}
function reloadCurrent() {
const loader = COMPLEX_LOADERS[state.key];
if (loader) { loader(); return; }
load();
}
function clampScore(value) {
return Math.max(0, Math.min(100, number(value)));
}
function phaseTone(phase) {
return {
"冰点": "ice", "修复": "repair", "发酵": "fermentation",
"高潮": "climax", "分化": "divergence", "退潮": "retreat",
}[phase] || "divergence";
}
function phaseBadgeHtml(phase) {
if (!phase) return "";
return '<span class="m-phase m-phase--' + phaseTone(phase) + '">' + escapeHtml(phase) + "</span>";
}
function directionTone(direction) {
return { "升温": "up", "降温": "down", "新进": "new", "持平": "flat" }[direction] || "flat";
}
function formatMoneyMillion(value) {
const parsed = number(value);
const sign = parsed > 0 ? "+" : "";
if (Math.abs(parsed) >= 100) return sign + formatNumber(parsed / 100, 2) + " 亿";
return sign + formatNumber(parsed * 100, 0) + " 万";
}
function sentimentTrendChart(rows) {
const W = 360, H = 240, padL = 8, padR = 30, padT = 12, padB = 22;
const pw = W - padL - padR;
const ph = H - padT - padB;
if (!rows.length) return emptyChart("暂无情绪历史数据");
function x(i) { return padL + (rows.length <= 1 ? pw / 2 : i / (rows.length - 1) * pw); }
function y(v) { return padT + (100 - clampScore(v)) / 100 * ph; }
const line = rows.map(function (r, i) {
return (i ? "L" : "M") + x(i).toFixed(1) + " " + y(number(r.score)).toFixed(1);
}).join(" ");
const area = line + " L" + x(rows.length - 1).toFixed(1) + " " + (padT + ph).toFixed(1) +
" L" + padL + " " + (padT + ph).toFixed(1) + " Z";
const right = W - 4;
const labelStep = Math.max(1, Math.ceil(rows.length / 6));
const xLabels = rows.map(function (r, i) {
if (i % labelStep !== 0 && i !== rows.length - 1) return "";
const anchor = i === 0 ? "start" : i === rows.length - 1 ? "end" : "middle";
return svgAxisText(x(i), H - 6, anchor, "", dateMMDD(displayCompactDate(r.trade_date)));
}).join("");
return '<svg viewBox="0 0 ' + W + " " + H + '" preserveAspectRatio="none" aria-hidden="true">' +
svgAxisText(right, padT + 8, "end", "", "100") +
svgAxisText(right, padT + ph / 2 + 3, "end", "", "50") +
svgAxisText(right, padT + ph, "end", "", "0") +
'<path class="m-sentiment-area" d="' + area + '"/>' +
'<path class="m-sentiment-line" d="' + line + '"/>' +
xLabels +
"</svg>";
}
/* ----- 情绪周期 ----- */
function loadSentiment() {
const seq = nextSeq();
const date = state.requestedDate;
const historyUrl = "/api/sentiment/history?trade_date=" + encodeURIComponent(date) +
"&limit=" + state.sentimentRange;
const req = Promise.all([
global.MobileAPI.request("/api/dashboard?trade_date=" + encodeURIComponent(date)),
global.MobileAPI.request(historyUrl),
]);
req.then(function (results) {
if (seq !== state.seq) return;
state.dashboard = results[0];
state.sentimentHistory = results[1];
const meta = (results[0] && results[0].meta) || {};
state.requestedDate = displayCompactDate(meta.requested_date || meta.trade_date || date);
updateHeader(findLabel(state.key) || state.key, state.requestedDate);
renderSentiment();
}).catch(function (error) {
if (seq !== state.seq) return;
renderError(error && error.message ? error.message : "情绪周期加载失败");
});
}
function sentimentComponentList(components) {
const items = Object.keys(components || {}).map(function (key) { return components[key]; });
if (!items.length) return "";
return '<div class="m-components">' + items.map(function (item) {
const score = clampScore(item.score);
return '<div class="m-component">' +
'<div class="m-component-head"><span>' + escapeHtml(item.label) + "</span>" +
"<b>" + formatNumber(number(item.score), 1) + "</b></div>" +
'<div class="m-component-track"><i style="width:' + score.toFixed(1) + '%"></i></div>' +
'<small>' + escapeHtml(item.summary || "") + " · 权重 " + number(item.weight) + "%</small>" +
"</div>";
}).join("") + "</div>";
}
function renderSentiment() {
const scroll = document.getElementById("m-scroll");
if (!scroll) return;
const dash = state.dashboard || {};
const overview = dash.overview || {};
const history = state.sentimentHistory || {};
const rawRows = history.rows || [];
const latest = rawRows.length ? rawRows[rawRows.length - 1] : null;
const tableRows = rawRows.slice().reverse();
let card = "";
if (latest) {
const score = number(latest.score);
const dayChange = number(latest.day_change);
const components = latest.components || overview.sentiment_components || {};
card =
'<div class="m-sent-card">' +
'<div class="m-sent-score-row"><strong class="m-sent-score">' + score + "</strong>" +
'<div class="m-sent-score-side">' + phaseBadgeHtml(latest.phase) +
'<span class="m-sent-direction m-sent-dir--' + directionTone(latest.direction) + '">' + escapeHtml(latest.direction) + "</span></div></div>" +
'<div class="m-sent-label">' + escapeHtml(latest.label || "") + "</div>" +
'<div class="m-sent-metrics">' +
sentMetric("较前日", (dayChange > 0 ? "+" : "") + formatNumber(dayChange, 1), changeClass(dayChange)) +
sentMetric("封板率", formatNumber(number(latest.seal_rate), 1) + "%", "") +
sentMetric("涨停", number(latest.limit_up_count), "up") +
sentMetric("炸板", number(latest.broken_count), "warn") +
"</div>" +
'<p class="m-sent-advice">' + escapeHtml(sentimentAdvice(latest.phase)) + "</p>" +
"</div>" +
'<div class="m-section-title">情绪趋势(近 ' + rawRows.length + " 日)</div>" +
'<div class="m-chart-card">' + sentimentTrendChart(rawRows) + "</div>" +
'<div class="m-section-title">五维分项</div>' +
sentimentComponentList(components);
}
const rangeTabs = [20, 40, 60].map(function (n) {
const active = state.sentimentRange === n;
return '<button class="m-range-tab' + (active ? " active" : "") + '" type="button" data-sentiment-range="' + n + '" aria-pressed="' + (active ? "true" : "false") + '">' + n + "日</button>";
}).join("");
const histRows = tableRows.map(function (r) {
return Object.assign({}, r, { trade_date: displayCompactDate(r.trade_date) });
});
scroll.innerHTML = card +
'<div class="m-section-title">历史明细 <span class="m-range-tabs">' + rangeTabs + "</span></div>" +
tableScrollHtml("market/sentiment/history", histRows, {});
}
function sentMetric(label, valueHtml, tone) {
return '<div class="m-sent-metric' + (tone ? " m-sent-" + tone : "") + '"><span>' + escapeHtml(label) + "</span><b>" + valueHtml + "</b></div>";
}
function sentimentAdvice(phase) {
return {
"冰点": "情绪处于极弱区,先观察风险释放。",
"修复": "风险开始收敛,关注率先转强的核心。",
"发酵": "主线与梯队正在形成,优先跟随核心。",
"高潮": "情绪处高位,聚焦核心并主动降低后排暴露。",
"分化": "强弱开始分层,关注承接与回流。",
"退潮": "情绪指标继续走弱,控制仓位。",
}[phase] || "市场结构尚未形成清晰阶段,保持观察。";
}
/* ----- 市场天梯 ----- */
const expandedLadder = {};
function loadLadder() {
const seq = nextSeq();
const date = state.requestedDate;
global.MobileAPI.request("/api/dashboard?trade_date=" + encodeURIComponent(date)).then(function (payload) {
if (seq !== state.seq) return;
state.dashboard = payload;
const meta = payload.meta || {};
state.requestedDate = displayCompactDate(meta.requested_date || meta.trade_date || date);
updateHeader(findLabel(state.key) || state.key, state.requestedDate);
renderLadder();
}).catch(function (error) {
if (seq !== state.seq) return;
renderError(error && error.message ? error.message : "市场天梯加载失败");
});
}
function ladderTierHtml(level, group, groupMap, maxLevel) {
const label = group ? group.label : (level === 1 ? "首板" : level + "板");
const count = group ? number(group.count) : 0;
const gap = count === 0;
const color = { 1: "var(--action)", 2: "var(--market-down)", 3: "var(--warning)", 4: "var(--market-up)" }[level] || "var(--text-tertiary)";
const rate = level > 1 && count ? Math.round(count / Math.max(number((groupMap[level - 1] || {}).count), 1) * 100 * 10) / 10 : 0;
const rateHtml = level > 1 && count ? '<span class="m-ladder-rate">较' + (level - 1) + "板 " + rate + "%</span>" : "";
let stocksHtml = "";
if (gap) {
stocksHtml = '<div class="m-ladder-gap">' + (level >= maxLevel ? "断层 · " + escapeHtml(label) + "及以上空缺" : "该层暂时空缺") + "</div>";
} else {
const stocks = (group.stocks || []).slice();
const cap = level === 1 || level === 2 ? 8 : 0;
const expanded = expandedLadder[level];
const visible = (expanded || !cap) ? stocks : stocks.slice(0, cap);
stocksHtml = visible.map(function (s) {
const onePrice = String(s.first_time || "").startsWith("09:25") && number(s.open_times) === 0;
const broken = number(s.open_times) >= 6;
const amount = number(s.seal_amount_million) ? "封单 " + formatNumber(s.seal_amount_million, 0) + " 万" : "成交 " + formatNumber(s.amount_billion, 1) + " 亿";
return '<button class="m-ladder-stock" type="button" data-ladder-stock="' + escapeHtml(s.code) + '">' +
'<span class="m-ladder-stock-main"><strong>' + escapeHtml(s.name) + "</strong>" +
'<small>' + escapeHtml(s.code) + "</small>" +
(onePrice ? '<em class="m-ladder-tag">一字</em>' : "") +
(broken ? '<em class="m-ladder-tag is-broken">烂板×' + number(s.open_times) + "</em>" : "") +
"</span>" +
'<span class="m-ladder-stock-sub"><b>' + escapeHtml(s.sector || s.reason || "其他") + "</b>" +
"<small>" + escapeHtml(s.first_time && s.first_time !== "--" ? s.first_time : "时间待校正") + " · " + escapeHtml(amount) + "</small></span>" +
"</button>";
}).join("");
const remaining = !expanded && cap && stocks.length > cap ? stocks.length - cap : 0;
if (remaining) {
stocksHtml += '<button class="m-ladder-more" type="button" data-ladder-expand="' + level + '">展开剩余 ' + remaining + " 只</button>";
}
}
return '<section class="m-ladder-tier' + (gap ? " is-gap" : "") + '">' +
'<div class="m-ladder-tier-head"><span class="m-ladder-tier-level" style="color:' + color + '">' + escapeHtml(label) + "</span>" +
'<span class="m-ladder-tier-count">' + count + " 只</span>" + rateHtml + "</div>" +
'<div class="m-ladder-stocks">' + stocksHtml + "</div></section>";
}
function renderLadder() {
const scroll = document.getElementById("m-scroll");
if (!scroll) return;
const dash = state.dashboard || {};
const ladders = dash.ladders || [];
if (!ladders.length) { scroll.innerHTML = emptyHtml(); return; }
const maxLevel = ladders.reduce(function (m, g) { return Math.max(m, number(g.level)); }, 0);
const topVisible = Math.max(5, maxLevel);
const groupMap = {};
ladders.forEach(function (g) { groupMap[number(g.level)] = g; });
const yesterday = dash.yesterday_limits || [];
const prevMax = yesterday.reduce(function (m, r) { return Math.max(m, number(r.prior_streak)); }, 0);
const spaceStocks = (groupMap[maxLevel] || {}).stocks || [];
const spaceChange = prevMax && maxLevel < prevMax ? "较昨日 " + prevMax + " 板 ↓ 压缩"
: prevMax && maxLevel > prevMax ? "较昨日 " + prevMax + " 板 ↑ 抬升" : "高度与昨日接近";
const spaceNote = maxLevel >= 5 ? "高位梯队仍有辨识度,重点看承接。"
: maxLevel >= 3 ? "空间位于中段,梯队延续性比绝对高度更重要。" : "高度受压缩,先看首板向二板的结构修复。";
const apexHtml = '<div class="m-apex">' +
'<div class="m-apex-head"><span>空间板</span><strong>' + (maxLevel ? maxLevel + " 板" : "--") + "</strong><em>" + escapeHtml(spaceChange) + "</em></div>" +
'<p>' + (spaceStocks.length ? spaceStocks.map(function (s) { return "<b>" + escapeHtml(s.name) + "</b>" + escapeHtml(s.sector || "其他") + ""; }).join(" · ") : "暂无空间板") + "</p>" +
"<small>" + escapeHtml(spaceNote) + "</small></div>";
const tiers = [];
for (let level = topVisible; level >= 1; level -= 1) {
tiers.push(ladderTierHtml(level, groupMap[level], groupMap, maxLevel));
}
const perf = dash.limit_performance || [];
const perfRows = perf.map(function (p) {
const value = Math.max(0, Math.min(100, number(p.advance_rate)));
return '<div class="m-rate-row"><span>' + escapeHtml(p.label || "昨日" + number(p.level) + "板") + "</span>" +
'<i><b style="width:' + value.toFixed(1) + '%"></b></i><strong>' + formatNumber(value, 1) + "%</strong></div>";
}).join("");
const perfHtml = perfRows
? '<div class="m-section-title">晋级率参考(昨日梯队 → 今日)</div><div class="m-rate-list">' + perfRows + "</div>"
: "";
scroll.innerHTML = apexHtml + tiers.join("") + perfHtml;
}
/* ----- 主题轮动 ----- */
function loadRotation() {
const seq = nextSeq();
const date = state.requestedDate;
global.MobileAPI.request("/api/rotation/history?trade_date=" + encodeURIComponent(date)).then(function (payload) {
if (seq !== state.seq) return;
state.rotation = payload;
state.rotationMembers = null;
state.rotationSelectedSector = "";
state.rotationSelectedDate = "";
const meta = payload || {};
state.requestedDate = displayCompactDate(meta.trade_date || date);
updateHeader(findLabel(state.key) || state.key, state.requestedDate);
renderRotation();
}).catch(function (error) {
if (seq !== state.seq) return;
renderError(error && error.message ? error.message : "主题轮动加载失败");
});
}
function renderRotation() {
const scroll = document.getElementById("m-scroll");
if (!scroll) return;
const rows = (state.rotation && state.rotation.rows) || [];
if (!rows.length) { scroll.innerHTML = emptyHtml(); return; }
const latestDate = rows[0].trade_date;
scroll.innerHTML = rows.map(function (day) {
const sectors = day.sectors || [];
const chips = sectors.map(function (sector) {
const strength = clampScore(sector.strength);
const heat = strength >= 90 ? "strong" : strength >= 70 ? "warm" : "mild";
return '<button class="m-rotation-chip ' + heat + '" type="button" data-rotation-sector="' + escapeHtml(sector.name) + '" data-rotation-date="' + escapeHtml(day.trade_date) + '">' +
'<span class="m-rotation-rank rank-' + Math.min(number(sector.rank), 4) + '">' + number(sector.rank) + "</span>" +
"<strong>" + escapeHtml(sector.name) + "</strong>" +
"<small>" + number(sector.count) + " 家 · " + formatNumber(number(sector.strength), 0) + "</small>" +
"</button>";
}).join("");
return '<section class="m-rotation-day' + (day.trade_date === latestDate ? " is-latest" : "") + '">' +
'<header><time>' + escapeHtml(displayCompactDate(day.trade_date).slice(5)) + "</time>" +
"<span>" + sectors.length + " 个热点</span></header>" +
'<div class="m-rotation-chips">' + chips + "</div></section>";
}).join("");
}
function loadRotationMembers(sector, date) {
const seq = nextSeq();
const url = "/api/rotation/members?trade_date=" + encodeURIComponent(date) + "&sector=" + encodeURIComponent(sector);
global.MobileAPI.request(url).then(function (payload) {
if (seq !== state.seq) return;
state.rotationMembers = payload;
renderRotationMembersSheet(sector, payload);
}).catch(function (error) {
if (seq !== state.seq) return;
state.rotationMembers = { error: error.message || "成分股加载失败" };
renderRotationMembersSheet(sector, state.rotationMembers);
});
}
function openRotationMembersSheet(sector, date) {
openSheet(
'<div class="m-sheet-head"><h2>' + escapeHtml(sector) + "成分股</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-rotation-members-body">' + skeletonHtml(8) + "</div>",
{ detail: false }
);
loadRotationMembers(sector, date);
}
function renderRotationMembersSheet(sector, payload) {
const body = document.getElementById("m-rotation-members-body");
if (!body) return;
if (payload.error) { body.innerHTML = emptyHtml(); return; }
const meta = payload.meta || {};
const rows = payload.rows || [];
const metaLine = '<div class="m-sheet-meta">' + escapeHtml(displayCompactDate(meta.trade_date) || "--") +
" · " + number(meta.quoted_count) + " / " + number(meta.member_count) + " 只</div>";
body.innerHTML = metaLine + tableScrollHtml("market/rotation/members", rows, { noLink: true });
}
/* ----- 竞价 ----- */
const AUCTION_DATASETS = [
{ key: "focus", label: "重点异动" },
{ key: "all", label: "全部候选" },
{ key: "onePrice", label: "竞价一字" },
{ key: "watchlist", label: "我的自选" },
];
function loadAuction() {
const seq = nextSeq();
const date = state.requestedDate;
global.MobileAPI.request("/api/auction?trade_date=" + encodeURIComponent(date)).then(function (payload) {
if (seq !== state.seq) return;
state.auction = payload;
const meta = payload.meta || {};
state.requestedDate = displayCompactDate(meta.trade_date || date);
updateHeader(findLabel(state.key) || state.key, state.requestedDate);
renderAuction();
}).catch(function (error) {
if (seq !== state.seq) return;
renderError(error && error.message ? error.message : "竞价数据加载失败");
});
}
function auctionRows() {
const data = state.auction || {};
const datasets = {
focus: data.focus_rows || [],
all: data.rows || [],
onePrice: data.one_price_rows || [],
watchlist: data.watchlist_rows || [],
};
const rows = (datasets[state.auctionDataset] || []).slice();
if (state.auctionDataset === "onePrice") {
return rows.map(function (r) { return Object.assign({}, r, { expectation: "竞价一字" }); });
}
return rows;
}
function auctionAmountTrend(history) {
if (!history || !history.length) return '<div class="m-inline-empty">历史竞价量能尚未形成</div>';
const max = Math.max.apply(null, history.map(function (h) { return number(h.amount_billion); })) || 1;
return '<div class="m-mini-bars">' + history.map(function (h, i) {
const hgt = Math.max(8, number(h.amount_billion) / max * 100);
const current = i === history.length - 1 ? " current" : "";
return '<div class="m-mini-bar' + current + '" title="' + escapeHtml(h.trade_date) + " · " + formatNumber(number(h.amount_billion), 2) + ' 亿 · ' + number(h.stock_count) + ' 只">' +
'<span style="height:' + hgt.toFixed(1) + '%"></span><small>' + escapeHtml(String(h.trade_date || "").slice(5)) + "</small></div>";
}).join("") + "</div>";
}
function auctionThemeCarry(carry) {
if (!carry || !carry.length) return "";
const tone = { "强承接": "strong", "有承接": "steady", "分歧": "mixed", "承接弱": "weak" };
return '<div class="m-carry">' + carry.slice(0, 6).map(function (item) {
return '<div class="m-carry-row">' +
'<span class="m-carry-name">' + escapeHtml(item.name) + "</span>" +
'<span class="m-carry-leader">' + escapeHtml(item.leader || "--") + " · 昨 " + number(item.prior_limit_count) + " 只</span>" +
'<span class="m-carry-status ' + (tone[item.status] || "mixed") + '">' + escapeHtml(item.status) + "</span>" +
'<span class="m-carry-median ' + changeClass(item.median_change) + '">' +
(item.median_change == null ? "暂无候选" : (number(item.median_change) > 0 ? "+" : "") + formatNumber(number(item.median_change), 2) + "%") + "</span></div>";
}).join("") + "</div>";
}
function auctionPhaseNotice(meta) {
const phase = meta.phase || "archive";
const copy = {
pending: ["竞价尚未开始", "9:15 进入观察期,9:25 读取最终竞价结果。"],
observing: ["竞价观察期", "此阶段先观察盘前变化,系统将在 9:25 自动读取最终结果。"],
selection: ["竞价筛选窗口", "最终竞价结果已经定格,请在 9:30 前完成筛选。"],
finalized: ["今日竞价已定格", "9:30 后停止更新,仅保留用于复盘与回测。"],
archive: ["历史竞价归档", "当前展示所选交易日的最终竞价结果。"],
}[phase] || ["竞价状态", "当前竞价状态待确认。"];
return '<div class="m-phase-notice m-phase-notice--' + escapeHtml(phase) + '"><strong>' + escapeHtml(copy[0]) + "</strong><span>" + escapeHtml(copy[1]) + "</span></div>";
}
function renderAuction() {
const scroll = document.getElementById("m-scroll");
if (!scroll) return;
const payload = state.auction;
if (!payload) return;
const summary = payload.summary || {};
const meta = payload.meta || {};
const themes = payload.themes || {};
const metrics = [
["竞价覆盖", formatNumber(number(summary.stock_count), 0) + " 只", ""],
["重点异动", formatNumber(number(summary.focus_count), 0) + " 只", "up"],
["竞价一字", formatNumber(number(summary.one_price_count), 0) + " 只", ""],
["竞价成交额", formatNumber(number(summary.amount_billion), 2) + " 亿", ""],
].map(function (m) {
return '<div class="m-sum-metric' + (m[2] ? " m-sum-" + m[2] : "") + '"><span>' + escapeHtml(m[0]) + "</span><strong>" + m[1] + "</strong></div>";
}).join("");
const tabs = AUCTION_DATASETS.map(function (d) {
const active = state.auctionDataset === d.key;
return '<button class="m-source-tab' + (active ? " active" : "") + '" type="button" role="tab" aria-selected="' + (active ? "true" : "false") + '" data-action="auction-dataset" data-dataset="' + d.key + '">' + escapeHtml(d.label) + "</button>";
}).join("");
const carry = auctionThemeCarry(themes.carry);
scroll.innerHTML =
auctionPhaseNotice(meta) +
'<div class="m-sum-strip">' + metrics + "</div>" +
'<div class="m-section-title">竞价量能</div><div class="m-chart-card">' + auctionAmountTrend(payload.amount_history || []) + "</div>" +
(carry ? '<div class="m-section-title">昨日强势题材承接</div><div class="m-card">' + carry + "</div>" : "") +
'<div class="m-source-tabs" role="tablist" aria-label="竞价数据集">' + tabs + "</div>" +
'<div class="m-table-wrap" id="m-auction-table"></div>';
renderAuctionTable();
}
function renderAuctionTable() {
const table = document.getElementById("m-auction-table");
if (!table) return;
const rows = auctionRows();
if (!rows.length) { table.innerHTML = emptyHtml(); return; }
table.innerHTML = buildTable(columnsForKey("market/auction"), rows);
}
/* ----- 题材库 ----- */
function loadThemes() {
const seq = nextSeq();
const date = state.requestedDate;
global.MobileAPI.request("/api/themes?trade_date=" + encodeURIComponent(date)).then(function (payload) {
if (seq !== state.seq) return;
state.themes = payload;
const meta = payload.meta || {};
state.requestedDate = displayCompactDate(meta.trade_date || date);
updateHeader(findLabel(state.key) || state.key, state.requestedDate);
renderThemes();
}).catch(function (error) {
if (seq !== state.seq) return;
renderError(error && error.message ? error.message : "题材库加载失败");
});
}
function renderThemes() {
const scroll = document.getElementById("m-scroll");
if (!scroll) return;
const payload = state.themes;
if (!payload) return;
const summary = payload.summary || {};
const items = payload.items || [];
const metrics = [
["收录题材", number(summary.theme_count), "个", ""],
["当日上涨", number(summary.up_count), "个", "up"],
["当日下跌", number(summary.down_count), "个", "down"],
["人气题材", number(summary.hot_count), "个", "warn"],
].map(function (m) {
return '<div class="m-sum-metric' + (m[3] ? " m-sum-" + m[3] : "") + '"><span>' + escapeHtml(m[0]) + "</span><strong>" + m[1] + '<small>' + escapeHtml(m[2]) + "</small></strong></div>";
}).join("");
const list = items.length ? '<div class="m-theme-list">' + items.map(function (item, index) {
return '<button class="m-theme-item" type="button" data-theme-code="' + escapeHtml(item.code) + '">' +
'<span class="m-theme-rank">' + (index + 1) + "</span>" +
'<span class="m-theme-copy"><strong>' + escapeHtml(item.name) + "</strong>" +
"<small>" + number(item.member_count) + " 只成分" + (item.hot_rank ? " · 人气第 " + number(item.hot_rank) : "") + "</small></span>" +
'<b class="' + changeClass(item.change) + '">' + (item.has_quote ? (number(item.change) > 0 ? "+" : "") + formatNumber(number(item.change), 2) + "%" : "--") + "</b>" +
"</button>";
}).join("") + "</div>" : emptyHtml();
scroll.innerHTML = '<div class="m-sum-strip">' + metrics + "</div>" + list;
}
function loadThemeDetail(code) {
const seq = nextSeq();
const url = "/api/themes/detail?code=" + encodeURIComponent(code) + "&trade_date=" + encodeURIComponent(state.requestedDate);
global.MobileAPI.request(url).then(function (payload) {
if (seq !== state.seq) return;
state.themesDetail = payload;
renderThemeDetailSheet(payload);
}).catch(function (error) {
if (seq !== state.seq) return;
const body = document.getElementById("m-theme-detail-body");
if (body) body.innerHTML = errorHtml(error.message || "题材详情加载失败");
});
}
function openThemeDetailSheet(code) {
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-theme-detail-body">' + skeletonHtml(6) + "</div>",
{ detail: true }
);
loadThemeDetail(code);
}
function renderThemeDetailSheet(payload) {
const body = document.getElementById("m-theme-detail-body");
if (!body) return;
const theme = payload.theme || {};
const summary = payload.summary || {};
const meta = payload.meta || {};
const metrics = [
["成分股", number(summary.member_count) + " 只", ""],
["有行情", number(summary.quoted_count) + " 只", ""],
["上涨", number(summary.up_count) + " 只", "up"],
["下跌", number(summary.down_count) + " 只", "down"],
["换手率", formatNumber(number(theme.turnover_rate), 2) + "%", ""],
].map(function (m) {
return '<div class="m-sum-metric' + (m[2] ? " m-sum-" + m[2] : "") + '"><span>' + escapeHtml(m[0]) + "</span><strong>" + m[1] + "</strong></div>";
}).join("");
body.innerHTML =
'<div class="m-theme-detail-head"><strong>' + escapeHtml(theme.name || "--") + "</strong>" +
'<b class="' + changeClass(theme.change) + '">' + (number(theme.change) > 0 ? "+" : "") + formatNumber(number(theme.change), 2) + "%</b></div>" +
'<div class="m-theme-detail-code">' + escapeHtml(theme.code || "--") + " · " + escapeHtml(displayCompactDate(meta.trade_date) || "--") + "</div>" +
'<div class="m-sum-strip">' + metrics + "</div>" +
'<div class="m-section-title">成分股</div>' +
tableScrollHtml("market/themes/members", payload.members || [], { noLink: true });
}
/* ----- 龙虎榜 ----- */
function loadDragon() {
const seq = nextSeq();
const date = state.requestedDate;
global.MobileAPI.request("/api/dragon-tiger?trade_date=" + encodeURIComponent(date)).then(function (payload) {
if (seq !== state.seq) return;
state.dragon = payload;
const meta = payload.meta || {};
state.requestedDate = displayCompactDate(meta.trade_date || date);
updateHeader(findLabel(state.key) || state.key, state.requestedDate);
renderDragon();
}).catch(function (error) {
if (seq !== state.seq) return;
renderError(error && error.message ? error.message : "龙虎榜加载失败");
});
}
function loadDragonProfiles() {
const seq = nextSeq();
global.MobileAPI.request("/api/dragon-tiger/profiles").then(function (payload) {
if (seq !== state.seq) return;
state.dragonProfiles = payload;
renderDragonProfiles();
}).catch(function (error) {
if (seq !== state.seq) return;
const scroll = document.getElementById("m-scroll");
if (scroll) scroll.innerHTML = errorHtml(error.message || "游资档案加载失败");
});
}
function dragonTraders() {
const payload = state.dragon || {};
return (payload.traders || []).filter(function (item) {
return item.identity_type === "trader" && item.recognized !== false;
});
}
function renderDragon() {
const scroll = document.getElementById("m-scroll");
if (!scroll) return;
const payload = state.dragon;
if (!payload) return;
const summary = payload.summary || {};
const status = payload.meta && payload.meta.status;
const traders = dragonTraders();
const unclassified = payload.unclassified_seats || [];
const unavailable = ["error", "unavailable"].indexOf(status) >= 0;
const empty = status === "empty";
if (unavailable || (empty && !traders.length && !unclassified.length)) {
scroll.innerHTML = '<div class="m-state m-motion-rise-in"><span class="m-state-icon">' + icon("inbox", 26) + "</span>" +
"<p>" + escapeHtml(unavailable ? "龙虎榜数据暂不可用" : payload.meta.trade_date + " 暂无龙虎榜明细") + "</p>" +
"<small>龙虎榜明细通常在盘后陆续披露,可稍后刷新或查看前一交易日。</small></div>";
return;
}
const modeTabs = '<div class="m-source-tabs" role="tablist" aria-label="龙虎榜视图">' +
'<button class="m-source-tab active" type="button" data-action="dragon-mode" data-mode="daily">当日</button>' +
'<button class="m-source-tab" type="button" data-action="dragon-mode" data-mode="profiles">游资档案</button>' +
"</div>";
const metrics = [
["上榜游资", number(summary.trader_count) + " 位", ""],
["操作明细", number(summary.operation_count) + " 条", ""],
["席位净买入", formatMoneyMillion(summary.seat_net_buy_million), changeClass(summary.seat_net_buy_million)],
["活跃股票", number(summary.active_stock_count) + " 只", ""],
].map(function (m) {
return '<div class="m-sum-metric' + (m[2] ? " m-sum-" + m[2] : "") + '"><span>' + escapeHtml(m[0]) + "</span><strong>" + m[1] + "</strong></div>";
}).join("");
const cards = traders.map(function (trader, index) {
const desc = trader.description || number(trader.stock_count) + " 只股票," + number(trader.operation_count) + " 笔操作";
return '<button class="m-dragon-card" type="button" data-dragon-trader="' + escapeHtml(trader.id) + '">' +
'<span class="m-dragon-card-rank">' + String(index + 1).padStart(2, "0") + "</span>" +
'<span class="m-dragon-card-mono">' + escapeHtml(trader.name.slice(0, 2)) + "</span>" +
'<span class="m-dragon-card-copy"><strong>' + escapeHtml(trader.name) + "</strong>" +
"<small>" + escapeHtml(desc) + "</small></span>" +
'<span class="m-dragon-card-stats"><small>' + number(trader.stock_count) + " 股 · " + number(trader.operation_count) + " 笔</small>" +
'<b class="' + changeClass(trader.net_buy_million) + '">' + formatMoneyMillion(trader.net_buy_million) + "</b></span></button>";
}).join("");
scroll.innerHTML = modeTabs +
'<div class="m-sum-strip">' + metrics + "</div>" +
'<div class="m-section-title">上榜游资</div>' +
'<div class="m-dragon-list">' + (cards || emptyHtml()) + "</div>" +
(unclassified.length ? '<div class="m-dragon-unclassified">另有 ' + number(unclassified.length) + " 个待归类席位</div>" : "");
}
function renderDragonProfiles() {
const scroll = document.getElementById("m-scroll");
if (!scroll) return;
const payload = state.dragonProfiles;
if (!payload) return;
const profiles = payload.profiles || [];
const summary = payload.summary || {};
const modeTabs = '<div class="m-source-tabs" role="tablist" aria-label="龙虎榜视图">' +
'<button class="m-source-tab" type="button" data-action="dragon-mode" data-mode="daily">当日</button>' +
'<button class="m-source-tab active" type="button" data-action="dragon-mode" data-mode="profiles">游资档案</button>' +
"</div>";
const metrics = [
["收录游资", number(summary.profile_count), ""],
["已有简介", number(summary.described_count), ""],
["关联席位", number(summary.organization_count), ""],
].map(function (m) {
return '<div class="m-sum-metric"><span>' + escapeHtml(m[0]) + "</span><strong>" + m[1] + "</strong></div>";
}).join("");
const list = profiles.length ? '<div class="m-profile-list">' + profiles.map(function (profile, index) {
return '<button class="m-profile-row" type="button" data-profile-id="' + escapeHtml(profile.id) + '">' +
'<span class="m-profile-mono">' + escapeHtml(profile.name.slice(0, 2)) + "</span>" +
'<span class="m-profile-copy"><strong>' + escapeHtml(profile.name) + "</strong>" +
"<small>" + escapeHtml(profile.description || "暂未收录简介") + "</small></span>" +
'<span class="m-profile-seat">' + number(profile.organization_count) + " 席</span></button>";
}).join("") + "</div>" : emptyHtml();
scroll.innerHTML = modeTabs + '<div class="m-sum-strip">' + metrics + "</div>" +
'<div class="m-section-title">游资名录(收录 ' + number(summary.profile_count) + " 位)</div>" + list;
}
function openDragonTraderSheet(id) {
const payload = state.dragon || {};
const traders = dragonTraders();
const trader = traders.find(function (t) { return t.id === id; });
if (!trader) return;
const ops = trader.operations || [];
openSheet(
'<div class="m-sheet-head m-detail-head"><div class="m-detail-head-left">' +
'<span class="m-detail-head-name">' + escapeHtml(trader.name) + "</span>" +
'<span class="m-detail-head-code">' + number(trader.stock_count) + " 股 · " + number(trader.operation_count) + " 笔</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-dragon-ops-body">' +
'<div class="m-dragon-trader-totals">' +
totRow("买入", trader.buy_million, "up") +
totRow("卖出", trader.sell_million, "down") +
totRow("净额", trader.net_buy_million, changeClass(trader.net_buy_million)) +
"</div>" +
tableScrollHtml("market/dragon/operations", ops, { noLink: true }) +
"</div>",
{ detail: true }
);
}
function totRow(label, value, cls) {
return '<div class="m-total"><span>' + escapeHtml(label) + "</span><b class='" + cls + "'>" + formatMoneyMillion(value) + "</b></div>";
}
function openProfileSheet(id) {
const profiles = (state.dragonProfiles && state.dragonProfiles.profiles) || [];
const profile = profiles.find(function (p) { return p.id === id; });
if (!profile) return;
const orgs = profile.organizations || [];
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">' +
'<div class="m-profile-detail-head"><span class="m-profile-mono">' + escapeHtml(profile.name.slice(0, 2)) + "</span>" +
"<div><strong>" + escapeHtml(profile.name) + "</strong><small>" + (orgs.length ? "关联 " + orgs.length + " 个公开席位" : "暂无关联席位") + "</small></div></div>" +
'<div class="m-section-title">人物简介</div>' +
'<p class="m-profile-desc">' + escapeHtml(profile.description || "名录暂未收录该游资的公开简介。") + "</p>" +
(orgs.length ? '<div class="m-section-title">关联营业部</div><div class="m-profile-orgs">' +
orgs.map(function (o) { return "<span>" + escapeHtml(o) + "</span>"; }).join("") + "</div>" : "") +
(payloadNotice(state.dragonProfiles) || "") +
"</div>",
{ detail: false }
);
}
function payloadNotice(payload) {
const meta = payload && payload.meta;
return meta && meta.notice ? '<p class="m-profile-notice">' + escapeHtml(meta.notice) + "</p>" : "";
}
/* ----- complex page registry ----- */
function setupComplexPage(key) {
resetComplexState(key);
document.getElementById("m-view").classList.add("m-view-feature");
updateHeader(findLabel(key) || key, state.requestedDate);
document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(8)));
}
const COMPLEX_PAGES = {
"market/sentiment": setupComplexPage,
"market/ladder": setupComplexPage,
"market/rotation": setupComplexPage,
"market/auction": setupComplexPage,
"market/themes": setupComplexPage,
"market/dragon": setupComplexPage,
};
const COMPLEX_LOADERS = {
"market/sentiment": loadSentiment,
"market/ladder": loadLadder,
"market/rotation": loadRotation,
"market/auction": loadAuction,
"market/themes": loadThemes,
"market/dragon": loadDragon,
};
function isComplexPage(key) {
return Boolean(COMPLEX_PAGES[key]);
}
/* ---------------------------------------------------------------- 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();
reloadCurrent();
}
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 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>' +
'<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 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 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 || "其他");
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-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>" +
'<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>';
}
/* ---------------------------------------------------------------- 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 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 */
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) { reloadCurrent(); return; }
const sortHead = event.target.closest("[data-sort-key]");
if (sortHead) { toggleSort(sortHead.dataset.sortKey); return; }
const rangeTab = event.target.closest("[data-sentiment-range]");
if (rangeTab) {
state.sentimentRange = number(rangeTab.dataset.sentimentRange) || 20;
document.querySelectorAll("[data-sentiment-range]").forEach(function (tab) {
const active = number(tab.dataset.sentimentRange) === state.sentimentRange;
tab.classList.toggle("active", active);
tab.setAttribute("aria-pressed", String(active));
});
loadSentiment();
return;
}
const auctionDataset = event.target.closest("[data-action=auction-dataset]");
if (auctionDataset) {
state.auctionDataset = auctionDataset.dataset.dataset || "focus";
document.querySelectorAll("[data-action=auction-dataset]").forEach(function (tab) {
const active = tab.dataset.dataset === state.auctionDataset;
tab.classList.toggle("active", active);
tab.setAttribute("aria-selected", String(active));
});
renderAuctionTable();
return;
}
const dragonMode = event.target.closest("[data-action=dragon-mode]");
if (dragonMode) {
const mode = dragonMode.dataset.mode;
state.dragonViewMode = mode === "profiles" ? "profiles" : "daily";
if (mode === "profiles") loadDragonProfiles();
else if (state.dragon) renderDragon();
else loadDragon();
return;
}
const ladderStock = event.target.closest("[data-ladder-stock]");
if (ladderStock) { openDetailSheet(ladderStock.dataset.ladderStock); return; }
const ladderExpand = event.target.closest("[data-ladder-expand]");
if (ladderExpand) {
const level = number(ladderExpand.dataset.ladderExpand);
if (expandedLadder[level]) delete expandedLadder[level];
else expandedLadder[level] = true;
renderLadder();
return;
}
const rotationSector = event.target.closest("[data-rotation-sector]");
if (rotationSector) {
openRotationMembersSheet(rotationSector.dataset.rotationSector, rotationSector.dataset.rotationDate);
return;
}
const themeCode = event.target.closest("[data-theme-code]");
if (themeCode) { openThemeDetailSheet(themeCode.dataset.themeCode); return; }
const dragonTrader = event.target.closest("[data-dragon-trader]");
if (dragonTrader) { openDragonTraderSheet(dragonTrader.dataset.dragonTrader); return; }
const profileId = event.target.closest("[data-profile-id]");
if (profileId) { openProfileSheet(profileId.dataset.profileId); 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";
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;
}
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) || isComplexPage(key)); },
};
})(window);