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 '