(function (global) { "use strict"; /* ---------------------------------------------------------------- helpers */ function number(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; } function escapeHtml(value) { return String(value == null ? "" : value).replace(/[&<>"']/g, function (ch) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]; }); } function formatNumber(value, digits) { return new Intl.NumberFormat("zh-CN", { minimumFractionDigits: digits, maximumFractionDigits: digits, }).format(number(value)); } function changeClass(value) { const n = number(value); return n > 0 ? "up" : n < 0 ? "down" : ""; } function streakLabel(streak) { const value = Math.max(1, number(streak)); return value === 1 ? "首板" : value + "板"; } function displayCompactDate(value) { const text = String(value || "").replaceAll("-", ""); if (text.length !== 8) return value || "--"; return text.slice(0, 4) + "-" + text.slice(4, 6) + "-" + text.slice(6, 8); } function localDateString(value) { const year = value.getFullYear(); const month = String(value.getMonth() + 1).padStart(2, "0"); const day = String(value.getDate()).padStart(2, "0"); return year + "-" + month + "-" + day; } function todayString() { return localDateString(new Date()); } function parseLocalDate(value) { const parts = String(value || "").split("-").map(Number); return new Date(parts[0], (parts[1] || 1) - 1, parts[2] || 1); } function addDays(value, delta) { const date = parseLocalDate(value); date.setDate(date.getDate() + delta); return localDateString(date); } function previousWeekday(value) { let date = parseLocalDate(value); date.setDate(date.getDate() - 1); while (date.getDay() === 0 || date.getDay() === 6) date.setDate(date.getDate() - 1); return localDateString(date); } const ICONS = { calendar: '', close: '', "chevron-left": '', "chevron-right": '', inbox: '', }; function icon(name, size) { const body = ICONS[name] || ""; return '"; } const TRIANGLE_UP = ''; const TRIANGLE_DOWN = ''; function sortIndicatorHtml(active) { if (active) { const up = state.sort.dir === "asc"; return '"; } return '"; } 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 ''; } return ''; } /* ---------------------------------------------------------------- state */ const state = { key: "", requestedDate: "", dashboard: null, popularity: null, popularitySource: "combined", seq: 0, calCursor: null, sort: { key: "", dir: null }, detail: null, }; let sheetToken = 0; function pageConfig(key) { const cfg = global.MobileNav && global.MobileNav.tableColumns ? global.MobileNav.tableColumns[key] : null; return cfg || null; } function columnsFor(cfg) { if (cfg.columns) return cfg.columns[state.popularitySource] || cfg.columns.combined; return cfg; } 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) { if (col.width) return col.width; if (col.type === "rank") return 40; if (col.type === "stock") return 96; if (col.wide) return 140; if (col.type === "text" || col.type === "concepts" || col.type === "outcome" || col.type === "dual") return 96; return 72; } function colAlign(col) { if (col.type === "rank") return "m-align-center"; if (["change", "price", "rate", "money", "gap", "int", "streak", "advance", "height", "move"].indexOf(col.type) >= 0) return "m-align-right"; return "m-align-left"; } function stockCell(row) { return '' + '' + escapeHtml(row.name || "--") + "" + '' + escapeHtml(row.code || "") + "" + ""; } function rankCell(row, index) { const value = number(row.rank) > 0 ? number(row.rank) : index + 1; const hot = index < 3 ? '' : ""; return '' + value + "" + hot + ""; } function streakCell(value) { const n = number(value); if (n <= 0) return ""; const high = n >= 4 ? " is-high" : ""; return '' + streakLabel(n) + ""; } function numCell(value, digits, signed) { if (value == null || value === "") return ''; const n = number(value); const sign = signed && n > 0 ? "+" : ""; return '' + sign + formatNumber(n, digits) + ""; } function changeCell(value) { if (value == null || value === "") return ''; const n = number(value); return '' + (n > 0 ? "+" : "") + formatNumber(n, 2) + ""; } function intCell(value, hideZero) { const n = Math.round(number(value)); if (hideZero && n === 0) return ''; if (value == null || value === "") return ''; return '' + n.toLocaleString("zh-CN") + ""; } function advanceCell(value) { const n = number(value); const cls = n === 0 ? "is-neutral" : n < 20 ? "is-warning" : "is-active"; return '' + formatNumber(n, 1) + ""; } function outcomeCell(value) { const map = { "晋级": "advance", "断板": "fail", "炸板": "broken", "跌停": "down" }; const cls = map[value] || "fail"; return '' + escapeHtml(value || "") + ""; } function heightCell(value) { const n = number(value); if (n <= 0) return ""; return '' + n + ""; } function moveCell(value) { const n = value == null ? null : number(value); if (n == null) return ''; if (n > 0) return '\u2191' + n + ""; if (n < 0) return '\u2193' + Math.abs(n) + ""; return '持平'; } function conceptsCell(value) { const list = Array.isArray(value) ? value : []; const text = list.slice(0, 3).join("、"); return '' + escapeHtml(text) + ""; } function dualCell(value) { const dual = Boolean(value); return '' + (dual ? "双榜共识" : "单榜入选") + ""; } function textCell(value) { const text = String(value == null ? "" : value); return '' + escapeHtml(text) + ""; } function cellHtml(col, row, index) { const value = row[col.key]; switch (col.type) { case "stock": return stockCell(row); case "rank": return rankCell(row, index); case "streak": return streakCell(value); case "change": return changeCell(value); case "price": return numCell(value, 2); case "rate": return numCell(value, 2); case "money": return numCell(value, 2); case "gap": return numCell(value, 2); case "int": return intCell(value, col.hideZero); case "advance": return advanceCell(value); case "outcome": return outcomeCell(value); case "height": return heightCell(value); case "move": return moveCell(value); case "concepts": return conceptsCell(value); case "dual": return dualCell(value); case "text": default: return textCell(value); } } /* ---------------------------------------------------------------- table */ function buildTable(cols, rows) { const frozen = cols.frozenColumns || []; const primary = cols.primaryColumns || []; const scroll = cols.scrollColumns || []; const ordered = frozen.concat(primary, scroll); let left = 0; const frozenLeft = frozen.map(function (col) { const offset = left; left += columnWidth(col); return offset; }); function cellOpen(col, index, isHead, 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) + '' + escapeHtml(col.label) + indicator + ""; }).join(""); const body = rows.map(function (row, rIndex) { const cells = ordered.map(function (col, i) { return cellOpen(col, i, false) + cellHtml(col, row, rIndex) + ""; }).join(""); const code = /^\d{6}$/.test(String(row.code || "")) ? ' data-code="' + escapeHtml(row.code) + '"' : ""; return "" + cells + ""; }).join(""); return '' + head + "" + body + "
"; } function skeletonHtml(rowCount) { const rows = []; for (let i = 0; i < rowCount; i += 1) { rows.push('
'); } return '"; } function emptyHtml() { return '
' + '' + icon("inbox", 26) + "" + "

该交易日暂无相关数据

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

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

" + '' + "
"; } /* ---------------------------------------------------------------- strip */ function stripSkeleton() { const cells = []; for (let i = 0; i < 7; i += 1) { cells.push('
'); } return '"; } 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 '
' + '' + escapeHtml(label) + "" + '' + valueHtml + "
"; } 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 ? "--" : "" + score + "") + (phase ? '' + escapeHtml(phase) + "" : ""); const amount = amountLabel(overview.amount_billion); const amountHtml = "" + escapeHtml(amount.value) + "" + (amount.unit ? '' + escapeHtml(amount.unit) + "" : ""); const seal = overview.seal_rate != null ? formatNumber(overview.seal_rate, 1) + "%" : "--"; const cells = []; cells.push(stripCell("情绪", emotionHtml, "", "")); cells.push(stripCell("涨停", "" + intOrDash(overview.limit_up_count) + "", "up", "")); cells.push(stripCell("跌停", "" + intOrDash(overview.limit_down_count) + "", "down", "")); cells.push(stripCell("炸板", "" + intOrDash(overview.broken_count) + "", "warn", "")); cells.push(stripCell("封板率", "" + seal + "", "", "")); cells.push(stripCell("成交额", amountHtml, "", amount.compact ? "m-strip-sm" : "")); cells.push(stripCell("最高连板", "" + (maxStreak > 0 ? maxStreak : "--") + "", "up", "")); return '
' + cells.join("") + "
"; } /* ---------------------------------------------------------------- rows */ function brokenLimitRate(row) { const name = String(row.name || "").toUpperCase(); const code = String(row.code || "").replace(/\D/g, ""); if (name.indexOf("ST") >= 0) return 10; if (/^(300|301|688|689)/.test(code)) return 20; if (/^(4|8|92)/.test(code)) return 30; return 10; } function prepareRows(key) { const dash = state.dashboard || {}; if (key === "market/limit-up") return dash.limits || []; if (key === "market/broken") { return (dash.broken || []).map(function (row) { row.limitGap = Math.max(0, brokenLimitRate(row) - number(row.change)); return row; }); } if (key === "market/limit-down") return dash.down_limits || []; if (key === "market/yesterday") return dash.yesterday_limits || []; if (key === "market/performance") return normalizePerformanceRows(dash.limit_performance || []); if (key === "market/popularity") { return state.popularity ? (state.popularity[state.popularitySource] || []) : []; } return []; } function normalizePerformanceRows(rows) { const groups = {}; (rows || []).forEach(function (row) { const level = Math.max(1, number(row.level)); const displayLevel = Math.min(level, 5); const group = groups[displayLevel] || { level: displayLevel, label: displayLevel === 1 ? "昨日首板" : displayLevel === 5 ? "昨日5板+" : "昨日" + displayLevel + "板", count: 0, advanced: 0, positive: 0, changeTotal: 0, }; const count = number(row.count); group.count += count; group.advanced += number(row.advanced); group.positive += count * number(row.positive_rate) / 100; group.changeTotal += count * number(row.average_change); groups[displayLevel] = group; }); return Object.keys(groups) .map(Number) .sort(function (a, b) { return b - a; }) .map(function (level) { const group = groups[level]; return { level: group.level, label: group.label, count: group.count, advanced: group.advanced, advance_rate: group.count ? group.advanced / group.count * 100 : 0, positive_rate: group.count ? group.positive / group.count * 100 : 0, average_change: group.count ? group.changeTotal / group.count : 0, }; }); } function performanceConclusion() { const overview = (state.dashboard && state.dashboard.overview) || {}; const up = number(overview.up_count); const down = number(overview.down_count); const breadthRate = up + down > 0 ? up / (up + down) * 100 : 50; const stance = breadthRate < 25 ? "宜守不宜攻" : breadthRate < 45 ? "控制仓位,聚焦核心" : "保持精选,跟随强势梯队"; const phase = overview.sentiment_phase || "观察"; return '
结论:' + escapeHtml(stance) + ",当前情绪周期「" + escapeHtml(phase) + "」。
"; } /* ---------------------------------------------------------------- render */ function updateHeader(title, dateText) { if (global.MobileRouter && global.MobileRouter.updateHeader) { global.MobileRouter.updateHeader({ title: title, back: true, actions: dateButtonHtml(dateText) }); } } function dateButtonHtml(dateText) { return '"; } function sourceTabsHtml(cfg) { if (!cfg.sources || !cfg.sources.length) return ""; const labels = { combined: "双榜综合", ths: "同花顺", dc: "东方财富" }; return '
' + cfg.sources.map(function (source) { const active = source === state.popularitySource; return '"; }).join("") + "
"; } function renderPage(key) { const cfg = pageConfig(key); if (!cfg) return; state.key = key; state.requestedDate = todayString(); state.dashboard = null; state.popularity = null; state.popularitySource = "combined"; state.calCursor = null; 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 = '
' + '
' + buildStrip() + "
" + sourceTabs + '
' + skeletonHtml(8) + "
" + "
"; document.getElementById("m-view").innerHTML = content; load(); } function findLabel(key) { const hubs = global.MobileNav && global.MobileNav.hubs ? global.MobileNav.hubs : {}; for (const hubKey in hubs) { const items = hubs[hubKey].items || []; for (const item of items) { if (item.key === key) return item.label; } } return key; } function load() { const key = state.key; const cfg = pageConfig(key); if (!cfg) return; const seq = ++state.seq; const requestedDate = state.requestedDate; const 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"); if (scroll) scroll.innerHTML = errorHtml(message); const page = document.querySelector(".m-page"); if (page) { const top = page.querySelector(".m-top"); if (top) top.remove(); } } /* ---------------------------------------------------------------- sheets */ function ensureSheetRoot() { let root = document.getElementById("m-sheet-root"); if (!root) { root = document.createElement("div"); root.id = "m-sheet-root"; root.className = "m-sheet-root"; document.getElementById("m-app").appendChild(root); } return root; } function openSheet(content, opts) { const root = ensureSheetRoot(); sheetToken += 1; root.innerHTML = '
' + '"; global.requestAnimationFrame(function () { root.classList.add("is-open"); }); bindSheetDrag(root); } function closeSheet() { const root = document.getElementById("m-sheet-root"); if (!root) return; const token = sheetToken; root.classList.remove("is-open"); global.setTimeout(function () { if (sheetToken === token && !root.classList.contains("is-open")) root.innerHTML = ""; }, 340); } function bindSheetDrag(root) { const sheet = root.querySelector(".m-sheet"); const handle = root.querySelector(".m-sheet-handle"); if (!sheet || !handle) return; let startY = 0; let dragging = false; handle.addEventListener("touchstart", function (event) { dragging = true; startY = event.touches[0].clientY; sheet.style.transition = "none"; }, { passive: true }); handle.addEventListener("touchmove", function (event) { if (!dragging) return; const dy = event.touches[0].clientY - startY; if (dy > 0) sheet.style.transform = "translateY(" + dy + "px)"; }, { passive: true }); handle.addEventListener("touchend", function (event) { if (!dragging) return; dragging = false; sheet.style.transition = ""; const dy = event.changedTouches[0].clientY - startY; if (dy > sheet.offsetHeight / 3) { closeSheet(); } else { sheet.style.transform = ""; } }); } function selectDate(dateStr) { if (!dateStr) return; state.requestedDate = dateStr; closeSheet(); load(); } function openDateSheet() { state.calCursor = { year: parseLocalDate(state.requestedDate).getFullYear(), month: parseLocalDate(state.requestedDate).getMonth(), }; openSheet( '
' + '

选择日期

' + '" + "
" + '
', { detail: false } ); renderDateSheetBody(); } function renderDateSheetBody() { const body = document.getElementById("m-date-sheet-body"); if (!body || !state.calCursor) return; const year = state.calCursor.year; const month = state.calCursor.month; const today = todayString(); const now = parseLocalDate(today); const currentMonth = now.getFullYear() * 12 + now.getMonth(); const cursorMonth = year * 12 + month; const prevDisabled = cursorMonth <= currentMonth - 12; const nextDisabled = cursorMonth >= currentMonth; const quick = [ { label: "今天", date: todayString() }, { label: "昨天", date: addDays(today, -1) }, { label: "前一交易日", date: previousTradeDate() }, ]; const quickHtml = quick.map(function (item) { return '"; }).join(""); body.innerHTML = '
' + quickHtml + "
" + '
' + '" + '' + year + " 年 " + (month + 1) + " 月" + '" + "
" + '
' + ["一", "二", "三", "四", "五", "六", "日"].map(function (d) { return "" + d + ""; }).join("") + "
" + '
' + calendarCells(year, month) + "
"; } function calendarCells(year, month) { const first = new Date(year, month, 1); const startWeekday = (first.getDay() + 6) % 7; const daysInMonth = new Date(year, month + 1, 0).getDate(); const today = todayString(); const cells = []; for (let i = 0; i < startWeekday; i += 1) cells.push(''); for (let d = 1; d <= daysInMonth; d += 1) { const date = new Date(year, month, d); const dateStr = localDateString(date); const dow = date.getDay(); const disabled = dow === 0 || dow === 6 || dateStr > today; const selected = dateStr === state.requestedDate; cells.push( '" ); } return cells.join(""); } function previousTradeDate() { const dash = state.dashboard || {}; if (dash.meta && dash.meta.previous_trade_date) return displayCompactDate(dash.meta.previous_trade_date); if (state.popularity && state.popularity.meta && state.popularity.meta.previous_trade_date) { return displayCompactDate(state.popularity.meta.previous_trade_date); } return previousWeekday(todayString()); } function openDetailSheet(code) { openSheet( '
' + '
' + '--' + '' + escapeHtml(code) + "" + "
" + '
' + '" + '" + "
" + '
' + '
' + '
' + '
' + '
' + "
" + "
", { detail: true } ); loadDetail(code); } function loadDetail(code) { const token = sheetToken; global.MobileAPI.request("/api/stock/" + encodeURIComponent(code) + "/preview").then(function (payload) { if (token !== sheetToken) return; renderDetail(payload); }).catch(function (error) { if (token !== sheetToken) return; const body = document.getElementById("m-detail-sheet-body"); if (body) body.innerHTML = errorHtml(error && error.message ? error.message : "行情预览加载失败"); }); } function 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 = '
' + '
' + '' + (price ? formatNumber(price, 2) : "--") + "" + '' + (change > 0 ? "+" : "") + formatNumber(change, 2) + "%" + "
" + '
' + escapeHtml(industry) + " · " + escapeHtml(displayCompactDate(meta.trade_date) || "--") + " 收盘
" + "
" + '
' + '' + '' + '' + escapeHtml(chartCaptionHtml("intraday")) + "" + "
" + '
' + chartHtml + "
" + '
点按图面可读数值 · 数据来源:本地行情
'; } /* ---------------------------------------------------------------- charts */ function emptyChart(message) { return '
' + escapeHtml(message || "数据暂不可用") + "
"; } function svgAxisText(x, y, anchor, cls, content) { return '' + escapeHtml(content) + ""; } 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 ? '' : ""; } 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 '"; } 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 '' + ''; }).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 '" + '"; } /* ---------------------------------------------------------------- events */ function bindEvents() { window.addEventListener("hashchange", closeSheet); document.addEventListener("click", function (event) { const dateBtn = event.target.closest("[data-action=date]"); if (dateBtn) { openDateSheet(); return; } const retry = event.target.closest("[data-action=retry]"); if (retry) { load(); return; } const 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"; 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)); }, }; })(window);