diff --git a/app/frontend/m/css/features.css b/app/frontend/m/css/features.css index 2fa98d4..0258783 100644 --- a/app/frontend/m/css/features.css +++ b/app/frontend/m/css/features.css @@ -46,44 +46,108 @@ font-variant-numeric: tabular-nums; } -/* 结论条 */ -.m-summary { - display: flex; - gap: 8px; - padding: 12px 12px 8px; - overflow-x: auto; +/* 顶栏七项单行数据栏 */ +.m-top { flex: 0 0 auto; - -webkit-overflow-scrolling: touch; } -.m-summary-item { - flex: 0 0 auto; - min-width: 96px; +.m-strip { display: flex; - flex-direction: column; - gap: 2px; - padding: 10px 12px; + align-items: stretch; + margin: 10px 12px; + height: 54px; border-radius: 8px; background: var(--surface); box-shadow: var(--elevation-card); - transition: background-color var(--motion-pop) var(--ease-press), - border-color var(--motion-pop) var(--ease-press); + overflow: hidden; } -.m-summary-item span { - font-size: var(--font-size-caption); +.m-strip-cell { + position: relative; + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + padding: 0 2px; +} + +.m-strip-cell + .m-strip-cell::before { + content: ""; + position: absolute; + left: 0; + top: 13px; + bottom: 13px; + width: 1px; + background: var(--border); +} + +.m-strip-label { + font-size: 10px; + font-weight: 400; + line-height: 1; color: var(--text-secondary); + white-space: nowrap; } -.m-summary-item strong { - font-size: var(--font-size-metric); +.m-strip-value { + display: inline-flex; + align-items: baseline; + gap: 2px; + font-size: 12px; font-weight: 600; + line-height: 1.15; font-variant-numeric: tabular-nums; color: var(--text-primary); + white-space: nowrap; } -.m-summary-item.is-up strong { color: var(--market-up); } -.m-summary-item.is-down strong { color: var(--market-down); } +.m-strip-value b { font-weight: 600; } + +.m-strip-phase { + font-size: 9px; + font-weight: 400; + color: var(--text-tertiary); +} + +.m-strip-unit { + font-size: 9.5px; + font-weight: 400; + color: var(--text-tertiary); +} + +.m-strip-up .m-strip-value { color: var(--market-up); } +.m-strip-down .m-strip-value { color: var(--market-down); } +.m-strip-warn .m-strip-value { color: var(--warning); } + +.m-strip-sm .m-strip-value { font-size: 11px; } +.m-strip-sm .m-strip-unit { font-size: 8.5px; } + +.m-strip--skeleton .m-strip-cell { justify-content: center; } + +.m-strip-skeleton-bar { + width: 60%; + height: 20px; + border-radius: 4px; + background: var(--surface-muted); + animation: m-skeleton-breathe 1200ms ease-in-out infinite; +} + +@media (max-width: 359px) { + .m-strip { + margin-left: 10px; + margin-right: 10px; + } + + .m-strip-label { font-size: 9.5px; } + .m-strip-value { font-size: 10px; } + .m-strip-phase { font-size: 8px; } + .m-strip-unit { font-size: 9px; } + .m-strip-sm .m-strip-value { font-size: 9px; } + .m-strip-sm .m-strip-unit { font-size: 8px; } +} .m-conclusion { padding: 4px 12px 0; @@ -204,6 +268,49 @@ .m-align-left { text-align: left; } .m-align-center { text-align: center; } +/* 列头排序 */ +.m-th-inner { + display: inline-flex; + align-items: center; + gap: 3px; + max-width: 100%; +} + +.m-th.m-sortable { + cursor: pointer; + -webkit-tap-highlight-color: transparent; + user-select: none; +} + +.m-th.m-sortable:active { + background: var(--surface-hover); +} + +.m-sort { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1px; + line-height: 0; + color: var(--text-tertiary); + opacity: 0.38; +} + +.m-sort svg { + display: block; +} + +.m-th.is-sorted .m-th-inner { + color: var(--action); + font-weight: 600; +} + +.m-th.is-sorted .m-sort { + color: var(--action); + opacity: 1; +} + /* 单元格内容 */ .m-stock { display: flex; @@ -474,19 +581,6 @@ font-weight: 600; } -.m-sheet-head-title { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.m-sheet-head-code { - font-size: var(--font-size-label); - color: var(--text-tertiary); - font-variant-numeric: tabular-nums; -} - .m-sheet-close { display: inline-flex; align-items: center; @@ -636,6 +730,68 @@ } /* 详情 Sheet */ +.m-detail-head-left { + flex: 1; + min-width: 0; + display: flex; + align-items: baseline; + gap: 8px; + overflow: hidden; +} + +.m-detail-head-name { + font-size: var(--font-size-page-title); + font-weight: 600; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.m-detail-head-code { + font-size: var(--font-size-label); + color: var(--text-tertiary); + font-weight: 400; + font-variant-numeric: tabular-nums; +} + +.m-detail-head-right { + display: inline-flex; + align-items: center; + flex: 0 0 auto; +} + +.m-detail-star { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--mobile-touch-size); + height: var(--mobile-touch-size); + flex: 0 0 var(--mobile-touch-size); + border: 0; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--motion-press-release) var(--ease-press), + color var(--motion-pop) var(--ease-press), + background-color var(--motion-press-release) var(--ease-press); +} + +.m-detail-star:active { + background: var(--surface-hover); + transform: scale(0.94); + transition-duration: var(--motion-press); +} + +.m-detail-star.is-added { + color: var(--warning); +} + +.m-detail-star:disabled { + opacity: 0.5; + cursor: default; +} + .m-detail-top { display: flex; flex-direction: column; @@ -644,21 +800,6 @@ border-bottom: 1px solid var(--border-subtle); } -.m-detail-name { - display: flex; - align-items: baseline; - gap: 8px; - font-size: var(--font-size-page-title); - font-weight: 600; -} - -.m-detail-code { - font-size: var(--font-size-label); - color: var(--text-tertiary); - font-weight: 400; - font-variant-numeric: tabular-nums; -} - .m-detail-quote { display: flex; align-items: baseline; @@ -688,9 +829,54 @@ color: var(--text-secondary); } +.m-detail-tabs { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 0 8px; +} + +.m-detail-tab { + height: 32px; + padding: 0 14px; + border: 1px solid var(--border-strong); + border-radius: 999px; + background: var(--surface); + color: var(--text-secondary); + font-size: var(--font-size-label); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: transform var(--motion-press-release) var(--ease-press), + background-color var(--motion-press-release) var(--ease-press), + color var(--motion-pop) var(--ease-press); +} + +.m-detail-tab:active { + background: var(--surface-hover); + transform: scale(0.96); + transition-duration: var(--motion-press); +} + +.m-detail-tab.active { + background: var(--action-soft); + border-color: var(--action); + color: var(--action); + font-weight: 600; +} + +.m-detail-chart-caption { + margin-left: auto; + font-size: var(--font-size-aux); + color: var(--text-tertiary); + text-align: right; +} + .m-detail-chart { - margin-top: 12px; - height: 96px; + position: relative; + height: var(--mobile-chart-height); + border-radius: 8px; + background: var(--surface-subtle); + overflow: hidden; } .m-detail-chart svg { @@ -705,6 +891,118 @@ color: var(--text-tertiary); } +/* 行情图 SVG */ +.m-chart-empty { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-label); + color: var(--text-tertiary); +} + +.m-chart-line { + fill: none; +} + +.m-chart-price { + stroke: var(--action); + stroke-width: 1.5; +} + +.m-chart-avg { + stroke: var(--warning); + stroke-width: 1; +} + +.m-chart-prev-close { + stroke: var(--text-tertiary); + stroke-width: 1; + stroke-dasharray: 4 3; + opacity: 0.6; +} + +.m-chart-axis { + font-size: 10px; + fill: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} + +.m-chart-axis.is-up { fill: var(--market-up); } +.m-chart-axis.is-down { fill: var(--market-down); } + +.m-chart-candle { + stroke-width: 1; +} + +.m-chart-candle.is-up { + stroke: var(--chart-up); + fill: var(--chart-up); +} + +.m-chart-candle.is-down { + stroke: var(--chart-down); + fill: var(--chart-down); +} + +.m-chart-ma { + fill: none; + stroke-width: 1.2; +} + +.m-chart-ma.is-ma5 { stroke: var(--warning); } +.m-chart-ma.is-ma10 { stroke: var(--action); } + +.m-chart-legend { + position: absolute; + top: 6px; + left: 10px; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 10px; + color: var(--text-secondary); + pointer-events: none; +} + +.m-chart-legend-ma5::before, +.m-chart-legend-ma10::before { + content: ""; + display: inline-block; + width: 10px; + height: 2px; + margin-right: 3px; + border-radius: 1px; + vertical-align: middle; +} + +.m-chart-legend-ma5::before { background: var(--warning); } +.m-chart-legend-ma10::before { background: var(--action); } + +/* 自选提示条 */ +.m-toast { + position: absolute; + left: 50%; + bottom: 72px; + transform: translateX(-50%) translateY(8px); + padding: 10px 16px; + border-radius: 8px; + background: var(--text-primary); + color: var(--canvas); + font-size: var(--font-size-label); + opacity: 0; + pointer-events: none; + white-space: nowrap; + z-index: 130; + transition: opacity var(--motion-fade) var(--ease-press), + transform var(--motion-fade) var(--ease-press); +} + +.m-toast.is-visible { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + /* 减少动画降级 */ @media (prefers-reduced-motion: reduce) { .m-sheet { @@ -729,7 +1027,9 @@ .m-source-tab:active, .m-quick:active, .m-cal-cell:active, - .m-sheet-close:active { + .m-sheet-close:active, + .m-detail-tab:active, + .m-detail-star:active { transform: none; } } diff --git a/app/frontend/m/js/pages.js b/app/frontend/m/js/pages.js index 77c9e03..d018ba0 100644 --- a/app/frontend/m/js/pages.js +++ b/app/frontend/m/js/pages.js @@ -79,6 +79,25 @@ 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 = { @@ -89,6 +108,8 @@ popularitySource: "combined", seq: 0, calCursor: null, + sort: { key: "", dir: null }, + detail: null, }; let sheetToken = 0; @@ -103,6 +124,72 @@ return cfg; } + function orderedColumns(cols) { + return (cols.frozenColumns || []).concat(cols.primaryColumns || [], cols.scrollColumns || []); + } + + function findColumn(cols, key) { + const ordered = orderedColumns(cols); + for (let i = 0; i < ordered.length; i += 1) { + if (ordered[i].key === key) return ordered[i]; + } + return null; + } + + function isNumericType(type) { + return ["rank", "change", "price", "rate", "money", "gap", "int", "streak", "advance", "height", "move"].indexOf(type) >= 0; + } + + function sortableColumn(col) { + return col.type !== "stock"; + } + + function resetSortIfInvalid(cols) { + const active = state.sort.key; + if (active && !findColumn(cols, active)) { + state.sort = { key: "", dir: null }; + } + } + + function sortedRows(rows, cols) { + resetSortIfInvalid(cols); + const key = state.sort.key; + const dir = state.sort.dir; + if (!key || !dir) return rows; + const col = findColumn(cols, key); + if (!col || !sortableColumn(col)) return rows; + const numeric = isNumericType(col.type); + const factor = dir === "asc" ? 1 : -1; + return rows.slice().sort(function (a, b) { + if (numeric) { + const av = number(a[key]); + const bv = number(b[key]); + if (av === bv) return 0; + return (av - bv) * factor; + } + const av = String(a[key] == null ? "" : a[key]); + const bv = String(b[key] == null ? "" : b[key]); + return av.localeCompare(bv, "zh-CN") * factor; + }); + } + + function toggleSort(key) { + const cfg = pageConfig(state.key); + if (!cfg) return; + const cols = columnsFor(cfg); + const col = findColumn(cols, key); + if (!col || !sortableColumn(col)) return; + const firstDir = isNumericType(col.type) ? "desc" : "asc"; + if (state.sort.key !== key) { + state.sort = { key: key, dir: firstDir }; + } else if (state.sort.dir === firstDir) { + state.sort = { key: key, dir: firstDir === "desc" ? "asc" : "desc" }; + } else { + state.sort = { key: "", dir: null }; + } + renderTableBody(); + } + /* ---------------------------------------------------------------- cells */ function columnWidth(col) { @@ -240,17 +327,27 @@ return offset; }); - function cellOpen(col, index, isHead) { + function cellOpen(col, index, isHead, extraCls, extraAttrs) { const isFrozen = index < frozen.length; const width = columnWidth(col); const clamp = col.type === "text" || col.type === "concepts" ? ";max-width:" + width + "px" : ""; const style = "min-width:" + width + "px" + clamp + (isFrozen ? ";width:" + width + "px;left:" + frozenLeft[index] + "px" : ""); - const cls = (isHead ? "m-th" : "m-td") + " " + colAlign(col) + (isFrozen ? " m-frozen" : ""); - return '<' + (isHead ? "th" : "td") + ' class="' + cls + '" style="' + style + '">'; + const cls = (isHead ? "m-th" : "m-td") + " " + colAlign(col) + (isFrozen ? " m-frozen" : "") + (extraCls ? " " + extraCls : ""); + return '<' + (isHead ? "th" : "td") + ' class="' + cls + '" style="' + style + '"' + (extraAttrs || "") + '>'; } const head = ordered.map(function (col, i) { - return cellOpen(col, i, true) + escapeHtml(col.label) + (i < frozen.length ? "" : ""); + 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) { @@ -287,76 +384,62 @@ ""; } - /* ---------------------------------------------------------------- summary */ + /* ---------------------------------------------------------------- strip */ - function summaryCard(label, value, tone) { - const cls = tone === "up" ? " is-up" : tone === "down" ? " is-down" : ""; - return '
' + escapeHtml(label) + "" + escapeHtml(value) + "
"; - } - - function buildSummary(key, cfg) { - const dash = state.dashboard || {}; - const overview = dash.overview || {}; - const cards = []; - - if (cfg.summary === "limit") { - const limits = dash.limits || []; - const maxStreak = limits.reduce(function (m, r) { return Math.max(m, number(r.streak)); }, 0); - const seal = overview.seal_rate != null ? formatNumber(overview.seal_rate, 1) + "%" : "--"; - cards.push(summaryCard("涨停家数", limits.length, "up")); - cards.push(summaryCard("最高连板", maxStreak ? maxStreak + " 板" : "--")); - cards.push(summaryCard("封板率", seal)); - } else if (cfg.summary === "broken") { - const broken = dash.broken || []; - const limits = dash.limits || []; - const seal = overview.seal_rate != null ? formatNumber(overview.seal_rate, 1) + "%" : "--"; - cards.push(summaryCard("炸板家数", broken.length, "down")); - cards.push(summaryCard("涨停家数", overview.limit_up_count != null ? overview.limit_up_count : limits.length, "up")); - cards.push(summaryCard("封板率", seal)); - } else if (cfg.summary === "down") { - const downs = dash.down_limits || []; - const maxStreak = downs.reduce(function (m, r) { return Math.max(m, number(r.streak)); }, 0); - const cluster = downSectorCluster(downs); - cards.push(summaryCard("跌停家数", downs.length, "down")); - cards.push(summaryCard("连续跌停", maxStreak ? maxStreak + " 天" : "--")); - cards.push(summaryCard("集中板块", cluster || "--")); - } else if (cfg.summary === "yesterday") { - const rows = dash.yesterday_limits || []; - const total = rows.length; - const advance = rows.filter(function (r) { return r.outcome === "晋级"; }).length; - const positive = rows.filter(function (r) { return number(r.current_change) > 0; }).length; - const risk = rows.filter(function (r) { return ["炸板", "跌停"].indexOf(r.outcome) >= 0; }).length; - const rate = function (v) { return total ? formatNumber(v / total * 100, 1) + "%" : "--"; }; - cards.push(summaryCard("晋级率", rate(advance), "up")); - cards.push(summaryCard("兑现率", rate(positive), "up")); - cards.push(summaryCard("亏钱效应", rate(risk), "down")); - } else if (cfg.summary === "performance") { - const up = number(overview.up_count); - const down = number(overview.down_count); - const ratio = up + down > 0 ? formatNumber(up / (up + down) * 100, 1) + "%" : "--"; - cards.push(summaryCard("上涨", up + " 家", "up")); - cards.push(summaryCard("下跌", down + " 家", "down")); - cards.push(summaryCard("红盘率", ratio, "up")); - } else { - return ""; + function stripSkeleton() { + const cells = []; + for (let i = 0; i < 7; i += 1) { + cells.push('
'); } - - if (!cards.length) return ""; - return '
' + cards.join("") + "
"; + return '"; } - function downSectorCluster(rows) { - const counts = {}; - rows.forEach(function (row) { - const sector = String(row.sector || "其他").trim() || "其他"; - if (sector === "其他") return; - counts[sector] = (counts[sector] || 0) + 1; - }); - let cluster = null; - Object.keys(counts).forEach(function (sector) { - if (!cluster || counts[sector] > cluster.count) cluster = { sector: sector, count: counts[sector] }; - }); - return cluster && cluster.count >= 2 ? cluster.sector + " \u00d7" + cluster.count : ""; + function amountLabel(amount) { + if (amount == null || amount === "") return { value: "--", unit: "", compact: false }; + const n = number(amount); + if (n >= 10000) { + return { value: formatNumber(n / 10000, 2), unit: "万亿", compact: n >= 100000 }; + } + return { value: Math.round(n).toLocaleString("zh-CN"), unit: "亿", compact: false }; + } + + function stripCell(label, valueHtml, tone, extraCls) { + return '
' + + '' + 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 */ @@ -470,15 +553,16 @@ state.popularity = null; state.popularitySource = "combined"; state.calCursor = null; + state.sort = { key: "", dir: null }; + state.detail = null; document.getElementById("m-view").classList.add("m-view-feature"); const title = findLabel(key) || key; updateHeader(title, state.requestedDate); - const summary = buildSummary(key, cfg); const sourceTabs = sourceTabsHtml(cfg); const content = '
' + - summary + + '
' + buildStrip() + "
" + sourceTabs + '
' + skeletonHtml(8) + "
" + "
"; @@ -504,18 +588,26 @@ if (!cfg) return; const seq = ++state.seq; const requestedDate = state.requestedDate; - const url = key === "market/popularity" - ? "/api/popularity?trade_date=" + encodeURIComponent(requestedDate) - : "/api/dashboard?trade_date=" + encodeURIComponent(requestedDate); + const popularity = key === "market/popularity"; - global.MobileAPI.request(url).then(function (payload) { + const dashboardUrl = "/api/dashboard?trade_date=" + encodeURIComponent(requestedDate); + const popUrl = "/api/popularity?trade_date=" + encodeURIComponent(requestedDate); + + const request = popularity + ? Promise.all([global.MobileAPI.request(popUrl), global.MobileAPI.request(dashboardUrl)]) + : global.MobileAPI.request(dashboardUrl).then(function (payload) { return [payload]; }); + + request.then(function (results) { if (seq !== state.seq) return; - if (key === "market/popularity") { - state.popularity = payload; + let meta = {}; + if (popularity) { + state.popularity = results[0]; + state.dashboard = results[1]; + meta = (results[0] && results[0].meta) || {}; } else { - state.dashboard = payload; + state.dashboard = results[0]; + meta = (results[0] && results[0].meta) || {}; } - const meta = payload.meta || {}; state.requestedDate = displayCompactDate(meta.requested_date || meta.trade_date || requestedDate); const title = findLabel(key) || key; updateHeader(title, state.requestedDate); @@ -527,8 +619,16 @@ } function renderData(key, cfg) { + renderTableBody(); + renderTopArea(key); + } + + function renderTableBody() { + const key = state.key; + const cfg = pageConfig(key); + if (!cfg) return; const cols = columnsFor(cfg); - const rows = prepareRows(key); + const rows = sortedRows(prepareRows(key), cols); const scroll = document.getElementById("m-table-scroll"); if (!scroll) return; @@ -538,35 +638,40 @@ scroll.innerHTML = buildTable(cols, rows); } - const page = document.querySelector(".m-page"); - if (!page) return; - - let summary = buildSummary(key, cfg); - if (key === "market/performance" && summary) summary += performanceConclusion(); - - const existingSummary = page.querySelector(".m-summary"); - if (existingSummary) { - existingSummary.outerHTML = summary || ""; - } else if (summary) { - const tabs = page.querySelector(".m-source-tabs"); - const tmp = document.createElement("div"); - tmp.innerHTML = summary; - const node = tmp.firstChild; - page.insertBefore(node, tabs || scroll); - } - scroll.classList.remove("m-motion-fade-in"); void scroll.offsetWidth; scroll.classList.add("m-motion-fade-in"); } + function renderTopArea(key) { + const page = document.querySelector(".m-page"); + if (!page) return; + let top = page.querySelector(".m-top"); + let html = buildStrip(); + if (key === "market/performance") html += performanceConclusion(); + if (!top) { + top = document.createElement("div"); + top.className = "m-top"; + const tabs = page.querySelector(".m-source-tabs"); + const scroll = document.getElementById("m-table-scroll"); + page.insertBefore(top, tabs || scroll); + } + top.innerHTML = html; + const strip = top.querySelector(".m-strip"); + if (strip) { + strip.classList.remove("m-motion-fade-in"); + void strip.offsetWidth; + strip.classList.add("m-motion-fade-in"); + } + } + function renderError(message) { const scroll = document.getElementById("m-table-scroll"); if (scroll) scroll.innerHTML = errorHtml(message); const page = document.querySelector(".m-page"); if (page) { - const summary = page.querySelector(".m-summary"); - if (summary) summary.remove(); + const top = page.querySelector(".m-top"); + if (top) top.remove(); } } @@ -722,10 +827,15 @@ function openDetailSheet(code) { openSheet( - '
' + - '
' + escapeHtml(code) + '
' + - '" + + '
' + + '
' + + '--' + + '' + escapeHtml(code) + "" + "
" + + '
' + + '" + + '" + + "
" + '
' + '
' + '
' + @@ -750,50 +860,287 @@ }); } + function chartCaptionHtml(tab) { + const detail = state.detail; + const payload = detail && detail.payload ? detail.payload : {}; + const meta = payload.meta || {}; + if (tab === "daily") { + const bars = (payload.prices || []).slice(-48); + const last = bars.length ? bars[bars.length - 1].trade_date : ""; + return "日线 · 近48根 · 至 " + (displayCompactDate(last) || "--"); + } + const d = displayCompactDate(meta.intraday_trade_date) || displayCompactDate(meta.trade_date); + return "分时 · " + (d || "--"); + } + + function switchChartTab(tab) { + const detail = state.detail; + if (!detail || !detail.payload) return; + detail.tab = tab; + const payload = detail.payload; + const meta = payload.meta || {}; + const chart = document.getElementById("m-detail-chart"); + if (chart) { + if (tab === "daily") { + chart.innerHTML = dailyChart(payload); + } else if (meta.intraday_status === "available") { + chart.innerHTML = intradayChart(payload); + } else { + chart.innerHTML = emptyChart(meta.intraday_notice || "分时数据暂不可用"); + } + } + const caption = document.getElementById("m-detail-caption"); + if (caption) caption.textContent = chartCaptionHtml(tab); + document.querySelectorAll(".m-detail-tab").forEach(function (btn) { + const active = btn.dataset.chartTab === tab; + btn.classList.toggle("active", active); + btn.setAttribute("aria-selected", String(active)); + }); + } + + function updateStarButton() { + const btn = document.querySelector(".m-detail-star"); + if (!btn) return; + const watched = Boolean(state.detail && state.detail.watchlist); + btn.classList.toggle("is-added", watched); + btn.setAttribute("aria-label", watched ? "移出自选" : "加入自选"); + btn.setAttribute("aria-pressed", String(watched)); + btn.innerHTML = starIcon(watched); + btn.disabled = false; + } + + function toggleWatch() { + const detail = state.detail; + if (!detail || !detail.code) return; + const btn = document.querySelector(".m-detail-star"); + const adding = !detail.watchlist; + const code = detail.code; + const url = adding ? "/api/watchlist" : "/api/watchlist/" + encodeURIComponent(code); + const method = adding ? "POST" : "DELETE"; + const body = adding ? { code: code, name: detail.name || code, sector: detail.sector || "" } : null; + if (btn) btn.disabled = true; + global.MobileAPI.request(url, method, body).then(function () { + detail.watchlist = adding; + updateStarButton(); + showToast(adding ? "已加入自选" : "已移出自选"); + }).catch(function () { + updateStarButton(); + showToast("操作失败,请重试"); + }); + } + + function showToast(message) { + const root = document.getElementById("m-sheet-root"); + if (!root) return; + let toast = document.getElementById("m-toast"); + if (!toast) { + toast = document.createElement("div"); + toast.id = "m-toast"; + toast.className = "m-toast"; + root.appendChild(toast); + } + toast.textContent = message; + toast.classList.remove("is-visible"); + void toast.offsetWidth; + toast.classList.add("is-visible"); + global.clearTimeout(toast._timer); + toast._timer = global.setTimeout(function () { + toast.classList.remove("is-visible"); + }, 1500); + } + function renderDetail(payload) { const body = document.getElementById("m-detail-sheet-body"); if (!body) return; const stock = payload.stock || {}; - const prices = payload.prices || []; const meta = payload.meta || {}; const name = stock.name && stock.name !== "--" ? stock.name : "--"; const price = number(stock.price); const change = number(stock.change); const industry = stock.industry && stock.industry !== "其他" ? stock.industry : (stock.sector || "其他"); - const spark = sparklineHtml(prices); + state.detail = { + code: stock.code || "", + name: name === "--" ? "" : name, + sector: industry === "其他" ? "" : industry, + watchlist: Boolean(stock.watchlist), + tab: "intraday", + payload: payload, + }; + + const headName = document.querySelector(".m-detail-head-name"); + const headCode = document.querySelector(".m-detail-head-code"); + if (headName) headName.textContent = name; + if (headCode) headCode.textContent = stock.code || ""; + updateStarButton(); + + const intradayOk = meta.intraday_status === "available"; + const chartHtml = intradayOk ? intradayChart(payload) : emptyChart(meta.intraday_notice || "分时数据暂不可用"); body.innerHTML = '
' + - '
' + escapeHtml(name) + '' + escapeHtml(stock.code || "") + "
" + '
' + '' + (price ? formatNumber(price, 2) : "--") + "" + '' + (change > 0 ? "+" : "") + formatNumber(change, 2) + "%" + "
" + - '
' + escapeHtml(industry) + " · " + escapeHtml(displayCompactDate(meta.trade_date) || "--") + "
" + + '
' + escapeHtml(industry) + " · " + escapeHtml(displayCompactDate(meta.trade_date) || "--") + " 收盘
" + "
" + - spark + - '
' + escapeHtml(meta.notice || "数据来源:本地行情") + "
"; + '
' + + '' + + '' + + '' + escapeHtml(chartCaptionHtml("intraday")) + "" + + "
" + + '
' + chartHtml + "
" + + '
点按图面可读数值 · 数据来源:本地行情
'; } - function sparklineHtml(prices) { - const closes = (prices || []).map(function (bar) { return number(bar.close); }).filter(function (v) { return v > 0; }); - if (closes.length < 2) return ""; - const min = Math.min.apply(null, closes); - const max = Math.max.apply(null, closes); - const range = max - min || 1; - const width = 320; - const height = 96; - const step = width / (closes.length - 1); - const points = closes.map(function (value, i) { - const x = Math.round(i * step); - const y = Math.round(height - (value - min) / range * height); - return x + "," + y; + /* ---------------------------------------------------------------- charts */ + + function emptyChart(message) { + return '
' + 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 up = closes[closes.length - 1] >= closes[0]; - return '
"; + + 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 */ @@ -808,20 +1155,24 @@ const retry = event.target.closest("[data-action=retry]"); if (retry) { load(); return; } + const sortHead = event.target.closest("[data-sort-key]"); + if (sortHead) { toggleSort(sortHead.dataset.sortKey); return; } + + const watchBtn = event.target.closest("[data-action=watch]"); + if (watchBtn) { toggleWatch(); return; } + + const chartTab = event.target.closest("[data-chart-tab]"); + if (chartTab) { switchChartTab(chartTab.dataset.chartTab); return; } + const sourceTab = event.target.closest("[data-action=source]"); if (sourceTab) { state.popularitySource = sourceTab.dataset.source || "combined"; - const cfg = pageConfig(state.key); - if (cfg) { - const rows = prepareRows(state.key); - const scroll = document.getElementById("m-table-scroll"); - if (scroll) scroll.innerHTML = rows.length ? buildTable(columnsFor(cfg), rows) : emptyHtml(); - document.querySelectorAll(".m-source-tab").forEach(function (tab) { - const active = tab.dataset.source === state.popularitySource; - tab.classList.toggle("active", active); - tab.setAttribute("aria-selected", String(active)); - }); - } + renderTableBody(); + document.querySelectorAll(".m-source-tab").forEach(function (tab) { + const active = tab.dataset.source === state.popularitySource; + tab.classList.toggle("active", active); + tab.setAttribute("aria-selected", String(active)); + }); return; }