(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": '', "chevron-down": '', inbox: '', trash: '', bot: '', filter: '', target: '', "refresh-cw": '', plus: '', search: '', send: '', square: '', "alert-triangle": '', check: '', "check-check": '', star: '', "scroll-text": '', "calendar-check": '', "sticky-note": '', bell: '', }; 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 }, sortTable: { cols: null, reapply: 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: "", chat: { page: "", messages: [], mentorId: "", mentorName: "", mentorTagline: "", mentorGrade: "", mentorFocus: [], mentorSetup: null, followUps: [], streaming: false, streamController: null, streamBuffer: "", streamBubble: null, loading: false, historyLoaded: false, aborter: null, }, screener: { data: null, date: "", view: "latest", strategyId: "", strategyName: "", loading: false, }, tracking: { data: null, loading: false, refreshing: false, }, review: { subKey: "", watchlist: null, trades: null, notes: null, dailyItems: null, alerts: null, alertStatus: "all", form: null, dirty: false, }, }; let sheetToken = 0; function pageConfig(key) { const cfg = global.MobileNav && global.MobileNav.tableColumns ? global.MobileNav.tableColumns[key] : null; return cfg || null; } function defaultSortForKey(key) { if (key === "market/limit-up") return { key: "streak", dir: "desc" }; if (key === "market/limit-down") return { key: "streak", dir: "desc" }; return { key: "", dir: 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 active = state.sortTable; if (!active || !active.cols) return; const col = findColumn(active.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 }; } active.reapply(); } /* ---------------------------------------------------------------- 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 '' + '' + 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 scoreCell(value) { if (value == null || value === "") return ''; return '' + formatNumber(number(value), 1) + ""; } function expectationCell(value) { if (value == null || value === "") return ""; const map = { "超预期": "above", "符合预期": "matched", "低于预期": "below", "竞价一字": "one" }; return '' + escapeHtml(value) + ""; } function directionCell(value) { if (value == null || value === "") return ""; const map = { "买入": "up", "卖出": "down", "持平": "flat" }; return '' + escapeHtml(value) + ""; } 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 = !(opts && opts.noSort) && 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 linkable = !(opts && opts.noLink); const code = linkable && /^\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) { 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 = defaultSortForKey(key); 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); state.sortTable = { cols: cols, reapply: renderTableBody }; 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 mountSortableTable(containerId, subKey, rowsProvider, opts) { const cols = columnsForKey(subKey); if (!cols) return; const reapply = function () { const container = document.getElementById(containerId); if (!container) return; const rows = sortedRows(rowsProvider() || [], cols); if (!rows.length) { container.innerHTML = emptyHtml(); return; } container.innerHTML = buildTable(cols, rows, opts || {}); }; state.sortTable = { cols: cols, reapply: reapply }; reapply(); } function complexFrame(key, bodyHtml) { return '
' + bodyHtml + "
"; } function complexScroll(bodyHtml) { return '
' + bodyHtml + "
"; } function resetComplexState(key) { state.key = key; state.requestedDate = todayString(); state.dashboard = null; state.popularity = null; state.calCursor = null; state.sort = { key: "", dir: null }; state.sortTable = { cols: null, reapply: 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 '' + escapeHtml(phase) + ""; } 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 = 168, padL = 8, padR = 30, padT = 10, padB = 20; 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 '"; } /* ----- 情绪周期 ----- */ 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 '
' + items.map(function (item) { const score = clampScore(item.score); return '
' + '
' + escapeHtml(item.label) + "" + "" + formatNumber(number(item.score), 1) + "
" + '
' + '' + escapeHtml(item.summary || "") + " · 权重 " + number(item.weight) + "%" + "
"; }).join("") + "
"; } 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 rangeTabs = [20, 40, 60].map(function (n) { const active = state.sentimentRange === n; return '"; }).join(""); let card = ""; if (latest) { const score = number(latest.score); const dayChange = number(latest.day_change); const components = latest.components || overview.sentiment_components || {}; card = '
' + '
' + score + "" + '
' + phaseBadgeHtml(latest.phase) + '' + escapeHtml(latest.direction) + "
" + '
' + escapeHtml(latest.label || "") + "
" + '
' + 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") + "
" + '

' + escapeHtml(sentimentAdvice(latest.phase)) + "

" + "
" + '
情绪趋势(近 ' + rawRows.length + ' 日)' + rangeTabs + "
" + '
' + sentimentTrendChart(rawRows) + "
" + '
五维分项
' + sentimentComponentList(components); } scroll.innerHTML = card + '
历史明细
' + '
'; mountSortableTable("m-sentiment-hist", "market/sentiment/history", function () { const raw = (state.sentimentHistory && state.sentimentHistory.rows) || []; return raw.slice().reverse().map(function (r) { return Object.assign({}, r, { trade_date: displayCompactDate(r.trade_date) }); }); }, { noSort: true }); } function sentMetric(label, valueHtml, tone) { return '
' + escapeHtml(label) + "" + valueHtml + "
"; } function sentimentAdvice(phase) { return { "冰点": "情绪处于极弱区,先观察风险释放。", "修复": "风险开始收敛,关注率先转强的核心。", "发酵": "主线与梯队正在形成,优先跟随核心。", "高潮": "情绪处高位,聚焦核心并主动降低后排暴露。", "分化": "强弱开始分层,关注承接与回流。", "退潮": "情绪指标继续走弱,控制仓位。", }[phase] || "市场结构尚未形成清晰阶段,保持观察。"; } /* ----- 市场天梯 ----- */ const expandedLadder = {}; function loadLadder() { const seq = nextSeq(); Object.keys(expandedLadder).forEach(function (level) { delete expandedLadder[level]; }); 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 ? '较' + (level - 1) + "板 " + rate + "%" : ""; let stocksHtml = ""; let foldBtn = ""; if (gap) { stocksHtml = '
' + (level >= maxLevel ? "断层 · " + escapeHtml(label) + "及以上空缺" : "该层暂时空缺") + "
"; } 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 '"; }).join(""); if (cap && stocks.length > cap) { const remaining = stocks.length - cap; foldBtn = '"; } } return '
' + '
' + escapeHtml(label) + "" + '' + count + " 只" + rateHtml + foldBtn + "
" + '
' + stocksHtml + "
"; } 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 spaceChange = prevMax && maxLevel < prevMax ? "较昨日 " + prevMax + " 板 ↓ 压缩" : prevMax && maxLevel > prevMax ? "较昨日 " + prevMax + " 板 ↑ 抬升" : "高度与昨日接近"; const spaceNote = maxLevel >= 5 ? "高位梯队仍有辨识度,重点看承接。" : maxLevel >= 3 ? "空间位于中段,梯队延续性比绝对高度更重要。" : "高度受压缩,先看首板向二板的结构修复。"; const apexHtml = '
' + '
空间板' + (maxLevel ? maxLevel + " 板" : "--") + "" + escapeHtml(spaceChange) + "
" + "" + escapeHtml(spaceNote) + "
"; 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 '
' + escapeHtml(p.label || "昨日" + number(p.level) + "板") + "" + '' + formatNumber(value, 1) + "%
"; }).join(""); const perfHtml = perfRows ? '
晋级率参考(昨日梯队 → 今日)
' + perfRows + "
" : ""; 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 '"; }).join(""); return '
' + '
" + "" + sectors.length + " 个热点
" + '
' + chips + "
"; }).join(""); } function loadRotationMembers(sector, date) { const seq = nextSeq(); const url = "/api/rotation/members?trade_date=" + encodeURIComponent(date) + "§or=" + 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( '

' + escapeHtml(sector) + "成分股

" + '
" + '
' + skeletonHtml(8) + "
", { 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 metaLine = '
' + escapeHtml(displayCompactDate(meta.trade_date) || "--") + " · " + number(meta.quoted_count) + " / " + number(meta.member_count) + " 只
"; body.innerHTML = metaLine + '
'; mountSortableTable("m-rotation-members-table", "market/rotation/members", function () { return (state.rotationMembers && state.rotationMembers.rows) || []; }, {}); } /* ----- 竞价 ----- */ 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 '
历史竞价量能尚未形成
'; const max = Math.max.apply(null, history.map(function (h) { return number(h.amount_billion); })) || 1; return '
' + history.map(function (h, i) { const hgt = Math.max(8, number(h.amount_billion) / max * 100); const current = i === history.length - 1 ? " current" : ""; return '
' + '' + escapeHtml(String(h.trade_date || "").slice(5)) + "
"; }).join("") + "
"; } function auctionThemeCarry(carry) { if (!carry || !carry.length) return ""; const tone = { "强承接": "strong", "有承接": "steady", "分歧": "mixed", "承接弱": "weak" }; return '
' + carry.slice(0, 6).map(function (item) { return '
' + '' + escapeHtml(item.name) + "" + '' + escapeHtml(item.leader || "--") + " · 昨 " + number(item.prior_limit_count) + " 只" + '' + escapeHtml(item.status) + "" + '' + (item.median_change == null ? "暂无候选" : (number(item.median_change) > 0 ? "+" : "") + formatNumber(number(item.median_change), 2) + "%") + "
"; }).join("") + "
"; } 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 '
' + escapeHtml(copy[0]) + "" + escapeHtml(copy[1]) + "
"; } 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 '
' + escapeHtml(m[0]) + "" + m[1] + "
"; }).join(""); const tabs = AUCTION_DATASETS.map(function (d) { const active = state.auctionDataset === d.key; return '"; }).join(""); const carry = auctionThemeCarry(themes.carry); scroll.innerHTML = auctionPhaseNotice(meta) + '
' + metrics + "
" + '
竞价量能
' + auctionAmountTrend(payload.amount_history || []) + "
" + (carry ? '
昨日强势题材承接
' + carry + "
" : "") + '
' + tabs + "
" + '
'; renderAuctionTable(); } function renderAuctionTable() { mountSortableTable("m-auction-table", "market/auction", auctionRows, {}); } /* ----- 题材库 ----- */ 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 '
' + escapeHtml(m[0]) + "" + m[1] + '' + escapeHtml(m[2]) + "
"; }).join(""); const list = items.length ? '
' + items.map(function (item, index) { return '"; }).join("") + "
" : emptyHtml(); scroll.innerHTML = '
' + metrics + "
" + 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( '

题材详情

' + '
" + '
' + skeletonHtml(6) + "
", { 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 '
' + escapeHtml(m[0]) + "" + m[1] + "
"; }).join(""); body.innerHTML = '
' + escapeHtml(theme.name || "--") + "" + '' + (number(theme.change) > 0 ? "+" : "") + formatNumber(number(theme.change), 2) + "%
" + '
' + escapeHtml(theme.code || "--") + " · " + escapeHtml(displayCompactDate(meta.trade_date) || "--") + "
" + '
' + metrics + "
" + '
成分股
' + '
'; mountSortableTable("m-theme-members-table", "market/themes/members", function () { return (state.themesDetail && state.themesDetail.members) || []; }, {}); } /* ----- 龙虎榜 ----- */ 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 = '
' + icon("inbox", 26) + "" + "

" + escapeHtml(unavailable ? "龙虎榜数据暂不可用" : payload.meta.trade_date + " 暂无龙虎榜明细") + "

" + "龙虎榜明细通常在盘后陆续披露,可稍后刷新或查看前一交易日。
"; return; } const modeTabs = '
' + '' + '' + "
"; 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 '
' + escapeHtml(m[0]) + "" + m[1] + "
"; }).join(""); const cards = traders.map(function (trader, index) { const desc = trader.description || number(trader.stock_count) + " 只股票," + number(trader.operation_count) + " 笔操作"; return '"; }).join(""); scroll.innerHTML = modeTabs + '
' + metrics + "
" + '
上榜游资
' + '
' + (cards || emptyHtml()) + "
" + (unclassified.length ? '
另有 ' + number(unclassified.length) + " 个待归类席位
" : ""); } 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 = '
' + '' + '' + "
"; const metrics = [ ["收录游资", number(summary.profile_count), ""], ["已有简介", number(summary.described_count), ""], ["关联席位", number(summary.organization_count), ""], ].map(function (m) { return '
' + escapeHtml(m[0]) + "" + m[1] + "
"; }).join(""); const list = profiles.length ? '
' + profiles.map(function (profile, index) { return '"; }).join("") + "
" : emptyHtml(); scroll.innerHTML = modeTabs + '
' + metrics + "
" + '
游资名录(收录 ' + number(summary.profile_count) + " 位)
" + 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( '
' + '' + escapeHtml(trader.name) + "" + '' + number(trader.stock_count) + " 股 · " + number(trader.operation_count) + " 笔
" + '
" + '
' + '
' + totRow("买入", trader.buy_million, "up") + totRow("卖出", trader.sell_million, "down") + totRow("净额", trader.net_buy_million, changeClass(trader.net_buy_million)) + "
" + '
' + "
", { detail: true } ); mountSortableTable("m-dragon-ops-table", "market/dragon/operations", function () { return ops; }, {}); } function totRow(label, value, cls) { return '
' + escapeHtml(label) + "" + formatMoneyMillion(value) + "
"; } 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( '

游资档案

' + '
" + '
' + '
' + escapeHtml(profile.name.slice(0, 2)) + "" + "
" + escapeHtml(profile.name) + "" + (orgs.length ? "关联 " + orgs.length + " 个公开席位" : "暂无关联席位") + "
" + '
人物简介
' + '

' + escapeHtml(profile.description || "名录暂未收录该游资的公开简介。") + "

" + (orgs.length ? '
关联营业部
' + orgs.map(function (o) { return "" + escapeHtml(o) + ""; }).join("") + "
" : "") + (payloadNotice(state.dragonProfiles) || "") + "
", { detail: false } ); } function payloadNotice(payload) { const meta = payload && payload.meta; return meta && meta.notice ? '

' + escapeHtml(meta.notice) + "

" : ""; } /* ---------------------------------------------------------------- helpers shared by new pages */ function openConfirmSheet(title, body, options) { const opts = options || {}; const confirmLabel = opts.confirmLabel || "确定"; const cancelLabel = opts.cancelLabel || "取消"; const danger = Boolean(opts.danger); openSheet( '

' + escapeHtml(title) + '

' + '
' + '
' + (body ? '

' + escapeHtml(body) + '

' : '') + '
' + '' + '' + '
', { detail: false } ); const ok = document.querySelector('[data-confirm-ok]'); if (ok && typeof opts.onConfirm === 'function') { ok.addEventListener('click', function () { closeSheet(); opts.onConfirm(); }); } } function nextSeq() { state.seq += 1; return state.seq; } function renderStrip5(cells) { // cells: [{label, value, tone?}] — 5 cells for 跟踪/复盘汇总条;窄屏自适应 return '
' + cells.map(function (cell) { const tone = cell.tone ? ' m-strip-' + cell.tone : ''; return '
' + '' + escapeHtml(cell.label) + '' + '' + (cell.value || '--') + '' + '
'; }).join('') + '
'; } /* ============================================================== 智能选股 + 策略跟踪 ============================================================== */ const SCREENER_VIEW_LABELS = { latest: "最新候选", active: "持续有效", history: "入选历史" }; function screenerCurrentStrategy(payload) { return findScreenerStrategy(payload, state.screener.strategyId); } function screenerCurrentLibrary(payload) { if (!state.screener.strategyId) return "smart"; const s = findScreenerStrategy(payload, state.screener.strategyId); if (!s) return "smart"; return (s.formula && s.formula.meta && s.formula.meta.library) || "smart"; } function screenerLibraryMode(library) { if (library === "curated") return "curated"; if (library === "quant" || library === "custom") return "quant"; return "smart"; } // 按当前策略名在 recent_results / latest_results 中匹配最新一次运行(对齐电脑端 activeScreenerResultEntry) function screenerRunForCurrentStrategy(payload) { if (!payload) return null; const strategy = screenerCurrentStrategy(payload); if (!strategy || !strategy.name) return null; const name = strategy.name; const recent = (payload.recent_results || []).slice().reverse(); for (let i = 0; i < recent.length; i += 1) { const run = recent[i]; if (run && run.meta && run.meta.strategy_name === name) return run; } const latest = (payload.latest_results || {})[screenerLibraryMode(screenerCurrentLibrary(payload))]; if (latest && latest.meta && latest.meta.strategy_name === name) return latest; return null; } // 持续有效 / 入选历史:生产 /api/screener/setup 只返回 recent_results / latest_results, // 从 recent_results 里筛出当前策略(meta.strategy_name 匹配,与 screenerRunForCurrentStrategy 同口径)的运行,逐候选组装。 function screenerArchiveRows(payload, view) { if (!payload) return []; const strategy = screenerCurrentStrategy(payload); const strategyName = strategy ? strategy.name : ""; if (!strategyName) return []; const rows = []; (payload.recent_results || []).forEach(function (run) { if (!run || !run.meta || run.meta.strategy_name !== strategyName) return; const meta = run.meta; const active = screenerSignalActive(run, payload); if (view === "active" && !active) return; (run.candidates || []).forEach(function (c) { const code = String(c.code || ""); if (!code) return; rows.push({ code: code, name: c.name || "", sector: c.sector || "", pct_chg: c.pct_chg, return_5d: c.return_5d, volume_ratio_5d: c.volume_ratio_5d, sector_strength: c.sector_strength, historical_probability: c.historical_probability, score_display: c.score_display, selection_date: displayCompactDate(String(meta.trade_date || "")) }); }); }); rows.sort(function (a, b) { return String(b.selection_date || "").localeCompare(String(a.selection_date || "")); }); return rows; } // 信号是否仍在有效期:阶段/精选策略按「阶段不变即有效」(meta.regime == 当前阶段); // 量化策略按策略频率对应的交易日数,以 meta.trade_date 与数据日比较(无交易日历时按自然日近似)。 function screenerSignalActive(run, payload) { const meta = (run && run.meta) || {}; const mode = String(meta.mode || "smart"); if (mode === "smart" || mode === "curated") { const regime = payload && payload.regime ? String(payload.regime.id || "") : ""; return regime ? String(meta.regime || "") === regime : true; } const freq = String((((run && run.formula) || {}).meta || {}).frequency || "每日"); const days = { "每周": 5, "双周": 10, "月度": 20, "事件驱动": 5 }[freq] || 1; const selected = String(meta.trade_date || "").replaceAll("-", ""); const asOf = String((payload && payload.trade_date) || "").replaceAll("-", ""); if (!selected || !asOf || selected === asOf) return true; const start = parseLocalDate(displayCompactDate(selected)); const end = parseLocalDate(displayCompactDate(asOf)); const diff = Math.round((end.getTime() - start.getTime()) / 86400000); return diff >= 0 && diff < days; } function screenerViewKey(payload, view) { if (!payload) return []; if (view === "active") return screenerArchiveRows(payload, "active"); if (view === "history") return screenerArchiveRows(payload, "history"); const run = screenerRunForCurrentStrategy(payload); if (!run) return []; return run.candidates || []; } function screenerCurrentRun(payload) { if (!payload) return null; if (state.screener.view === "active") return null; if (state.screener.view === "history") return null; return screenerRunForCurrentStrategy(payload); } function screenerCurrentRunId() { const payload = state.screener.data; if (!payload) return 0; const run = screenerCurrentRun(payload); return run ? Number(run.run_id || 0) : 0; } function screenerFollowedCodes() { const tracking = state.tracking.data; if (!tracking || !tracking.batches) return {}; const map = {}; tracking.batches.forEach(function (batch) { (batch.items || []).forEach(function (item) { if (item && item.code) map[String(item.code)] = item; }); }); return map; } function findScreenerStrategy(payload, strategyId) { const list = (payload && payload.strategies) || []; if (!strategyId) return null; for (let i = 0; i < list.length; i += 1) { if (String(list[i].id) === String(strategyId)) return list[i]; } return null; } function findScreenerStrategyByName(payload, name) { const list = (payload && payload.strategies) || []; if (!name) return null; for (let i = 0; i < list.length; i += 1) { if (list[i].name === name) return list[i]; } return null; } function pickInitialScreenerStrategy(payload) { const list = (payload && payload.strategies) || []; // 默认选 smart 阶段策略 const stage = list.find(function (s) { const lib = s.formula && s.formula.meta && s.formula.meta.library; return lib === "smart"; }); if (stage) return stage; if (list.length) return list[0]; return null; } function setupScreenerPage(key) { document.getElementById("m-view").classList.add("m-view-feature"); state.screener = { data: null, date: todayString(), view: "latest", strategyId: "", strategyName: "", loading: false, }; updateHeader("智能选股", displayCompactDate(state.screener.date)); document.getElementById("m-actions").innerHTML = ''; document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(8))); } function loadScreener() { state.screener.loading = true; const seq = nextSeq(); const date = state.screener.date || todayString(); const url = "/api/screener/setup?trade_date=" + encodeURIComponent(date); global.MobileAPI.request(url).then(function (payload) { if (seq !== state.seq) return; state.screener.data = payload; state.screener.date = payload.requested_trade_date || payload.trade_date || date; // 自动选择默认策略(首次进入时) if (!state.screener.strategyId) { const def = pickInitialScreenerStrategy(payload); if (def) { state.screener.strategyId = def.id; state.screener.strategyName = def.name; } } updateHeader("智能选股", displayCompactDate(state.screener.date)); renderScreener(); }).catch(function (error) { if (seq !== state.seq) return; renderError(error && error.message ? error.message : "选股数据加载失败"); }).then(function () { state.screener.loading = false; }); } function renderScreener() { const scroll = document.getElementById("m-scroll"); if (!scroll) return; const payload = state.screener.data; if (!payload) { scroll.innerHTML = skeletonHtml(6); return; } const regime = payload.regime || {}; const stageLabel = regime.label || regime.id || "--"; const confidence = regime.confidence != null ? Math.round(Number(regime.confidence)) : null; const reason = regime.reason || regime.description || ""; const updatedAt = formatScreenerUpdatedAt(regime.updated_at); const factors = (payload.factor_data && payload.factor_data.date_count) || 0; const stageCard = '
' + '
' + '当前阶段' + '' + escapeHtml(updatedAt) + '' + '
' + '
' + '' + escapeHtml(stageLabel) + '' + (confidence != null ? '置信度 ' + confidence + '%' : '') + '
' + (reason ? '

' + escapeHtml(reason) + '

' : '') + '
'; // 视图切换 pill 行 const tabs = Object.keys(SCREENER_VIEW_LABELS).map(function (key) { const active = state.screener.view === key; return ''; }).join(""); const viewTabs = '
' + tabs + '
'; // 当前策略胶囊行 const strategyName = state.screener.strategyName || "选择策略"; const statusText = screenerStrategyStatusText(payload); const strategyRow = ''; // 表格容器 const tableWrap = '
'; const foot = '

结果由盘后自动更新 · 自定义策略请在电脑端新建/编译

'; scroll.innerHTML = stageCard + viewTabs + strategyRow + tableWrap + foot; renderScreenerTable(); } function screenerStrategyStatusText(payload) { if (!state.screener.strategyId) return "选择策略"; const strategy = findScreenerStrategy(payload, state.screener.strategyId); if (strategy && strategy.published_run) { const pub = strategy.published_run; const detail = pub.detail || ""; if (pub.status === "ready") return detail; if (pub.status === "missing_data") return "数据不足"; if (pub.status === "not_run") return "等待盘后"; } if (strategy && strategy.missing_data && strategy.missing_data.length) return "数据不足"; if (state.screener.view === "active") return "查看持续有效"; if (state.screener.view === "history") return "查看入选历史"; const rows = screenerViewKey(payload, state.screener.view); if (rows.length) return rows.length + " 只候选"; return "暂无信号"; } function screenerEmptyText(payload) { const status = screenerStrategyStatusText(payload); if (status === "数据不足") return "该策略数据不足,暂无法出候选"; if (status === "等待盘后") return "该策略等待盘后更新"; if (status === "选择策略") return "请先选择一个策略查看结果"; if (state.screener.view === "active") return "该策略暂无持续有效信号"; if (state.screener.view === "history") return "该策略暂无入选历史"; return "该策略当日暂无信号"; } function formatScreenerUpdatedAt(value) { if (!value) return "盘后已更新"; const text = String(value); if (text.length >= 5 && text.indexOf(":") > 0) { return "盘后 " + text + " 已更新"; } if (text.length >= 10) return displayCompactDate(text) + " 已更新"; return "盘后已更新"; } function renderScreenerTable() { const wrap = document.getElementById("m-screener-table"); if (!wrap) return; const payload = state.screener.data; if (!payload) { wrap.innerHTML = ""; return; } const rows = screenerViewKey(payload, state.screener.view); if (!rows.length) { wrap.innerHTML = '
' + '' + icon("inbox", 26) + '' + '

' + escapeHtml(screenerEmptyText(payload)) + '

' + '可切换其他策略查看结果' + '' + '
'; return; } // 跟踪状态映射(加入跟踪后置灰) const followed = screenerFollowedCodes(); rows.forEach(function (r) { r.tracked = followed[String(r.code)] ? true : false; }); const primaryColumns = []; if (state.screener.view === "history") { primaryColumns.push({ key: "selection_date", label: "入选日", type: "text", width: 76 }); } primaryColumns.push( { key: "score_display", label: "综合分", type: "score" }, { key: "pct_chg", label: "涨幅%", type: "change" }, { key: "sector", label: "板块", type: "text" } ); const cols = { frozenColumns: [ { key: "stock", label: "股票", type: "stock", width: 96 } ], primaryColumns: primaryColumns, scrollColumns: [ { key: "historical_probability", label: "历史胜率%", type: "rate" }, { key: "return_5d", label: "5日%", type: "change" }, { key: "volume_ratio_5d", label: "量比", type: "rate" }, { key: "sector_strength", label: "板块强度", type: "rate" } ] }; state.sortTable = { cols: cols, reapply: renderScreenerTable }; const head = orderedColumns(cols).map(function (col, i) { const isFrozen = i < cols.frozenColumns.length; const sortable = sortableColumn(col); const active = sortable && state.sort.key === col.key && Boolean(state.sort.dir); const indicator = sortable ? sortIndicatorHtml(active) : ""; const extraCls = sortable ? " m-sortable" + (active ? " is-sorted" : "") : ""; const extraAttrs = sortable ? ' data-sort-key="' + escapeHtml(col.key) + '" aria-sort="' + (active ? (state.sort.dir === "asc" ? "ascending" : "descending") : "none") + '"' : ''; return '' + '' + escapeHtml(col.label) + indicator + ''; }).join(""); const sorted = sortedRows(rows, cols); const body = sorted.map(function (row, rIndex) { const cells = orderedColumns(cols).map(function (col, i) { const isFrozen = i < cols.frozenColumns.length; const width = columnWidth(col); const cls = "m-td " + colAlign(col) + (isFrozen ? " m-frozen" : ""); const style = "min-width:" + width + "px" + (isFrozen ? ";width:" + width + "px;left:0" : ""); let html = cellHtml(col, row, rIndex); if (col.key === "score_display") { const n = Number(row.score_display); if (Number.isFinite(n) && n >= 80) { html = '' + formatNumber(n, 1) + ''; } } return '' + html + ''; }).join(""); const code = /^\d{6}$/.test(String(row.code || "")) ? ' data-code="' + escapeHtml(row.code) + '"' : ""; return '' + cells + ''; }).join(""); wrap.innerHTML = '' + head + '' + body + '
'; } function openScreenerStrategyDrawer() { const payload = state.screener.data; if (!payload) return; const list = (payload.strategies || []).map(function (s) { const meta = (s.formula && s.formula.meta) || {}; return { name: s.name, library: meta.library || "", category: meta.category || "", id: s.id, regimes: s.regimes || [], published_run: s.published_run || null, missing_data: s.missing_data || [] }; }); if (!list.length) { list.push({ id: "smart", name: "智能阶段", library: "smart", category: "周期策略", published_run: null, missing_data: [] }); } openSheet( '

选择策略

' + '
' + '
', { detail: false } ); const body = document.getElementById("m-screener-drawer-body"); body.innerHTML = screenerDrawerContent(list); const input = body.querySelector("[data-screener-drawer-search]"); input.addEventListener("input", function () { body.innerHTML = screenerDrawerContent(list, input.value, body.dataset.cat); }); body.addEventListener("click", function (event) { const pill = event.target.closest("[data-screener-cat]"); if (pill) { body.dataset.cat = pill.dataset.screenerCat; body.innerHTML = screenerDrawerContent(list, input.value, body.dataset.cat); return; } const row = event.target.closest("[data-screener-row]"); if (row) { const id = row.dataset.screenerRow; const strat = list.find(function (s) { return String(s.id) === String(id); }); if (strat) { state.screener.strategyId = strat.id; state.screener.strategyName = strat.name; } closeSheet(); renderScreener(); return; } }); } function screenerStrategyRegimeText(s) { const regimes = (s && s.regimes) || []; if (!regimes.length) return ""; const all = (state.screener.data && state.screener.data.regimes) || []; const labels = []; regimes.forEach(function (id) { const r = all.find(function (x) { return x.id === id; }); labels.push(r ? r.label : id); }); if (!labels.length) return ""; if (all.length > 0 && labels.length >= all.length) return "全阶段"; return labels.join(" / "); } function screenerStrategyStatusTextFor(s, payload) { if (s.published_run) { const pub = s.published_run; if (pub.status === "ready") return pub.detail || "数据完整"; if (pub.status === "missing_data") return "数据不足"; if (pub.status === "not_run") return "等待盘后"; } if (s.missing_data && s.missing_data.length) return "数据不足"; return "暂无信号"; } function screenerDrawerContent(list, filter, cat) { const kw = (filter || "").trim().toLowerCase(); const activeCat = cat || "all"; const cats = [{ key: "all", label: "全部" }, { key: "基本面", label: "基本面" }, { key: "趋势", label: "趋势" }, { key: "短线", label: "短线" }, { key: "动量", label: "动量" }, { key: "量化", label: "量化" }, { key: "事件", label: "事件" }, { key: "资金", label: "资金" }]; const catPills = cats.map(function (c) { const active = activeCat === c.key; return ''; }).join(""); const filtered = list.filter(function (s) { if (kw && String(s.name).toLowerCase().indexOf(kw) < 0) return false; if (activeCat !== "all") { const cat = s.category || s.library || ""; if (String(cat) !== activeCat) return false; } return true; }); const rows = filtered.map(function (s) { const isCurrent = String(s.id) === String(state.screener.strategyId); const status = screenerStrategyStatusTextFor(s, state.screener.data); const statusCls = status === "数据不足" ? 'm-strategy-status--warn' : (status === "暂无信号" || status === "等待盘后" ? 'm-strategy-status--muted' : ''); const regimeText = screenerStrategyRegimeText(s); return ''; }).join("") || '

无匹配策略

'; return '' + '
' + catPills + '
' + '
' + '选择策略' + '' + filtered.length + ' 套' + '
' + '
' + rows + '
'; } function setupTrackingPage(key) { document.getElementById("m-view").classList.add("m-view-feature"); state.tracking = { data: null, loading: false, refreshing: false }; updateHeader("策略跟踪", ""); document.getElementById("m-actions").innerHTML = ''; document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(6))); } function loadTracking() { state.tracking.loading = true; const seq = nextSeq(); global.MobileAPI.request("/api/screener/tracking?limit=12").then(function (payload) { if (seq !== state.seq) return; state.tracking.data = payload; renderTracking(); }).catch(function (error) { if (seq !== state.seq) return; renderError(error && error.message ? error.message : "跟踪数据加载失败"); }).then(function () { state.tracking.loading = false; }); } function renderTracking() { const scroll = document.getElementById("m-scroll"); if (!scroll) return; const data = state.tracking.data || {}; const summary = data.summary || {}; const batches = data.batches || []; const items = []; batches.forEach(function (b) { (b.items || []).forEach(function (it) { items.push(Object.assign({ batch: b }, it)); }); }); items.sort(function (a, b) { return String(b.batch.selection_date || "").localeCompare(String(a.batch.selection_date || "")); }); if (!items.length) { scroll.innerHTML = '
' + '' + icon("target", 26) + '' + '

暂无跟踪记录

' + '在智能选股页将候选加入跟踪,5 个交易日内会在这里自动汇总表现' + '' + '
'; return; } const stripHtml = renderStrip5([ { label: "跟踪标的", value: String(summary.total != null ? summary.total : items.length) }, { label: "已观察", value: String(summary.observed != null ? summary.observed : 0) }, { label: "T+1胜率", value: summary.t1_win_rate != null ? summary.t1_win_rate + "%" : "--" }, { label: "T+5胜率", value: summary.t5_win_rate != null ? summary.t5_win_rate + "%" : "--" }, { label: "T+5平均", value: summary.average_t5 != null ? (summary.average_t5 > 0 ? "+" : "") + summary.average_t5 + "%" : "--", tone: summary.average_t5 > 0 ? "up" : (summary.average_t5 < 0 ? "down" : "") } ]); const cols = { frozenColumns: [ { key: "stock", label: "股票", type: "stock", width: 96 } ], primaryColumns: [ { key: "status_text", label: "状态", type: "text" }, { key: "max_gain", label: "最大涨幅", type: "change" } ], scrollColumns: [ { key: "selection_date", label: "入选日", type: "text" }, { key: "strategy_name", label: "策略", type: "text", wide: true }, { key: "entry_price", label: "入场价", type: "price" }, { key: "t1_close", label: "T+1收", type: "change" }, { key: "t3_close", label: "T+3", type: "change" }, { key: "t5_close", label: "T+5", type: "change" }, { key: "max_drawdown", label: "最大回撤", type: "change" } ] }; const rows = items.map(function (it) { return { code: it.code, name: it.name, sector: it.sector, status: it.status, status_text: it.status || "--", max_gain: it.max_gain, selection_date: displayCompactDate(it.batch.selection_date || ""), strategy_name: it.batch.strategy_name || "--", entry_price: it.entry_price, t1_close: it.t1_close, t3_close: it.t3_close, t5_close: it.t5_close, max_drawdown: it.max_drawdown, track_id: it.id }; }); // 表头 const head = orderedColumns(cols).map(function (col, i) { const isFrozen = i < cols.frozenColumns.length; const sortable = sortableColumn(col) && col.key !== "status_text"; const active = sortable && state.sort.key === col.key && Boolean(state.sort.dir); const indicator = sortable ? sortIndicatorHtml(active) : ""; const extraCls = sortable ? " m-sortable" + (active ? " is-sorted" : "") : ""; const extraAttrs = sortable ? ' data-sort-key="' + escapeHtml(col.key) + '" aria-sort="' + (active ? (state.sort.dir === "asc" ? "ascending" : "descending") : "none") + '"' : ''; return '' + '' + escapeHtml(col.label) + indicator + ''; }).join(""); state.sortTable = { cols: cols, reapply: renderTracking }; const sorted = sortedRows(rows, cols); const body = sorted.map(function (row, rIndex) { const cells = orderedColumns(cols).map(function (col, i) { const isFrozen = i < cols.frozenColumns.length; const width = columnWidth(col); const cls = "m-td " + colAlign(col) + (isFrozen ? " m-frozen" : ""); const style = "min-width:" + width + "px" + (isFrozen ? ";width:" + width + "px;left:0" : ""); let html; if (col.key === "status_text") { html = trackingStatusBadge(row.status); } else if (col.key === "max_gain") { const v = row.max_gain; if (v == null) html = '--'; else html = '' + (Number(v) > 0 ? "+" : "") + formatNumber(Number(v), 2) + ''; } else { html = cellHtml(col, row, rIndex); } return '' + html + ''; }).join(""); const code = /^\d{6}$/.test(String(row.code || "")) ? ' data-code="' + escapeHtml(row.code) + '" data-track-id="' + escapeHtml(String(row.track_id || "")) + '"' : ''; return '' + cells + ''; }).join(""); const tableHtml = '
' + head + '' + body + '
'; scroll.innerHTML = stripHtml + tableHtml; } function trackingStatusBadge(status) { const text = status || "--"; if (text.indexOf("已完成") === 0) return '' + escapeHtml(text) + ''; if (text.indexOf("等待") === 0) return '' + escapeHtml(text) + ''; if (text.indexOf("跟踪中") === 0) return '' + escapeHtml(text) + ''; return '' + escapeHtml(text) + ''; } /* ---------------------------------------------------------------- screener/tracking detail sheets (composed on top of existing openDetailSheet) */ function appendDetailGroup(sheet, title, rows) { return '

' + escapeHtml(title) + '

' + rows.map(function (r) { return '
' + escapeHtml(r.label) + '' + '' + r.value + '
'; }).join("") + '
'; } function openScreenerDetailSheet(code) { openDetailSheet(code); // 等详情体渲染完成后追加「选股信息」分组 + 「加入跟踪」按钮 const attach = function () { const body = document.getElementById("m-detail-sheet-body"); if (!body) return; // 找第一个空骨架或图表加载完成 if (body.querySelector(".m-skeleton")) { return global.setTimeout(attach, 80); } const rows = screenerViewKey(state.screener.data, state.screener.view); const row = rows.find(function (r) { return String(r.code) === String(code); }); if (!row) return; const runId = screenerCurrentRunId(); const followed = screenerFollowedCodes()[String(code)]; const isTracked = Boolean(followed); const contributions = Array.isArray(row.contributions) ? row.contributions : []; const riskFlags = Array.isArray(row.risk_flags) ? row.risk_flags : []; const contributionGroup = (row.reason || contributions.length) ? appendDetailGroup("主要贡献", contributions.length ? contributions.map(function (c) { return { label: c.label || "", value: (c.value != null && c.value !== "" ? escapeHtml(String(c.value)) : "--") + (c.points != null ? "(+" + formatNumber(Number(c.points), 1) + "分)" : "") }; }) : [{ label: "", value: '' + escapeHtml(row.reason || "") + '' }]) : ""; const riskGroup = riskFlags.length ? appendDetailGroup("风险标记", riskFlags.map(function (f) { return { label: "", value: '' + escapeHtml(f) + '' }; })) : ""; const html = appendDetailGroup("选股信息", [ { label: "综合分", value: formatNumber(Number(row.score_display || 0), 1), cls: Number(row.score_display || 0) >= 80 ? "up" : "" }, { label: "当日涨幅", value: row.pct_chg != null ? (Number(row.pct_chg) > 0 ? "+" : "") + formatNumber(Number(row.pct_chg), 2) + "%" : "--", cls: Number(row.pct_chg || 0) > 0 ? "up" : "down" }, { label: "历史胜率", value: row.historical_probability != null ? formatNumber(Number(row.historical_probability), 1) + "%" : "--" }, { label: "板块", value: escapeHtml(row.sector || "--") } ]) + contributionGroup + riskGroup + '
' + (isTracked ? '' : '') + '
'; const foot = document.createElement("div"); foot.className = "m-detail-foot"; foot.innerHTML = html; body.appendChild(foot); const btn = body.querySelector("[data-screener-track]"); if (btn) { btn.addEventListener("click", function () { addScreenerTrackingFromSheet(btn.dataset.runId, btn.dataset.code); }); } }; attach(); } function addScreenerTrackingFromSheet(runId, code) { if (!runId || runId === "0" || !code) { showToast("当前结果不支持加入跟踪"); return; } global.MobileAPI.request("/api/screener/tracking", "POST", { run_id: Number(runId), code: code }).then(function () { showToast("已加入跟踪"); // 刷新 tracking 缓存 return global.MobileAPI.request("/api/screener/tracking?limit=12"); }).then(function (payload) { if (payload) state.tracking.data = payload; // 重渲染选股表中跟踪态 renderScreenerTable(); // 更新当前 sheet 中的按钮 const body = document.getElementById("m-detail-sheet-body"); if (body) { const actions = body.querySelector(".m-screener-detail-actions"); if (actions) { actions.innerHTML = ''; } } }).catch(function (err) { showToast((err && err.message) || "加入跟踪失败"); }); } function openTrackingDetailSheet(code, trackId) { openDetailSheet(code); const attach = function () { const body = document.getElementById("m-detail-sheet-body"); if (!body) return; if (body.querySelector(".m-skeleton")) { return global.setTimeout(attach, 80); } const data = state.tracking.data || {}; let item = null; let batch = null; (data.batches || []).forEach(function (b) { (b.items || []).forEach(function (it) { if (String(it.id) === String(trackId) || String(it.code) === String(code)) { item = it; batch = b; } }); }); if (!item) return; const html = appendDetailGroup("跟踪信息", [ { label: "入选日", value: escapeHtml(displayCompactDate(batch.selection_date || "")) }, { label: "策略", value: escapeHtml(batch.strategy_name || "--") }, { label: "入场价", value: item.entry_price != null ? formatNumber(Number(item.entry_price), 2) : "--" }, { label: "T+1开", value: item.t1_open != null ? (Number(item.t1_open) > 0 ? "+" : "") + formatNumber(Number(item.t1_open), 2) + "%" : "--" }, { label: "T+1收", value: item.t1_close != null ? (Number(item.t1_close) > 0 ? "+" : "") + formatNumber(Number(item.t1_close), 2) + "%" : "--" }, { label: "T+3收", value: item.t3_close != null ? (Number(item.t3_close) > 0 ? "+" : "") + formatNumber(Number(item.t3_close), 2) + "%" : "--" }, { label: "T+5收", value: item.t5_close != null ? (Number(item.t5_close) > 0 ? "+" : "") + formatNumber(Number(item.t5_close), 2) + "%" : "--" }, { label: "最大涨幅", value: item.max_gain != null ? (Number(item.max_gain) > 0 ? "+" : "") + formatNumber(Number(item.max_gain), 2) + "%" : "--", cls: "up" }, { label: "最大回撤", value: item.max_drawdown != null ? formatNumber(Number(item.max_drawdown), 2) + "%" : "--", cls: "down" } ]) + '
' + '' + '
'; const foot = document.createElement("div"); foot.className = "m-detail-foot"; foot.innerHTML = html; body.appendChild(foot); const btn = body.querySelector("[data-tracking-remove]"); if (btn) { btn.addEventListener("click", function () { openConfirmSheet("移除跟踪", "移除后该股将不再自动更新表现,但历史成交已记录保留。", { danger: true, confirmLabel: "移除", onConfirm: function () { removeTracking(btn.dataset.trackId); } }); }); } }; attach(); } function removeTracking(trackId) { if (!trackId) return; global.MobileAPI.request("/api/screener/tracking/" + encodeURIComponent(trackId), "DELETE").then(function () { showToast("已移除跟踪"); closeSheet(); return global.MobileAPI.request("/api/screener/tracking?limit=12"); }).then(function (payload) { if (payload) { state.tracking.data = payload; renderTracking(); } }).catch(function (err) { showToast((err && err.message) || "移除失败"); }); } function refreshTracking() { if (state.tracking.refreshing) return; state.tracking.refreshing = true; global.MobileAPI.request("/api/screener/tracking/refresh", "POST", {}).then(function (payload) { showToast(payload && payload.notice ? payload.notice : "已更新"); if (payload && payload.tracking) { state.tracking.data = payload.tracking; renderTracking(); } else { return global.MobileAPI.request("/api/screener/tracking?limit=12"); } }).then(function (payload) { if (payload) { state.tracking.data = payload; renderTracking(); } }).catch(function (err) { showToast((err && err.message) || "刷新失败"); }).then(function () { state.tracking.refreshing = false; const icon = document.querySelector("[data-tracking-refresh] svg"); if (icon) icon.classList.remove("is-spinning"); }); } /* ============================================================== 复盘助手 + 问师 聊天工作台 ============================================================== */ const ASSISTANT_PRESET_QUESTIONS = [ "市场位置", "市场主线", "交易复盘", "明日清单" ]; function setupChatPage(key) { document.getElementById("m-view").classList.add("m-view-feature"); state.chat = { page: key, messages: [], mentorId: "", mentorName: "", mentorTagline: "", mentorGrade: "", mentorFocus: [], mentorSetup: null, followUps: [], streaming: false, streamController: null, streamBuffer: "", streamBubble: null, loading: false, historyLoaded: false, aborter: null }; const isAssistant = key === "assistant/chat"; // 复盘助手是底栏直达(无 back);问师从智能工具图标页进入(有 back);m-actions 由本函数自己控制 global.MobileRouter.updateHeader({ title: isAssistant ? "复盘助手" : "问师", back: !isAssistant, actions: "" }); document.getElementById("m-view").innerHTML = buildChatShell(key); const actions = []; if (!isAssistant) { actions.push(''); } actions.push(''); document.getElementById("m-actions").innerHTML = actions.join(""); bindChatShell(key); } function buildChatShell(key) { const isAssistant = key === "assistant/chat"; return '
' + (isAssistant ? '' : '') + '
' + chatSkeletonHtml() + '
' + '
' + '
' + '' + '
' + '' + '
' + '
'; } function chatSkeletonHtml() { return '
' + '
' + '
' + '
' + '
'; } function bindChatShell(key) { const isAssistant = key === "assistant/chat"; const input = document.getElementById("m-chat-input"); if (input) { // 避免重复绑定:每次 setupChatPage 都新建 input 元素 input.addEventListener("input", function () { autoSizeChatInput(); syncChatSendEnabled(); }); input.addEventListener("keydown", function (event) { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); if (!state.chat.streaming) submitChatMessage(); } }); } syncChatSendEnabled(); autoSizeChatInput(); } function autoSizeChatInput() { const input = document.getElementById("m-chat-input"); if (!input) return; input.style.height = "auto"; const max = 116; // ~4 lines const next = Math.min(max, Math.max(44, input.scrollHeight)); input.style.height = next + "px"; } function syncChatSendEnabled() { const input = document.getElementById("m-chat-input"); const btn = document.querySelector("[data-chat-send]"); if (!btn) return; if (state.chat.streaming) { btn.classList.add("m-chat-send--stop"); btn.setAttribute("aria-label", "停止"); btn.innerHTML = icon("square", 14); btn.disabled = false; return; } btn.classList.remove("m-chat-send--stop"); btn.setAttribute("aria-label", "发送"); btn.innerHTML = icon("send", 20); const text = input ? input.value.trim() : ""; btn.disabled = !text; } function loadChat() { if (state.chat.page === "assistant/chat") { loadAssistantHistory(); } else { loadMentorSetup(); } } function loadAssistantHistory() { state.chat.loading = true; const seq = nextSeq(); global.MobileAPI.request("/api/assistant/messages").then(function (payload) { if (seq !== state.seq) return; const items = (payload && payload.items) || []; state.chat.messages = items.map(function (it) { return { role: it.role, content: String(it.content || ""), created_at: it.created_at || "" }; }); state.chat.historyLoaded = true; renderChatStream(); }).catch(function (error) { if (seq !== state.seq) return; renderChatStreamError(error && error.message ? error.message : "对话历史加载失败"); }).then(function () { state.chat.loading = false; }); } function loadMentorSetup() { state.chat.loading = true; const seq = nextSeq(); global.MobileAPI.request("/api/mentors/setup?trade_date=" + encodeURIComponent(todayString())).then(function (payload) { if (seq !== state.seq) return; state.chat.mentorSetup = payload; // 无已选导师时保持空态(spec §1.5):不自动选中导师,等用户点「选择导师」或顶栏 bot 开抽屉 if (!state.chat.mentorId) { // 空态,等待选择 } else { // 已选导师,更新名称/标签 const cur = ((payload && payload.mentors) || []).find(function (m) { return m.id === state.chat.mentorId; }); if (cur) { state.chat.mentorName = cur.name || state.chat.mentorName; state.chat.mentorTagline = cur.tagline || state.chat.mentorTagline; state.chat.mentorGrade = (cur.evidence && cur.evidence.grade) || state.chat.mentorGrade; state.chat.mentorFocus = cur.focus || state.chat.mentorFocus; } loadMentorHistory(); } renderChatMentorBar(); renderChatStream(); }).catch(function (error) { if (seq !== state.seq) return; renderChatStreamError(error && error.message ? error.message : "导师目录加载失败"); }).then(function () { state.chat.loading = false; }); } function loadMentorHistory() { if (!state.chat.mentorId) return; const seq = nextSeq(); const url = "/api/mentors/messages?mentor_id=" + encodeURIComponent(state.chat.mentorId) + "&trade_date=" + encodeURIComponent(todayString()); global.MobileAPI.request(url).then(function (payload) { if (seq !== state.seq) return; const items = (payload && payload.items) || []; state.chat.messages = items.map(function (it) { return { role: it.role, content: String(it.content || ""), created_at: it.created_at || "" }; }); state.chat.historyLoaded = true; renderChatStream(); }).catch(function (error) { if (seq !== state.seq) return; renderChatStreamError(error && error.message ? error.message : "对话历史加载失败"); }); } function selectMentor(id, opts) { const payload = state.chat.mentorSetup; if (!payload) return; const mentors = payload.mentors || []; const m = mentors.find(function (x) { return x.id === id; }); if (!m) return; state.chat.mentorId = m.id; state.chat.mentorName = m.name; state.chat.mentorTagline = m.tagline || ""; state.chat.mentorGrade = (m.evidence && m.evidence.grade) || ""; state.chat.mentorFocus = m.focus || []; state.chat.messages = []; state.chat.followUps = []; if (!opts || !opts.silent) { showToast("已切换到「" + state.chat.mentorName + "」"); } if (typeof closeSheet === "function") closeSheet(); renderChatMentorBar(); renderChatStream(); loadMentorHistory(); } function clearAssistantMessages() { global.MobileAPI.request("/api/assistant/messages", "DELETE").then(function () { state.chat.messages = []; state.chat.followUps = []; renderChatStream(); showToast("已清空对话"); }).catch(function (err) { showToast((err && err.message) || "清空失败"); }); } function clearMentorMessages(mentorId, tradeDate) { const url = "/api/mentors/messages?mentor_id=" + encodeURIComponent(mentorId) + "&trade_date=" + encodeURIComponent(tradeDate); global.MobileAPI.request(url, "DELETE").then(function () { state.chat.messages = []; state.chat.followUps = []; renderChatStream(); showToast("已清空当日对话"); }).catch(function (err) { showToast((err && err.message) || "清空失败"); }); } function renderChatStream() { const stream = document.getElementById("m-chat-stream"); if (!stream) return; const isAssistant = state.chat.page === "assistant/chat"; const messages = state.chat.messages || []; if (state.chat.streaming) { // 渲染中只更新最新气泡文本 const bubble = stream.querySelector("[data-stream-bubble]"); if (bubble) { bubble.innerHTML = renderChatMarkdown(state.chat.streamBuffer); scrollChatToBottom(); } return; } let html = ""; if (!messages.length) { if (isAssistant) { html = chatBubbleHtml("ai", "我是你的复盘助手,可以问我市场位置、主线、交易复盘、明日清单。") + '
从下面挑一个开始,或直接输入你的问题
' + chatPresetPillsHtml(); } else if (state.chat.mentorId) { // 显示导师 tagline 原文(spec §1.5) const tagline = state.chat.mentorTagline || "今天想问点什么?"; html = chatBubbleHtml("ai", tagline) + '
基于今日市场数据回答;引用数据时已带日期
'; } else { html = '

请先选择一位导师

' + '
'; } } else { html = messages.map(function (msg, idx) { return chatBubbleHtmlWithTimestamp(msg, idx, messages); }).join(""); } stream.innerHTML = html; scrollChatToBottom(); renderChatFollowUps(); } function renderChatStreamError(message) { const stream = document.getElementById("m-chat-stream"); if (!stream) return; stream.innerHTML = '

' + escapeHtml(message) + '

' + '
'; const btn = stream.querySelector("[data-chat-retry]"); if (btn) btn.addEventListener("click", loadChat); } function chatBubbleHtml(role, content) { const isUser = role === "user"; return '
' + '
' + (isUser ? escapeHtml(content) : renderChatMarkdown(content)) + '
' + '
'; } function chatBubbleHtmlWithTimestamp(msg, idx, all) { const isUser = msg.role === "user"; let stamp = ""; if (msg.created_at && idx > 0) { const prev = all[idx - 1]; if (prev && prev.created_at && shouldInsertTimestamp(prev.created_at, msg.created_at)) { stamp = '
' + escapeHtml(chatTimestampLabel(msg.created_at)) + '
'; } } return stamp + chatBubbleHtml(isUser ? "user" : "ai", msg.content); } function shouldInsertTimestamp(prevIso, currIso) { try { const a = new Date(prevIso).getTime(); const b = new Date(currIso).getTime(); return (b - a) > 5 * 60 * 1000; } catch (_error) { return false; } } function chatTimestampLabel(iso) { try { const d = new Date(iso); const now = new Date(); const sameDay = d.toDateString() === now.toDateString(); if (sameDay) return "今天 " + String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); const yest = new Date(now.getTime() - 24 * 3600 * 1000); if (d.toDateString() === yest.toDateString()) return "昨天 " + String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); return displayCompactDate(iso) + " " + String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); } catch (_error) { return displayCompactDate(iso) || ""; } } // 极简 markdown:加粗 / 列表 / 引用 / 标题(与电脑端限制保持一致;不渲染图片/表格/代码) function renderChatMarkdown(text) { if (!text) return ""; const lines = String(text).split("\n"); const out = []; let inList = false; let inQuote = false; for (let i = 0; i < lines.length; i += 1) { const raw = lines[i]; const line = escapeHtml(raw); if (/^\s*[-*]\s+/.test(raw)) { if (!inList) { out.push("
    "); inList = true; } if (inQuote) { out.push(""); inQuote = false; } out.push("
  • " + line.replace(/^\s*[-*]\s+/, "").replace(/\*\*([^*]+)\*\*/g, "$1") + "
  • "); continue; } if (inList) { out.push("
"); inList = false; } if (/^>\s?/.test(raw)) { if (!inQuote) { out.push("
"); inQuote = true; } out.push(line.replace(/^>\s?/, "").replace(/\*\*([^*]+)\*\*/g, "$1")); continue; } if (inQuote) { out.push("
"); inQuote = false; } if (/^###\s+/.test(raw)) { out.push("

" + line.replace(/^###\s+/, "").replace(/\*\*([^*]+)\*\*/g, "$1") + "

"); continue; } if (!line.trim()) { out.push(""); continue; } out.push("

" + line.replace(/\*\*([^*]+)\*\*/g, "$1") + "

"); } if (inList) out.push(""); if (inQuote) out.push(""); return out.join(""); } function renderChatMentorBar() { const bar = document.getElementById("m-chat-mentor-bar"); if (!bar) return; if (state.chat.page === "assistant/chat") { bar.innerHTML = ""; bar.style.display = "none"; return; } if (!state.chat.mentorId) { bar.innerHTML = ""; bar.style.display = "none"; return; } bar.style.display = "flex"; const grade = state.chat.mentorGrade || ""; const gradeBadge = grade ? '' + escapeHtml(grade) + '' : ""; bar.innerHTML = ''; } function chatPresetPillsHtml() { return '
' + '
' + ASSISTANT_PRESET_QUESTIONS.map(function (q) { return ''; }).join("") + '
'; } function renderChatFollowUps() { const stream = document.getElementById("m-chat-stream"); if (!stream) return; const old = stream.querySelector("[data-chat-followup-row]"); if (old) old.remove(); if (state.chat.streaming || !state.chat.followUps || !state.chat.followUps.length) return; const html = '
' + state.chat.followUps.slice(0, 3).map(function (q) { return ''; }).join("") + '
'; stream.insertAdjacentHTML("beforeend", html); scrollChatToBottom(); } function scrollChatToBottom() { const stream = document.getElementById("m-chat-stream"); if (!stream) return; global.requestAnimationFrame(function () { stream.scrollTop = stream.scrollHeight; }); } function submitChatMessage() { const input = document.getElementById("m-chat-input"); if (!input) return; const text = input.value.trim(); if (!text) return; sendChatMessage(text); } function sendChatMessage(text) { if (state.chat.streaming) return; if (state.chat.page === "tools/mentor" && !state.chat.mentorId) { showToast("请先选择导师"); return; } const userMsg = { role: "user", content: text, created_at: new Date().toISOString() }; state.chat.messages = state.chat.messages.concat([userMsg]); state.chat.followUps = []; const input = document.getElementById("m-chat-input"); if (input) { input.value = ""; autoSizeChatInput(); } renderChatStream(); startChatStream(text); } function startChatStream(question) { if (state.chat.streaming) return; state.chat.streaming = true; state.chat.streamBuffer = ""; state.chat.streamController = new AbortController(); syncChatSendEnabled(); // 插入等待气泡占位 const stream = document.getElementById("m-chat-stream"); if (stream) { stream.insertAdjacentHTML("beforeend", '
' + '
' + '' + '
'); scrollChatToBottom(); } renderChatFollowUps(); const url = state.chat.page === "assistant/chat" ? "/api/assistant/chat" : "/api/mentors/chat"; const body = state.chat.page === "assistant/chat" ? { question: question, trade_date: todayString() } : { mentor_id: state.chat.mentorId, question: question, trade_date: todayString() }; const onEvent = function (event) { if (!event || !event.type) return; if (event.type === "delta" && typeof event.content === "string") { state.chat.streamBuffer += event.content; const bubble = document.querySelector("[data-stream-bubble]"); if (bubble) { bubble.innerHTML = renderChatMarkdown(state.chat.streamBuffer) + ''; scrollChatToBottom(); } } else if (event.type === "meta") { if (Array.isArray(event.follow_ups) && event.follow_ups.length) { state.chat.followUps = event.follow_ups.slice(0, 3); } renderChatFollowUps(); } }; global.MobileAPI.streamNdjson(url, { method: "POST", body: body, signal: state.chat.streamController.signal, onEvent: onEvent, errorMessage: state.chat.page === "assistant/chat" ? "智能解读失败" : "问师回复失败" }).then(function () { finalizeChatStream(); }).catch(function (err) { const aborted = err && (err.name === "AbortError" || err.message === "请求已取消"); handleChatStreamError(err, aborted); }); } function finalizeChatStream() { if (!state.chat.streaming) return; const aiText = state.chat.streamBuffer; if (aiText) { const aiMsg = { role: "assistant", content: aiText, created_at: new Date().toISOString() }; state.chat.messages = state.chat.messages.concat([aiMsg]); } state.chat.streaming = false; state.chat.streamController = null; state.chat.streamBuffer = ""; state.chat.streamBubble = null; syncChatSendEnabled(); renderChatStream(); } function handleChatStreamError(err, aborted) { if (aborted) { // 用户主动停止 if (state.chat.streamBuffer) { const aiMsg = { role: "assistant", content: state.chat.streamBuffer, created_at: new Date().toISOString() }; state.chat.messages = state.chat.messages.concat([aiMsg]); } state.chat.streaming = false; state.chat.streamController = null; state.chat.streamBuffer = ""; syncChatSendEnabled(); renderChatStream(); return; } state.chat.streaming = false; state.chat.streamController = null; state.chat.streamBuffer = ""; syncChatSendEnabled(); // 渲染错误气泡 + 重试 const stream = document.getElementById("m-chat-stream"); if (stream) { const placeholder = stream.querySelector("[data-stream-msg]"); if (placeholder) placeholder.remove(); stream.insertAdjacentHTML("beforeend", '
' + '
' + '' + escapeHtml((err && err.message) || "网络异常,请重试") + '' + '' + '
'); const btn = stream.querySelector("[data-chat-retry]"); if (btn) btn.addEventListener("click", function () { // 找到上条用户消息并重试 const last = [...state.chat.messages].reverse().find(function (m) { return m.role === "user"; }); if (last) { state.chat.messages = state.chat.messages.slice(0, state.chat.messages.length - 1); renderChatStream(); sendChatMessage(last.content); } }); scrollChatToBottom(); } } function stopChatStream() { if (!state.chat.streaming || !state.chat.streamController) return; try { state.chat.streamController.abort(); } catch (_error) { /* noop */ } } function openMentorDrawer() { const payload = state.chat.mentorSetup; if (!payload) { showToast("导师目录暂未加载"); return; } const mentors = payload.mentors || []; const listHtml = mentors.map(function (m) { const grade = (m.evidence && m.evidence.grade) || ""; const isCurrent = m.id === state.chat.mentorId; return ''; }).join("") || '

导师目录为空

'; openSheet( '

选择导师

' + '
' + '
' + '' + '
' + listHtml + '
' + '
', { detail: false } ); const sheetBody = document.querySelector(".m-sheet-body"); if (!sheetBody) return; const input = sheetBody.querySelector("[data-mentor-drawer-search]"); const list = sheetBody.querySelector(".m-mentor-drawer-list"); if (input && list) { input.addEventListener("input", function () { const kw = input.value.trim().toLowerCase(); const rows = list.querySelectorAll(".m-mentor-row"); rows.forEach(function (row) { const name = (row.querySelector(".m-mentor-row-name") || {}).textContent || ""; const tag = (row.querySelector(".m-mentor-row-tag") || {}).textContent || ""; const hit = !kw || name.toLowerCase().indexOf(kw) >= 0 || tag.toLowerCase().indexOf(kw) >= 0; row.style.display = hit ? "" : "none"; }); }); } // 导师行点击在 sheet 内绑定(m-view 上的全局 handler 抓不到 sheet 内点击) if (list) { list.addEventListener("click", function (event) { const row = event.target.closest("[data-mentor-row]"); if (!row) return; const id = row.dataset.mentorRow; if (id) selectMentor(id); }); } } /* ----- 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, "tools/screener": setupScreenerPage, "tools/tracking": setupTrackingPage, "tools/mentor": setupChatPage, "assistant/chat": setupChatPage, "review/watchlist": setupReviewPage, "review/trades": setupReviewPage, "review/daily": setupReviewPage, "review/notes": setupReviewPage, "review/alerts": setupReviewPage, }; const COMPLEX_LOADERS = { "market/sentiment": loadSentiment, "market/ladder": loadLadder, "market/rotation": loadRotation, "market/auction": loadAuction, "market/themes": loadThemes, "market/dragon": loadDragon, "tools/screener": loadScreener, "tools/tracking": loadTracking, "tools/mentor": loadChat, "assistant/chat": loadChat, "review/watchlist": loadReview, "review/trades": loadReview, "review/daily": loadReview, "review/notes": loadReview, "review/alerts": loadReview, }; 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 = '
' + '"; 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 backdrop = root.querySelector(".m-sheet-backdrop"); const handle = root.querySelector(".m-sheet-handle"); const head = root.querySelector(".m-sheet-head"); if (!sheet) return; let startY = 0; let startT = 0; let dragging = false; function begin(event) { if (event.target && event.target.closest(".m-sheet-close")) return; dragging = true; startY = event.clientY; startT = Date.now(); sheet.style.transition = "none"; if (backdrop) backdrop.style.transition = "none"; if (event.currentTarget && event.currentTarget.setPointerCapture) { try { event.currentTarget.setPointerCapture(event.pointerId); } catch (e) { /* noop */ } } } function move(event) { if (!dragging) return; const dy = event.clientY - startY; if (dy > 0) { sheet.style.transform = "translateY(" + dy + "px)"; if (backdrop) { const ratio = Math.min(1, dy / sheet.offsetHeight); backdrop.style.opacity = String(Math.max(0, 1 - ratio)); } } } function end(event) { if (!dragging) return; dragging = false; sheet.style.transition = ""; if (backdrop) { backdrop.style.opacity = ""; backdrop.style.transition = ""; } const dy = event.clientY - startY; const dt = Date.now() - startT; const velocity = dt > 0 ? dy / dt : 0; if (dy >= sheet.offsetHeight * 0.25 || (velocity >= 0.5 && dy >= 40)) { closeSheet(); } else { sheet.style.transform = ""; } } [handle, head].forEach(function (grip) { if (!grip) return; grip.style.touchAction = "none"; grip.addEventListener("pointerdown", begin); grip.addEventListener("pointermove", move); grip.addEventListener("pointerup", end); grip.addEventListener("pointercancel", end); }); } function selectDate(dateStr) { if (!dateStr) return; state.requestedDate = dateStr; if (state.key === "tools/screener") { state.screener.date = dateStr; state.screener.strategyId = ""; state.screener.strategyName = ""; const label = document.querySelector("[data-screener-date] .m-date-btn-label"); if (label) label.textContent = displayCompactDate(dateStr); } closeSheet(); reloadCurrent(); } function openDateSheet() { const baseDate = state.key === "tools/screener" && state.screener.date ? state.screener.date : state.requestedDate; state.calCursor = { year: parseLocalDate(baseDate).getFullYear(), month: parseLocalDate(baseDate).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 ? "已加入自选" : "已移出自选"); // 自选股列表页在背后:同步移除该行,避免关 Sheet 后仍显示已移除标的 if (!adding && state.review && state.review.watchlist) { state.review.watchlist.items = (state.review.watchlist.items || []).filter(function (it) { return String(it.code) !== String(code); }); renderReviewWatchlist(); } }).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 '" + '"; } /* ============================================================== 我的复盘(P3b) ============================================================== */ const TRADE_ACTION_OPTIONS = [ { key: "buy", label: "买入", tone: "up" }, { key: "add", label: "加仓", tone: "up" }, { key: "sell", label: "卖出", tone: "down" }, { key: "trim", label: "减仓", tone: "down" }, { key: "watch", label: "观察", tone: "flat" } ]; const TRADE_EMOTION_OPTIONS = [ { key: "calm", label: "平静" }, { key: "confident", label: "笃定" }, { key: "hesitant", label: "犹豫" }, { key: "anxious", label: "焦虑" }, { key: "impulsive", label: "冲动" } ]; function reviewSubKey(key) { const parts = String(key || "").split("/"); return parts[1] || ""; } function reviewTitle(key) { const map = { watchlist: "自选股", trades: "交易日志", daily: "每日复盘", notes: "个股笔记", alerts: "提醒中心" }; return map[reviewSubKey(key)] || "我的复盘"; } function headerActionButton(dataAttr, iconName, ariaLabel) { return '"; } function setupReviewPage(key) { const subKey = reviewSubKey(key); state.key = key; state.requestedDate = todayString(); state.sort = { key: "", dir: null }; state.sortTable = { cols: null, reapply: null }; state.detail = null; state.review.subKey = subKey; state.review.form = null; state.review.dirty = false; state.review.watchlist = null; state.review.trades = null; state.review.notes = null; state.review.dailyItems = null; state.review.alerts = null; state.review.alertStatus = "all"; document.getElementById("m-view").classList.add("m-view-feature"); const title = reviewTitle(key); let actions = ""; if (subKey === "trades") { actions = headerActionButton("review-add-trade", "plus", "新增交易"); } else if (subKey === "notes") { actions = headerActionButton("review-add-note", "plus", "新增笔记"); } else if (subKey === "alerts") { actions = headerActionButton("review-add-alert", "plus", "新增提醒") + '"; } if (subKey === "daily") { updateHeader(title, state.requestedDate); } else { global.MobileRouter.updateHeader({ title: title, back: true, actions: actions }); } document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(6))); } function loadReview() { const subKey = state.review.subKey; if (subKey === "watchlist") loadReviewWatchlist(); else if (subKey === "trades") loadReviewTrades(); else if (subKey === "daily") loadReviewDaily(); else if (subKey === "notes") loadReviewNotes(); else if (subKey === "alerts") loadReviewAlerts(); } function reviewState(kind) { return '

' + escapeHtml(kind) + "加载失败

" + '
'; } /* ----- 自选股 ----- */ function loadReviewWatchlist() { const seq = nextSeq(); global.MobileAPI.request("/api/watchlist?trade_date=" + encodeURIComponent(todayString())).then(function (payload) { if (seq !== state.seq) return; state.review.watchlist = payload || { items: [] }; renderReviewWatchlist(); }).catch(function (error) { if (seq !== state.seq) return; const scroll = document.getElementById("m-scroll"); if (scroll) scroll.innerHTML = reviewState("自选股"); }); } function watchDotColor(color) { return { red: true, blue: true, green: true, amber: true }[color] ? color : "red"; } function watchStockCell(row) { const color = watchDotColor(row.color); return '' + '' + '' + '' + escapeHtml(row.name || "--") + "" + '' + escapeHtml(row.code || "") + "" + ""; } function renderReviewWatchlist() { const scroll = document.getElementById("m-scroll"); if (!scroll) return; const data = state.review.watchlist || { items: [] }; const rows = data.items || []; if (!rows.length) { scroll.innerHTML = '
' + '' + icon("star", 26) + "" + "

还没有自选股

" + "在任意股票详情点星形加入" + "
"; return; } const cols = { frozenColumns: [{ key: "stock", label: "股票", type: "stock", width: 112 }], primaryColumns: [ { key: "change", label: "当日涨幅", type: "change" }, { key: "attention_score", label: "关注分", type: "score" } ], scrollColumns: [ { key: "sector", label: "板块", type: "text" }, { key: "return_5d", label: "5日涨幅", type: "change" }, { key: "remark", label: "跟踪备注", type: "text" } ] }; state.sortTable = { cols: cols, reapply: renderReviewWatchlist }; const sorted = sortedRows(rows, cols); scroll.innerHTML = '
' + buildReviewTable(cols, sorted, { noSort: true, cellRenderer: function (col, row) { if (col.key === "stock") return watchStockCell(row); if (col.key === "attention_score") { if (row.attention_score == null || row.attention_score === "") return ''; return '' + formatNumber(number(row.attention_score), 1) + ""; } if (col.key === "remark") { const text = String(row.remark == null ? "" : row.remark); if (!text) return '--'; return '' + escapeHtml(text) + ""; } return null; } }) + "
"; } /* ----- 交易日志 ----- */ function loadReviewTrades() { const seq = nextSeq(); global.MobileAPI.request("/api/trades").then(function (payload) { if (seq !== state.seq) return; state.review.trades = payload || { items: [], summary: {} }; renderReviewTrades(); }).catch(function (error) { if (seq !== state.seq) return; const scroll = document.getElementById("m-scroll"); if (scroll) scroll.innerHTML = reviewState("交易日志"); }); } function tradeActionBadge(action) { const opt = TRADE_ACTION_OPTIONS.find(function (o) { return o.key === action; }); const label = opt ? opt.label : (action || "--"); const tone = opt ? opt.tone : "flat"; return '' + escapeHtml(label) + ""; } function renderReviewTrades() { const scroll = document.getElementById("m-scroll"); if (!scroll) return; const data = state.review.trades || { items: [], summary: {} }; const items = data.items || []; const summary = data.summary || {}; if (!items.length) { scroll.innerHTML = '
' + '' + icon("scroll-text", 26) + "" + "

还没有交易记录

" + "记下每一次买卖,回头复盘更有据" + '' + "
"; return; } const pnlAmount = summary.pnl_amount; const avgPos = summary.average_position; const strip = renderStrip5([ { label: "记录", value: String(summary.total != null ? summary.total : items.length) }, { label: "已实现", value: String(summary.realized != null ? summary.realized : 0) }, { label: "胜率", value: summary.win_rate != null ? summary.win_rate + "%" : "--" }, { label: "累计盈亏", value: pnlAmount == null ? "--" : (pnlAmount > 0 ? "+" : "") + formatNumber(pnlAmount, 2), tone: pnlAmount == null ? "" : (pnlAmount > 0 ? "up" : (pnlAmount < 0 ? "down" : "")) }, { label: "平均仓位", value: avgPos == null ? "--" : avgPos + "%" } ]); const cols = { frozenColumns: [{ key: "stock", label: "股票", type: "stock", width: 96 }], primaryColumns: [ { key: "action_label", label: "动作", type: "text" }, { key: "pnl_pct", label: "盈亏%", type: "change" } ], scrollColumns: [ { key: "trade_date", label: "日期", type: "text" }, { key: "position_pct", label: "仓位%", type: "rate" }, { key: "pnl_amount", label: "盈亏额", type: "change" }, { key: "emotion_label", label: "情绪", type: "text" } ] }; const rows = items.map(function (it) { return { code: it.code, name: it.name, action: it.action, action_label: it.action_label, pnl_pct: it.pnl_pct, trade_date: displayCompactDate(it.trade_date), position_pct: it.position_pct, pnl_amount: it.pnl_amount, emotion_label: it.emotion_label, id: it.id, _raw: it }; }); state.sortTable = { cols: cols, reapply: renderReviewTrades }; const sorted = sortedRows(rows, cols); const table = buildReviewTable(cols, sorted, { cellRenderer: function (col, row) { if (col.key === "action_label") return tradeActionBadge(row.action); if (col.key === "pnl_pct") { if (row.pnl_pct == null || row.pnl_pct === "") return '--'; return '' + (number(row.pnl_pct) > 0 ? "+" : "") + formatNumber(number(row.pnl_pct), 2) + ""; } if (col.key === "pnl_amount") { if (row.pnl_amount == null || row.pnl_amount === "") return '--'; return '' + (number(row.pnl_amount) > 0 ? "+" : "") + formatNumber(number(row.pnl_amount), 2) + ""; } if (col.key === "position_pct") { if (row.position_pct == null || row.position_pct === "") return '--'; return '' + formatNumber(number(row.position_pct), 1) + ""; } return null; }, rowAttrs: function (row) { return ' data-trade-id="' + escapeHtml(String(row.id || "")) + '"'; } }); scroll.innerHTML = strip + '
' + table + "
"; } function openTradeDetail(tradeId) { const data = state.review.trades || { items: [] }; const item = (data.items || []).find(function (it) { return String(it.id) === String(tradeId); }); if (!item) return; openSheet( '

交易详情

' + '
" + '
' + tradeDetailBody(item) + "
", { detail: false } ); } function tradeDetailBody(item) { const rows = [ ["股票", (item.name || "--") + " " + (item.code || "")], ["日期", displayCompactDate(item.trade_date)], ["动作", item.action_label || "--"], ["价格", item.price != null ? formatNumber(number(item.price), 2) : "--"], ["数量", item.quantity != null ? intOrDash(item.quantity) : "--"], ["仓位", item.position_pct != null ? formatNumber(number(item.position_pct), 1) + "%" : "--"], ["盈亏额", item.pnl_amount == null ? "--" : (number(item.pnl_amount) > 0 ? "+" : "") + formatNumber(number(item.pnl_amount), 2)], ["盈亏%", item.pnl_pct == null ? "--" : (number(item.pnl_pct) > 0 ? "+" : "") + formatNumber(number(item.pnl_pct), 2) + "%"], ["情绪", item.emotion_label || "--"], ["标签", (item.tags && item.tags.length) ? item.tags.join("、") : "--"] ]; const toneRow = item.pnl_amount == null ? "" : (number(item.pnl_amount) > 0 ? "up" : number(item.pnl_amount) < 0 ? "down" : ""); const grid = rows.map(function (pair) { const isPnl = pair[0] === "盈亏额"; return '
' + escapeHtml(pair[0]) + "" + '' + escapeHtml(pair[1]) + "
"; }).join(""); const block = function (title, text) { return '
' + escapeHtml(title) + "
" + '
' + escapeHtml(text || "尚未填写") + "
"; }; return '
' + grid + "
" + '
' + block("交易逻辑", item.thesis) + block("执行复核", item.execution) + '
' + '' + '' + "
"; } function deleteTrade(tradeId) { openConfirmSheet("删除交易记录", "删除后无法恢复,确定删除这条交易记录吗?", { danger: true, confirmLabel: "删除", onConfirm: function () { global.MobileAPI.request("/api/trades/" + encodeURIComponent(tradeId), "DELETE").then(function (payload) { state.review.trades = { items: payload.items || [], summary: payload.summary || {} }; closeSheet(); renderReviewTrades(); showToast("交易记录已删除"); }).catch(function (error) { showToast(error && error.message ? error.message : "删除失败"); }); } }); } /* ----- 每日复盘 ----- */ function loadReviewDaily() { const seq = nextSeq(); global.MobileAPI.request("/api/notes?scope=daily").then(function (payload) { if (seq !== state.seq) return; state.review.dailyItems = (payload && payload.items) || []; updateHeader("每日复盘", state.requestedDate); renderReviewDaily(); }).catch(function (error) { if (seq !== state.seq) return; const scroll = document.getElementById("m-scroll"); if (scroll) scroll.innerHTML = reviewState("每日复盘"); }); } function dailyNoteFor(dateStr) { const items = state.review.dailyItems || []; const compact = String(dateStr || "").replaceAll("-", ""); return items.find(function (it) { return String(it.trade_date || "").replaceAll("-", "") === compact; }) || null; } function renderReviewDaily() { const scroll = document.getElementById("m-scroll"); if (!scroll) return; const dateStr = state.requestedDate || todayString(); const note = dailyNoteFor(dateStr); const items = state.review.dailyItems || []; const dateLine = '
' + '' + escapeHtml(displayCompactDate(dateStr)) + "" + '" + "
"; if (!note) { const history = dailyHistoryBlock(items, dateStr); scroll.innerHTML = '
' + '' + icon("calendar-check", 26) + "" + "

今日还未复盘

" + '花几分钟回顾今天的盘面与操作' + '' + "
" + history; return; } const card = function (title, text) { return '
' + '
' + escapeHtml(title) + "
" + '

' + escapeHtml(text || "尚未填写") + "

" + "
"; }; const history = dailyHistoryBlock(items, dateStr); scroll.innerHTML = dateLine + card("今日盘面", note.summary) + card("做对做错", note.content) + card("明日计划", note.plan) + '' + history; } function dailyHistoryBlock(items, currentDate) { const sorted = (items || []).slice().sort(function (a, b) { return String(b.trade_date || "").localeCompare(String(a.trade_date || "")); }); if (!sorted.length) return ""; const rows = sorted.map(function (it) { const active = String(it.trade_date || "").replaceAll("-", "") === String(currentDate || "").replaceAll("-", ""); const oneLine = String(it.summary || "").trim() || "(无盘面摘要)"; return '"; }).join(""); return '
' + '
历史复盘
' + '
' + rows + "
" + "
"; } function deleteDailyNote(noteId) { openConfirmSheet("删除此日复盘", "删除后无法恢复,确定删除这一天的复盘吗?", { danger: true, confirmLabel: "删除", onConfirm: function () { global.MobileAPI.request("/api/notes/" + encodeURIComponent(noteId), "DELETE").then(function () { state.review.dailyItems = (state.review.dailyItems || []).filter(function (it) { return String(it.id) !== String(noteId); }); closeSheet(); renderReviewDaily(); showToast("复盘已删除"); }).catch(function (error) { showToast(error && error.message ? error.message : "删除失败"); }); } }); } /* ----- 个股笔记 ----- */ function loadReviewNotes() { const seq = nextSeq(); global.MobileAPI.request("/api/notes?scope=stock").then(function (payload) { if (seq !== state.seq) return; state.review.notes = (payload && payload.items) || []; renderReviewNotes(); }).catch(function (error) { if (seq !== state.seq) return; const scroll = document.getElementById("m-scroll"); if (scroll) scroll.innerHTML = reviewState("个股笔记"); }); } function groupNotesByStock(items) { const groups = {}; (items || []).forEach(function (it) { const key = it.code || "_"; if (!groups[key]) groups[key] = { code: it.code, name: it.stock_name || it.code || "--", notes: [] }; groups[key].notes.push(it); }); return Object.keys(groups).map(function (k) { return groups[k]; }); } function renderReviewNotes() { const scroll = document.getElementById("m-scroll"); if (!scroll) return; const items = state.review.notes || []; if (!items.length) { scroll.innerHTML = '
' + '' + icon("sticky-note", 26) + "" + "

还没有个股笔记

" + "把对个股的观察与计划记下来" + '' + "
"; return; } const groups = groupNotesByStock(items); scroll.innerHTML = groups.map(function (g) { const cards = g.notes.map(function (it) { const plan = it.plan ? '计划:' + escapeHtml(it.plan) + "" : ""; return '"; }).join(""); return '
' + '
' + '' + escapeHtml(g.name) + "" + '' + escapeHtml(g.code || "") + "" + '' + g.notes.length + " 条" + "
" + '
' + cards + "
" + "
"; }).join(""); } function openNoteDetail(noteId) { const item = (state.review.notes || []).find(function (it) { return String(it.id) === String(noteId); }); if (!item) return; openSheet( '

' + escapeHtml((item.stock_name || item.code || "笔记") + " · " + displayCompactDate(item.trade_date)) + "

" + '
" + '
' + '
笔记
' + escapeHtml(item.content || "尚未填写") + "
" + '
计划
' + escapeHtml(item.plan || "尚未填写") + "
" + '
' + '' + "
", { detail: false } ); } function deleteNote(noteId) { openConfirmSheet("删除笔记", "删除后无法恢复,确定删除这条笔记吗?", { danger: true, confirmLabel: "删除", onConfirm: function () { global.MobileAPI.request("/api/notes/" + encodeURIComponent(noteId), "DELETE").then(function () { state.review.notes = (state.review.notes || []).filter(function (it) { return String(it.id) !== String(noteId); }); closeSheet(); renderReviewNotes(); showToast("笔记已删除"); }).catch(function (error) { showToast(error && error.message ? error.message : "删除失败"); }); } }); } /* ----- 提醒中心 ----- */ function loadReviewAlerts() { const seq = nextSeq(); const status = state.review.alertStatus; global.MobileAPI.request("/api/alerts?status=" + status + "&as_of=" + encodeURIComponent(todayString())).then(function (payload) { if (seq !== state.seq) return; state.review.alerts = payload || { items: [], unread_count: 0 }; renderReviewAlerts(); }).catch(function (error) { if (seq !== state.seq) return; const scroll = document.getElementById("m-scroll"); if (scroll) scroll.innerHTML = reviewState("提醒中心"); }); } function alertKindLabel(kind) { if (kind === "manual") return "自定提醒"; if (kind === "strategy_t5") return "跟踪完成"; return "策略反馈"; } function alertKindBadge(kind) { const tone = kind === "manual" ? "action" : kind === "strategy_t5" ? "down" : "warn"; return '' + escapeHtml(alertKindLabel(kind)) + ""; } function renderReviewAlerts() { const scroll = document.getElementById("m-scroll"); if (!scroll) return; const data = state.review.alerts || { items: [], unread_count: 0 }; const items = data.items || []; const unreadCount = number(data.unread_count); const readAllBtn = document.querySelector("[data-review-read-all]"); if (readAllBtn) readAllBtn.hidden = unreadCount <= 0; const filterTabs = ["all", "unread"].map(function (f) { const active = state.review.alertStatus === f; const label = f === "all" ? "全部" : "未读"; const dot = f === "unread" && unreadCount > 0 ? '' : ""; return '"; }).join(""); const tabs = '
' + filterTabs + "
"; if (!items.length) { const emptyText = state.review.alertStatus === "unread" ? "没有未读提醒" : "暂无提醒"; scroll.innerHTML = tabs + '
' + '' + icon("bell", 26) + "" + "

" + escapeHtml(emptyText) + "

" + (state.review.alertStatus === "all" ? '重要节点给自己留个备忘' : "") + "
"; return; } const rows = items.map(function (it) { const upcoming = !it.due; const unread = !it.is_read; const dot = unread && it.due ? '' : ""; const dateText = displayCompactDate(it.available_date); return '"; }).join(""); scroll.innerHTML = tabs + '
' + rows + "
"; } function openAlertDetail(alertId) { const item = (state.review.alerts && state.review.alerts.items || []).find(function (it) { return String(it.id) === String(alertId); }); if (!item) return; const upcoming = !item.due; const canRead = !item.is_read && !upcoming; openSheet( '

提醒详情

' + '
" + '
' + '
' + alertKindBadge(item.kind) + '

' + escapeHtml(item.title) + "

" + '
' + escapeHtml(displayCompactDate(item.available_date)) + (upcoming ? "(未到期)" : "") + "
" + (item.content ? '
' + escapeHtml(item.content) + "
" : "") + (item.code ? '" : "") + "
" + '
' + (canRead ? '' : "") + '' + "
", { detail: false } ); } function markAlertRead(alertId) { global.MobileAPI.request("/api/alerts/" + encodeURIComponent(alertId) + "/read", "POST", {}).then(function (payload) { state.review.alerts = payload || state.review.alerts; closeSheet(); renderReviewAlerts(); showToast("已标为已读"); }).catch(function (error) { showToast(error && error.message ? error.message : "操作失败"); }); } function deleteAlert(alertId) { openConfirmSheet("删除提醒", "删除后无法恢复,确定删除这条提醒吗?", { danger: true, confirmLabel: "删除", onConfirm: function () { global.MobileAPI.request("/api/alerts/" + encodeURIComponent(alertId), "DELETE").then(function (payload) { state.review.alerts = payload || state.review.alerts; closeSheet(); renderReviewAlerts(); showToast("提醒已删除"); }).catch(function (error) { showToast(error && error.message ? error.message : "删除失败"); }); } }); } function markAllAlertsRead() { global.MobileAPI.request("/api/alerts/read-all", "POST", { as_of: todayString() }).then(function (payload) { state.review.alerts = payload || state.review.alerts; renderReviewAlerts(); showToast("全部已读"); }).catch(function (error) { showToast(error && error.message ? error.message : "操作失败"); }); } /* ----- 全屏表单页 ----- */ function openForm(kind, opts) { state.review.form = { kind: kind, opts: opts || {}, action: "buy", emotion: "calm", stock: null }; state.review.dirty = false; const title = kind === "trade" ? ((opts && opts.trade) ? "编辑交易" : "新增交易") : kind === "daily" ? "写复盘" : "新增笔记"; global.MobileRouter.updateHeader({ title: title, back: true, actions: "" }); document.getElementById("m-view").classList.add("m-view-feature"); document.getElementById("m-view").innerHTML = renderForm(); bindForm(); } function closeForm() { if (state.review.dirty) { openConfirmSheet("放弃本次编辑?", "返回后已填写的内容将不会保存。", { confirmLabel: "放弃", onConfirm: function () { exitForm(); } }); return; } exitForm(); } function exitForm() { const key = state.key; const wasDaily = state.review.subKey === "daily"; const keepDate = state.requestedDate; state.review.form = null; state.review.dirty = false; setupReviewPage(key); if (wasDaily) state.requestedDate = keepDate; loadReview(); } function formFieldHtml(label, controlHtml, required, hintHtml) { return '"; } function textInputHtml(id, value, attrs) { return '"; } function numberInputHtml(id, value, attrs) { return '"; } function textareaHtml(id, value, maxlength, rows) { return '
' + '" + '0/' + maxlength + "" + "
"; } function dateInputHtml(id, value) { return '
' + '' + '' + icon("calendar", 16) + "" + "
"; } function segHtml(group, options, selected) { return '
' + options.map(function (opt) { const active = opt.key === selected; return '"; }).join("") + "
"; } function stockPickerHtml(id, stock) { const chip = stock ? '' + '' + escapeHtml(stock.name) + "" + '' + escapeHtml(stock.code) + "" + '" + "" : ""; return '
' + (chip || '') + '' + "
"; } function renderForm() { const form = state.review.form; if (form.kind === "trade") { return renderTradeForm(form); } if (form.kind === "daily") { return renderDailyForm(form); } return renderNoteForm(form); } function renderTradeForm(form) { const t = form.opts.trade || {}; const tags = (t.tags || []).join(", "); const fields = [ formFieldHtml("日期", dateInputHtml("m-trade-date", displayCompactDate(t.trade_date)), true), formFieldHtml("代码", textInputHtml("m-trade-code", t.code, 'inputmode="numeric" maxlength="6" placeholder="6 位代码"'), true), formFieldHtml("名称", textInputHtml("m-trade-name", t.name, 'placeholder="股票名称"'), true), formFieldHtml("动作", segHtml("action", TRADE_ACTION_OPTIONS, form.action || t.action || "buy"), true), formFieldHtml("价格", numberInputHtml("m-trade-price", t.price, 'placeholder="成交价格"'), true), formFieldHtml("数量", numberInputHtml("m-trade-quantity", t.quantity, 'inputmode="numeric" placeholder="成交数量"'), false), formFieldHtml("仓位%", '
' + numberInputHtml("m-trade-position", t.position_pct, 'placeholder="0"') + '%
', false), formFieldHtml("盈亏额", numberInputHtml("m-trade-pnl-amount", t.pnl_amount, 'placeholder="可留空"'), false), formFieldHtml("盈亏%", '
' + numberInputHtml("m-trade-pnl-pct", t.pnl_pct, 'placeholder="可留空"') + '%
', false), formFieldHtml("情绪", segHtml("emotion", TRADE_EMOTION_OPTIONS, form.emotion || t.emotion || "calm"), true), formFieldHtml("标签", textInputHtml("m-trade-tags", tags, 'placeholder="逗号分隔,如 打板、龙头"'), false), formFieldHtml("交易逻辑", textareaHtml("m-trade-thesis", t.thesis, 2000, 4), false), formFieldHtml("执行复核", textareaHtml("m-trade-execution", t.execution, 2000, 4), false) ]; return '
' + '
' + '
' + fields.join("") + "
" + "
" + '
' + "
"; } function renderDailyForm() { const note = state.review.form.opts.note || {}; const fields = [ formFieldHtml("日期", dateInputHtml("m-daily-date", state.requestedDate || todayString()), true), formFieldHtml("今日盘面", textareaHtml("m-daily-summary", note.summary, 500, 3), false), formFieldHtml("做对做错", textareaHtml("m-daily-content", note.content, 5000, 8), false), formFieldHtml("明日计划", textareaHtml("m-daily-plan", note.plan, 2000, 5), false) ]; return '
' + '
' + '
' + fields.join("") + "
" + "
" + '
' + "
"; } function renderNoteForm() { const fields = [ formFieldHtml("股票", stockPickerHtml("m-note-stock", state.review.form.stock), true), formFieldHtml("笔记内容", textareaHtml("m-note-content", "", 5000, 6), false), formFieldHtml("计划", textareaHtml("m-note-plan", "", 2000, 4), false) ]; return '
' + '
' + '
' + fields.join("") + "
" + "
" + '
' + "
"; } function markDirty() { if (state.review.form) state.review.dirty = true; } function bindForm() { const scroll = document.getElementById("m-scroll"); if (!scroll) return; scroll.addEventListener("input", function (event) { if (event.target.matches("input, textarea")) markDirty(); if (event.target.dataset && event.target.dataset.maxlength) { const counter = document.querySelector('[data-counter-for="' + event.target.id + '"]'); if (counter) counter.textContent = event.target.value.length + "/" + event.target.dataset.maxlength; } }); scroll.querySelectorAll("textarea[data-maxlength]").forEach(function (ta) { const counter = document.querySelector('[data-counter-for="' + ta.id + '"]'); if (counter) counter.textContent = ta.value.length + "/" + ta.dataset.maxlength; }); scroll.addEventListener("click", function (event) { const seg = event.target.closest("[data-seg-key]"); if (seg) { const group = seg.parentElement && seg.parentElement.dataset.segGroup; seg.parentElement.querySelectorAll("[data-seg-key]").forEach(function (b) { b.classList.toggle("active", b === seg); b.setAttribute("aria-checked", String(b === seg)); }); state.review.form[group] = seg.dataset.segKey; markDirty(); return; } }); const picker = scroll.querySelector("[data-stock-picker]"); if (picker) bindStockPicker(picker, state.review.form); } function bindStockPicker(picker, target) { const t = target || { stock: null }; let timer = null; let requestSeq = 0; function hideResults() { const results = picker.querySelector("[data-stock-results]"); if (results) { results.hidden = true; results.innerHTML = ""; } } function renderPickerContent() { picker.innerHTML = stockPickerHtml("", t.stock); } function runStockSearch(q) { const seq = ++requestSeq; global.MobileAPI.request("/api/search?q=" + encodeURIComponent(q) + "&trade_date=" + encodeURIComponent(todayString())).then(function (payload) { if (seq !== requestSeq) return; const stocks = (payload && payload.groups && payload.groups.stocks) || []; const results = picker.querySelector("[data-stock-results]"); if (!results) return; results.hidden = false; results.innerHTML = stocks.length ? stocks.map(function (s) { return '"; }).join("") : '
没有找到匹配的股票
'; }).catch(function () { if (seq !== requestSeq) return; const results = picker.querySelector("[data-stock-results]"); if (!results) return; results.hidden = false; results.innerHTML = '
搜索失败
'; }); } picker.addEventListener("input", function (event) { if (event.target.tagName !== "INPUT") return; markDirty(); clearTimeout(timer); const q = event.target.value.trim(); if (!q) { hideResults(); return; } timer = setTimeout(function () { runStockSearch(q); }, 180); }); picker.addEventListener("focusin", function (event) { if (event.target.tagName !== "INPUT") return; const q = event.target.value.trim(); if (q) runStockSearch(q); }); picker.addEventListener("click", function (event) { const row = event.target.closest("[data-stock-pick]"); if (row) { t.stock = { code: row.dataset.stockPick, name: row.dataset.stockName, sector: row.dataset.stockSector }; hideResults(); renderPickerContent(); markDirty(); return; } const clear = event.target.closest("[data-stock-clear]"); if (clear) { t.stock = null; renderPickerContent(); markDirty(); return; } }); document.addEventListener("click", function onDoc(event) { if (!picker.contains(event.target)) hideResults(); }); } function fieldError(id, message) { const input = document.getElementById(id); if (!input) return; const label = input.closest(".m-form-field"); if (!label) return; label.classList.add("is-invalid"); let err = label.querySelector(".m-field-error"); if (!err) { err = document.createElement("span"); err.className = "m-field-error"; label.appendChild(err); } err.textContent = message; } function clearFieldError(id) { const input = document.getElementById(id); if (!input) return; const label = input.closest(".m-form-field"); if (!label) return; label.classList.remove("is-invalid"); const err = label.querySelector(".m-field-error"); if (err) err.remove(); } function numberValue(id) { const el = document.getElementById(id); const raw = el ? el.value.trim() : ""; return raw === "" ? "" : number(raw); } function submitForm() { const form = state.review.form; if (!form) return; if (form.kind === "trade") submitTradeForm(); else if (form.kind === "daily") submitDailyForm(); else submitNoteForm(); } function setSubmitting(btn, submitting) { if (!btn) return; btn.disabled = submitting; if (submitting) { btn.innerHTML = '保存中…'; } else { btn.innerHTML = "保存"; } } function submitTradeForm() { const form = state.review.form; const trade = form.opts.trade || {}; const tradeDate = document.getElementById("m-trade-date").value; const code = document.getElementById("m-trade-code").value.trim(); const name = document.getElementById("m-trade-name").value.trim(); const price = numberValue("m-trade-price"); const position = numberValue("m-trade-position"); const pnlAmount = numberValue("m-trade-pnl-amount"); const pnlPct = numberValue("m-trade-pnl-pct"); const tags = document.getElementById("m-trade-tags").value.trim(); clearFieldError("m-trade-code"); clearFieldError("m-trade-name"); clearFieldError("m-trade-price"); let invalid = false; if (!code) { fieldError("m-trade-code", "请输入股票代码"); invalid = true; } if (!name) { fieldError("m-trade-name", "请输入股票名称"); invalid = true; } if (price === "") { fieldError("m-trade-price", "请输入成交价格"); invalid = true; } if (invalid) return; const payload = { trade_date: tradeDate || todayString(), code: code, name: name, action: form.action, price: price, quantity: number(document.getElementById("m-trade-quantity").value || 0), position_pct: position === "" ? 0 : position, pnl_amount: pnlAmount === "" ? null : pnlAmount, pnl_pct: pnlPct === "" ? null : pnlPct, emotion: form.emotion, tags: tags ? tags.split(/[,,]/).map(function (s) { return s.trim(); }).filter(Boolean) : [], thesis: document.getElementById("m-trade-thesis").value.trim(), execution: document.getElementById("m-trade-execution").value.trim() }; if (trade.id) payload.id = trade.id; const btn = document.querySelector("[data-review-submit]"); setSubmitting(btn, true); global.MobileAPI.request("/api/trades", "POST", payload).then(function () { state.review.dirty = false; exitForm(); showToast("交易已保存"); }).catch(function (error) { setSubmitting(btn, false); showToast(error && error.message ? error.message : "保存失败"); }); } function submitDailyForm() { const form = state.review.form; const note = form.opts.note || {}; const summary = document.getElementById("m-daily-summary").value.trim(); const content = document.getElementById("m-daily-content").value.trim(); const plan = document.getElementById("m-daily-plan").value.trim(); const tradeDate = document.getElementById("m-daily-date").value || todayString(); if (!summary && !content && !plan) { showToast("复盘内容不能全部为空"); return; } const payload = { trade_date: tradeDate, summary: summary, content: content, plan: plan }; if (note.id) payload.id = note.id; const btn = document.querySelector("[data-review-submit]"); setSubmitting(btn, true); global.MobileAPI.request("/api/notes", "POST", payload).then(function () { state.review.dirty = false; state.requestedDate = tradeDate; exitForm(); showToast("已保存"); }).catch(function (error) { setSubmitting(btn, false); showToast(error && error.message ? error.message : "保存失败"); }); } function submitNoteForm() { const form = state.review.form; const stock = form.stock; if (!stock || !stock.code) { showToast("请先选择股票"); return; } const payload = { code: stock.code, stock_name: stock.name, trade_date: todayString(), content: document.getElementById("m-note-content").value.trim(), plan: document.getElementById("m-note-plan").value.trim() }; if (!payload.content && !payload.plan) { showToast("笔记内容不能为空"); return; } const btn = document.querySelector("[data-review-submit]"); setSubmitting(btn, true); global.MobileAPI.request("/api/notes", "POST", payload).then(function () { state.review.dirty = false; exitForm(); showToast("笔记已保存"); }).catch(function (error) { setSubmitting(btn, false); showToast(error && error.message ? error.message : "保存失败"); }); } /* ----- 提醒新增抽屉 ----- */ function openAlertAddDrawer() { state.review.alertDraft = { stock: null }; openSheet( '

新增提醒

' + '
" + '
' + alertAddDrawerBody() + "
", { detail: false } ); const body = document.getElementById("m-alert-add-body"); const picker = body.querySelector("[data-stock-picker]"); if (picker) bindStockPicker(picker, state.review.alertDraft); } function alertAddDrawerBody() { return '' + formFieldHtml("提醒日期", dateInputHtml("m-alert-date", todayString()), true) + formFieldHtml("关联股票", stockPickerHtml("m-alert-stock", state.review.alertDraft ? state.review.alertDraft.stock : null), false) + formFieldHtml("内容", textareaHtml("m-alert-content", "", 500, 3), false) + '
'; } function submitAlertAdd() { const title = document.getElementById("m-alert-title").value.trim(); if (!title) { showToast("请输入提醒标题"); return; } const stock = state.review.alertDraft && state.review.alertDraft.stock; const payload = { title: title, remind_date: document.getElementById("m-alert-date").value || todayString(), code: stock ? stock.code : "", content: document.getElementById("m-alert-content").value.trim() }; global.MobileAPI.request("/api/alerts", "POST", payload).then(function (res) { state.review.alerts = res || state.review.alerts; closeSheet(); renderReviewAlerts(); showToast("已添加提醒"); }).catch(function (error) { showToast(error && error.message ? error.message : "保存失败"); }); } function buildReviewTable(cols, rows, opts) { const o = 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 off = left; left += columnWidth(col); return off; }); 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 = !o.noSort && 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) { const custom = o.cellRenderer ? o.cellRenderer(col, row, rIndex) : null; const html = custom != null ? custom : cellHtml(col, row, rIndex); return cellOpen(col, i, false) + html + ""; }).join(""); const linkable = !o.noLink && /^\d{6}$/.test(String(row.code || "")); const code = linkable ? ' data-code="' + escapeHtml(row.code) + '"' : ""; const extra = o.rowAttrs ? o.rowAttrs(row) : ""; return "" + cells + ""; }).join(""); return '' + head + "" + body + "
"; } /* ---------------------------------------------------------------- events */ function bindEvents() { window.addEventListener("hashchange", closeSheet); // 全屏表单页的返回:优先回到列表/查看态,而非离开路由(capture 阶段先于路由层返回) document.getElementById("m-back").addEventListener("click", function (event) { if (state.review && state.review.form) { event.preventDefault(); event.stopPropagation(); closeForm(); } }, true); 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 reviewAddTrade = event.target.closest("[data-review-add-trade]"); if (reviewAddTrade) { openForm("trade"); return; } const reviewAddNote = event.target.closest("[data-review-add-note]"); if (reviewAddNote) { openForm("note"); return; } const reviewAddAlert = event.target.closest("[data-review-add-alert]"); if (reviewAddAlert) { openAlertAddDrawer(); return; } const reviewReadAll = event.target.closest("[data-review-read-all]"); if (reviewReadAll) { markAllAlertsRead(); return; } const reviewEditTrade = event.target.closest("[data-review-edit-trade]"); if (reviewEditTrade) { const id = reviewEditTrade.dataset.tradeId; const data = state.review.trades || { items: [] }; const item = (data.items || []).find(function (it) { return String(it.id) === String(id); }); closeSheet(); if (item) openForm("trade", { trade: item }); return; } const reviewDeleteTrade = event.target.closest("[data-review-delete-trade]"); if (reviewDeleteTrade) { deleteTrade(reviewDeleteTrade.dataset.tradeId); return; } const reviewEditDaily = event.target.closest("[data-review-edit-daily]"); if (reviewEditDaily) { const note = dailyNoteFor(state.requestedDate || todayString()); openForm("daily", { note: note }); return; } const reviewDeleteDaily = event.target.closest("[data-review-delete-daily]"); if (reviewDeleteDaily) { deleteDailyNote(reviewDeleteDaily.dataset.noteId); return; } const dailyDateRow = event.target.closest("[data-daily-date]"); if (dailyDateRow) { state.requestedDate = dailyDateRow.dataset.dailyDate; updateHeader("每日复盘", state.requestedDate); renderReviewDaily(); return; } const reviewDeleteNote = event.target.closest("[data-review-delete-note]"); if (reviewDeleteNote) { deleteNote(reviewDeleteNote.dataset.noteId); return; } const noteCard = event.target.closest("[data-note-id]"); if (noteCard) { openNoteDetail(noteCard.dataset.noteId); return; } const alertRow = event.target.closest("[data-alert-id]"); if (alertRow) { openAlertDetail(alertRow.dataset.alertId); return; } const alertRead = event.target.closest("[data-review-alert-read]"); if (alertRead) { markAlertRead(alertRead.dataset.alertId); return; } const alertDelete = event.target.closest("[data-review-alert-delete]"); if (alertDelete) { deleteAlert(alertDelete.dataset.alertId); return; } const alertFilter = event.target.closest("[data-review-alert-filter]"); if (alertFilter) { state.review.alertStatus = alertFilter.dataset.reviewAlertFilter === "unread" ? "unread" : "all"; loadReviewAlerts(); return; } const reviewSubmit = event.target.closest("[data-review-submit]"); if (reviewSubmit) { submitForm(); return; } const alertSave = event.target.closest("[data-alert-save]"); if (alertSave) { submitAlertAdd(); return; } const alertStock = event.target.closest("[data-alert-stock]"); if (alertStock) { openDetailSheet(alertStock.dataset.code); return; } const row = event.target.closest("[data-code]"); if (row) { const interactive = event.target.closest("button, a, input, select, textarea"); if (!interactive) { if (state.key === "review/trades" && row.dataset.tradeId) { openTradeDetail(row.dataset.tradeId); } else if (state.key === "tools/tracking" && row.dataset.trackId) { openTrackingDetailSheet(row.dataset.code, row.dataset.trackId); } else if (state.key === "tools/screener") { openScreenerDetailSheet(row.dataset.code); } else { openDetailSheet(row.dataset.code); } } } // 智能选股:视图切换 const screenerView = event.target.closest("[data-screener-view]"); if (screenerView) { const view = screenerView.dataset.screenerView; if (view && view !== state.screener.view) { state.screener.view = view; state.sort = { key: "", dir: null }; renderScreener(); } return; } // 智能选股:策略胶囊行(开抽屉) const screenerOpen = event.target.closest("[data-screener-open-drawer]"); if (screenerOpen) { openScreenerStrategyDrawer(); return; } // 智能选股:日期按钮 const screenerDate = event.target.closest("[data-screener-date]"); if (screenerDate) { openDateSheet(); return; } // 策略跟踪:刷新按钮 const trackingRefresh = event.target.closest("[data-tracking-refresh]"); if (trackingRefresh) { const icon = trackingRefresh.querySelector("svg"); if (icon) icon.classList.add("is-spinning"); refreshTracking(); return; } // 聊天工作台(复盘助手 / 问师):标题区操作位 const chatClear = event.target.closest("[data-chat-clear]"); if (chatClear) { if (state.chat.page === "assistant/chat") { openConfirmSheet("清空对话", "将删除当前账号的全部复盘助手对话历史,无法恢复。", { danger: true, confirmLabel: "清空", onConfirm: clearAssistantMessages }); } else if (state.chat.page === "tools/mentor") { if (!state.chat.mentorId) { showToast("请先选择导师"); return; } openConfirmSheet("清空当日对话", "将清空该导师今天的对话记录,无法恢复。", { danger: true, confirmLabel: "清空", onConfirm: function () { clearMentorMessages(state.chat.mentorId, todayString()); } }); } return; } const chatMentors = event.target.closest("[data-chat-mentors]"); if (chatMentors) { openMentorDrawer(); return; } const chatSend = event.target.closest("[data-chat-send]"); if (chatSend) { if (state.chat.streaming) stopChatStream(); else submitChatMessage(); return; } const chatPreset = event.target.closest("[data-chat-preset]"); if (chatPreset) { sendChatMessage(chatPreset.dataset.chatPreset); return; } const chatFollowup = event.target.closest("[data-chat-followup]"); if (chatFollowup) { sendChatMessage(chatFollowup.dataset.chatFollowup); return; } const chatRemoveMentor = event.target.closest("[data-chat-remove-mentor]"); if (chatRemoveMentor) { state.chat.mentorId = ""; state.chat.mentorName = ""; state.chat.mentorTagline = ""; state.chat.mentorGrade = ""; state.chat.followUps = []; state.chat.messages = []; renderChatStream(); renderChatMentorBar(); return; } }); } /* ---------------------------------------------------------------- init */ bindEvents(); global.MobilePages = { render: renderPage, has: function (key) { return Boolean(pageConfig(key) || isComplexPage(key)); }, }; })(window);