(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": ' ',
};
function icon(name, size) {
const body = ICONS[name] || "";
return '' + body + " ";
}
const TRIANGLE_UP = ' ';
const TRIANGLE_DOWN = ' ';
function sortIndicatorHtml(active) {
if (active) {
const up = state.sort.dir === "asc";
return '' + (up ? TRIANGLE_UP : TRIANGLE_DOWN) + " ";
}
return '' + TRIANGLE_UP + TRIANGLE_DOWN + " ";
}
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,
},
};
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 '";
}
function skeletonHtml(rowCount) {
const rows = [];
for (let i = 0; i < rowCount; i += 1) {
rows.push('
');
}
return '' + rows.join("") + "
";
}
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 '' + cells.join("") + "
";
}
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 '' +
icon("calendar", 15) +
'' + escapeHtml(dateText || state.requestedDate || todayString()) + " " +
" ";
}
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 '' + escapeHtml(labels[source] || source) + " ";
}).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 '' +
svgAxisText(right, padT + 8, "end", "", "100") +
svgAxisText(right, padT + ph / 2 + 3, "end", "", "50") +
svgAxisText(right, padT + ph, "end", "", "0") +
' ' +
' ' +
xLabels +
" ";
}
/* ----- 情绪周期 ----- */
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 '' + n + "日 ";
}).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 '' +
'' + escapeHtml(s.name) + " " +
'' + escapeHtml(s.code) + " " +
(onePrice ? '一字 ' : "") +
(broken ? '烂板×' + number(s.open_times) + " " : "") +
" " +
'' + escapeHtml(s.sector || s.reason || "其他") + " " +
"" + escapeHtml(s.first_time && s.first_time !== "--" ? s.first_time : "时间待校正") + " · " + escapeHtml(amount) + " " +
" ";
}).join("");
if (cap && stocks.length > cap) {
const remaining = stocks.length - cap;
foldBtn = '' +
(expanded ? "收起 \u25b4" : "展开剩余 " + remaining + " 只 \u25be") + " ";
}
}
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 '' +
'' + number(sector.rank) + " " +
'' +
"" + escapeHtml(sector.name) + " " +
"" + number(sector.count) + " 家 · " + formatNumber(number(sector.strength), 0) + " " +
" " +
" ";
}).join("");
return '' +
'' + escapeHtml(displayCompactDate(day.trade_date).slice(5)) + " " +
"" + 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) + "成分股 " +
'' + icon("close", 20) + " " +
'' + 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 '' + escapeHtml(d.label) + " ";
}).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 '' +
'' + (index + 1) + " " +
'' + escapeHtml(item.name) + " " +
"" + number(item.member_count) + " 只成分" + (item.hot_rank ? " · 人气第 " + number(item.hot_rank) : "") + " " +
'' + (item.has_quote ? (number(item.change) > 0 ? "+" : "") + formatNumber(number(item.change), 2) + "%" : "--") + " " +
" ";
}).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(
'
题材详情 ' +
'' + icon("close", 20) + " " +
'' + 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 '' +
'' + String(index + 1).padStart(2, "0") + " " +
'' + escapeHtml(trader.name.slice(0, 2)) + " " +
'' + escapeHtml(trader.name) + " " +
"" + escapeHtml(desc) + " " +
'' + number(trader.stock_count) + " 股 · " + number(trader.operation_count) + " 笔 " +
'' + formatMoneyMillion(trader.net_buy_million) + " ";
}).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 '' +
'' + escapeHtml(profile.name.slice(0, 2)) + " " +
'' + escapeHtml(profile.name) + " " +
"" + escapeHtml(profile.description || "暂未收录简介") + " " +
'' + number(profile.organization_count) + " 席 ";
}).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) + " 笔
" +
'
' + icon("close", 20) + " " +
'' +
'
' +
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(
'
游资档案 ' +
'' + icon("close", 20) + " " +
'' +
'
' + 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) + ' ' +
'' + icon("close", 20) + ' ' +
'' +
(body ? '
' + escapeHtml(body) + '
' : '') +
'
' +
'' + escapeHtml(cancelLabel) + ' ' +
'' + escapeHtml(confirmLabel) + ' ' +
'
',
{ 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;
}
// 持续有效 / 入选历史:对齐电脑端 selectedScreenerArchiveRows,按当前策略过滤 active_signals/candidate_history 组装行
function screenerArchiveRows(payload, view) {
if (!payload) return [];
const source = view === "active" ? (payload.active_signals || []) : (payload.candidate_history || []);
const strategy = screenerCurrentStrategy(payload);
const strategyName = strategy ? strategy.name : "";
const mode = screenerLibraryMode(screenerCurrentLibrary(payload));
return source.filter(function (row) {
if ((row.mode || "smart") !== mode) return false;
return (row.hits || []).some(function (hit) { return hit.strategy_name === strategyName; });
}).map(function (row) {
let hits = (row.hits || []).filter(function (hit) { return hit.strategy_name === strategyName; });
if (view === "active") hits = hits.filter(function (hit) { return hit.active; });
const latestHit = hits.slice().sort(function (a, b) {
return String(b.selection_date || "").localeCompare(String(a.selection_date || ""));
})[0] || {};
return {
code: row.code,
name: row.name,
sector: row.sector,
pct_chg: row.pct_chg,
return_5d: row.return_5d,
score_display: latestHit.score_display != null ? latestHit.score_display : row.score_display,
selection_date: displayCompactDate(latestHit.selection_date || row.selection_date || ""),
status: view === "active" ? "持续有效" : "已入选"
};
});
}
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 '' + escapeHtml(SCREENER_VIEW_LABELS[key]) + ' ';
}).join("");
const viewTabs = '' + tabs + '
';
// 当前策略胶囊行
const strategyName = state.screener.strategyName || "选择策略";
const statusText = screenerStrategyStatusText(payload);
const strategyRow =
'' +
'' + icon("filter", 16) + ' ' +
'' + escapeHtml(strategyName) + ' ' +
(statusText ? '' + escapeHtml(statusText) + ' ' : '') +
'' + icon("chevron-down", 16) + ' ' +
' ';
// 表格容器
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) {
if (state.screener.view === "active") return "该策略暂无持续有效信号";
if (state.screener.view === "history") return "该策略暂无入选历史";
const status = screenerStrategyStatusText(payload);
if (status === "数据不足") return "该策略数据不足,暂无法出候选";
if (status === "等待盘后") return "该策略等待盘后更新";
if (status === "选择策略") 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 = '';
}
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(
'
选择策略 ' +
'' + icon("close", 20) + ' ' +
'
',
{ 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 '' + escapeHtml(c.label) + ' ';
}).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 '' +
'' +
'' + escapeHtml(s.name) + (isCurrent ? ' ' + icon("check", 16) + ' ' : '') + ' ' +
(regimeText ? '' + escapeHtml(regimeText) + ' ' : '') +
' ' +
'' + escapeHtml(status) + ' ' +
' ';
}).join("") || '';
return '' + icon("search", 16) + '
' +
'' + catPills + '
' +
'' + 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 = '';
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 = '' +
'' + escapeHtml(state.chat.mentorName || "") + gradeBadge + ' ' +
'× ' +
' ';
}
function chatPresetPillsHtml() {
return '' +
'
' +
ASSISTANT_PRESET_QUESTIONS.map(function (q) {
return '' + escapeHtml(q) + ' ';
}).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 '' + escapeHtml(q) + ' ';
}).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 '' +
'' +
'' + escapeHtml(m.name) + ' ' +
'' + escapeHtml(m.tagline || "") + ' ' +
' ' +
'' + escapeHtml(grade || "—") + ' ' +
(isCurrent ? '' + icon("check", 16) + ' ' : '') +
' ';
}).join("") || '';
openSheet(
'
选择导师 ' +
'' + icon("close", 20) + ' ' +
'' +
'
' + icon("search", 16) + '
' +
'
' + 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,
};
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,
};
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(
'' +
'
选择日期 ' +
'' + icon("close", 20) + " " +
"" +
'
',
{ 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 '' + escapeHtml(item.label) + " ";
}).join("");
body.innerHTML =
'' + quickHtml + "
" +
'' +
'" + icon("chevron-left", 18) + " " +
'' + year + " 年 " + (month + 1) + " 月 " +
'" + icon("chevron-right", 18) + " " +
"
" +
'' + ["一", "二", "三", "四", "五", "六", "日"].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(
'" + d + " "
);
}
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) + " " +
"
" +
'
' +
'' + starIcon(false) + " " +
'' + icon("close", 20) + " " +
"
" +
'",
{ detail: true }
);
loadDetail(code);
}
function loadDetail(code) {
const token = sheetToken;
global.MobileAPI.request("/api/stock/" + encodeURIComponent(code) + "/preview").then(function (payload) {
if (token !== sheetToken) return;
renderDetail(payload);
}).catch(function (error) {
if (token !== sheetToken) return;
const body = document.getElementById("m-detail-sheet-body");
if (body) body.innerHTML = errorHtml(error && error.message ? error.message : "行情预览加载失败");
});
}
function chartCaptionHtml(tab) {
const detail = state.detail;
const payload = detail && detail.payload ? detail.payload : {};
const meta = payload.meta || {};
if (tab === "daily") {
const bars = (payload.prices || []).slice(-48);
const last = bars.length ? bars[bars.length - 1].trade_date : "";
return "日线 · 近48根 · 至 " + (displayCompactDate(last) || "--");
}
const d = displayCompactDate(meta.intraday_trade_date) || displayCompactDate(meta.trade_date);
return "分时 · " + (d || "--");
}
function switchChartTab(tab) {
const detail = state.detail;
if (!detail || !detail.payload) return;
detail.tab = tab;
const payload = detail.payload;
const meta = payload.meta || {};
const chart = document.getElementById("m-detail-chart");
if (chart) {
if (tab === "daily") {
chart.innerHTML = dailyChart(payload);
} else if (meta.intraday_status === "available") {
chart.innerHTML = intradayChart(payload);
} else {
chart.innerHTML = emptyChart(meta.intraday_notice || "分时数据暂不可用");
}
}
const caption = document.getElementById("m-detail-caption");
if (caption) caption.textContent = chartCaptionHtml(tab);
document.querySelectorAll(".m-detail-tab").forEach(function (btn) {
const active = btn.dataset.chartTab === tab;
btn.classList.toggle("active", active);
btn.setAttribute("aria-selected", String(active));
});
}
function updateStarButton() {
const btn = document.querySelector(".m-detail-star");
if (!btn) return;
const watched = Boolean(state.detail && state.detail.watchlist);
btn.classList.toggle("is-added", watched);
btn.setAttribute("aria-label", watched ? "移出自选" : "加入自选");
btn.setAttribute("aria-pressed", String(watched));
btn.innerHTML = starIcon(watched);
btn.disabled = false;
}
function toggleWatch() {
const detail = state.detail;
if (!detail || !detail.code) return;
const btn = document.querySelector(".m-detail-star");
const adding = !detail.watchlist;
const code = detail.code;
const url = adding ? "/api/watchlist" : "/api/watchlist/" + encodeURIComponent(code);
const method = adding ? "POST" : "DELETE";
const body = adding ? { code: code, name: detail.name || code, sector: detail.sector || "" } : null;
if (btn) btn.disabled = true;
global.MobileAPI.request(url, method, body).then(function () {
detail.watchlist = adding;
updateStarButton();
showToast(adding ? "已加入自选" : "已移出自选");
}).catch(function () {
updateStarButton();
showToast("操作失败,请重试");
});
}
function showToast(message) {
const root = document.getElementById("m-sheet-root");
if (!root) return;
let toast = document.getElementById("m-toast");
if (!toast) {
toast = document.createElement("div");
toast.id = "m-toast";
toast.className = "m-toast";
root.appendChild(toast);
}
toast.textContent = message;
toast.classList.remove("is-visible");
void toast.offsetWidth;
toast.classList.add("is-visible");
global.clearTimeout(toast._timer);
toast._timer = global.setTimeout(function () {
toast.classList.remove("is-visible");
}, 1500);
}
function renderDetail(payload) {
const body = document.getElementById("m-detail-sheet-body");
if (!body) return;
const stock = payload.stock || {};
const meta = payload.meta || {};
const name = stock.name && stock.name !== "--" ? stock.name : "--";
const price = number(stock.price);
const change = number(stock.change);
const industry = stock.industry && stock.industry !== "其他" ? stock.industry : (stock.sector || "其他");
state.detail = {
code: stock.code || "",
name: name === "--" ? "" : name,
sector: industry === "其他" ? "" : industry,
watchlist: Boolean(stock.watchlist),
tab: "intraday",
payload: payload,
};
const headName = document.querySelector(".m-detail-head-name");
const headCode = document.querySelector(".m-detail-head-code");
if (headName) headName.textContent = name;
if (headCode) headCode.textContent = stock.code || "";
updateStarButton();
const intradayOk = meta.intraday_status === "available";
const chartHtml = intradayOk ? intradayChart(payload) : emptyChart(meta.intraday_notice || "分时数据暂不可用");
body.innerHTML =
'' +
'
' +
'' + (price ? formatNumber(price, 2) : "--") + " " +
'' + (change > 0 ? "+" : "") + formatNumber(change, 2) + "% " +
"
" +
'
' + escapeHtml(industry) + " · " + escapeHtml(displayCompactDate(meta.trade_date) || "--") + " 收盘
" +
"
" +
'' +
'分时 ' +
'日线 ' +
'' + escapeHtml(chartCaptionHtml("intraday")) + " " +
"
" +
'' + chartHtml + "
" +
'点按图面可读数值 · 数据来源:本地行情
';
}
/* ---------------------------------------------------------------- charts */
function emptyChart(message) {
return '' + escapeHtml(message || "数据暂不可用") + "
";
}
function svgAxisText(x, y, anchor, cls, content) {
return '' + escapeHtml(content) + " ";
}
function dateMMDD(value) {
const s = String(value || "");
return s.length >= 10 ? s.slice(5, 10) : s;
}
function movingAverage(values, period) {
const out = [];
for (let i = 0; i < values.length; i += 1) {
if (i < period - 1) { out.push(null); continue; }
let sum = 0;
for (let j = i - period + 1; j <= i; j += 1) sum += values[j];
out.push(sum / period);
}
return out;
}
function maPath(ma, xf, yf, cls) {
const pts = [];
for (let i = 0; i < ma.length; i += 1) {
if (ma[i] == null) continue;
pts.push(xf(i).toFixed(1) + " " + yf(ma[i]).toFixed(1));
}
return pts.length > 1 ? ' ' : "";
}
function intradayChart(payload) {
const W = 360, H = 240, padL = 8, padR = 52, padT = 10, padB = 22;
const pw = W - padL - padR;
const ph = H - padT - padB;
const points = payload.intraday || [];
const meta = payload.meta || {};
const prevClose = number(meta.intraday_previous_close) || (points.length ? number(points[0].close) : 0);
if (!points.length || prevClose <= 0) {
return emptyChart(meta.intraday_notice || "分时数据暂不可用");
}
let maxPct = 2;
points.forEach(function (p) {
const cp = number(p.close);
const ap = number(p.average);
if (cp > 0) maxPct = Math.max(maxPct, Math.abs((cp - prevClose) / prevClose * 100));
if (ap > 0) maxPct = Math.max(maxPct, Math.abs((ap - prevClose) / prevClose * 100));
});
maxPct = Math.max(0.01, Math.ceil(maxPct * 100) / 100);
function x(i) {
return padL + (points.length <= 1 ? pw / 2 : i / (points.length - 1) * pw);
}
function y(close) {
const pct = (close - prevClose) / prevClose * 100;
return padT + (maxPct - pct) / (2 * maxPct) * ph;
}
const pricePath = points.map(function (p, i) {
return (i ? "L" : "M") + x(i).toFixed(1) + " " + y(number(p.close)).toFixed(1);
}).join(" ");
const avgPts = [];
points.forEach(function (p, i) {
const a = number(p.average);
if (a <= 0) return;
avgPts.push((avgPts.length ? "L" : "M") + x(i).toFixed(1) + " " + y(a).toFixed(1));
});
const avgPath = avgPts.join(" ");
const y0 = y(prevClose).toFixed(1);
const right = W - 4;
return '' +
' ' +
' ' +
' ' +
svgAxisText(right, padT + 8, "end", "is-up", "+" + maxPct.toFixed(2) + "%") +
svgAxisText(right, padT + ph / 2 + 3, "end", "", "0.00%") +
svgAxisText(right, padT + ph, "end", "is-down", "-" + maxPct.toFixed(2) + "%") +
svgAxisText(padL, H - 6, "start", "", "09:30") +
svgAxisText(padL + pw / 2, H - 6, "middle", "", "11:30/13:00") +
svgAxisText(padL + pw, H - 6, "end", "", "15:00") +
" ";
}
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 '' +
'MA5 ' +
'MA10 ' +
"
" +
'' +
candles +
maPath(ma5, x, y, "is-ma5") +
maPath(ma10, x, y, "is-ma10") +
svgAxisText(right, padT + 8, "end", "is-up", formatNumber(max, 2)) +
svgAxisText(right, padT + ph, "end", "is-down", formatNumber(min, 2)) +
svgAxisText(padL, H - 6, "start", "", dateMMDD(firstDate)) +
svgAxisText(padL + pw / 2, H - 6, "middle", "", dateMMDD(midDate)) +
svgAxisText(padL + pw, H - 6, "end", "", dateMMDD(lastDate)) +
" ";
}
/* ---------------------------------------------------------------- events */
function bindEvents() {
window.addEventListener("hashchange", closeSheet);
document.addEventListener("click", function (event) {
const dateBtn = event.target.closest("[data-action=date]");
if (dateBtn) { openDateSheet(); return; }
const retry = event.target.closest("[data-action=retry]");
if (retry) { reloadCurrent(); return; }
const sortHead = event.target.closest("[data-sort-key]");
if (sortHead) { toggleSort(sortHead.dataset.sortKey); return; }
const rangeTab = event.target.closest("[data-sentiment-range]");
if (rangeTab) {
state.sentimentRange = number(rangeTab.dataset.sentimentRange) || 20;
document.querySelectorAll("[data-sentiment-range]").forEach(function (tab) {
const active = number(tab.dataset.sentimentRange) === state.sentimentRange;
tab.classList.toggle("active", active);
tab.setAttribute("aria-pressed", String(active));
});
loadSentiment();
return;
}
const auctionDataset = event.target.closest("[data-action=auction-dataset]");
if (auctionDataset) {
state.auctionDataset = auctionDataset.dataset.dataset || "focus";
document.querySelectorAll("[data-action=auction-dataset]").forEach(function (tab) {
const active = tab.dataset.dataset === state.auctionDataset;
tab.classList.toggle("active", active);
tab.setAttribute("aria-selected", String(active));
});
renderAuctionTable();
return;
}
const dragonMode = event.target.closest("[data-action=dragon-mode]");
if (dragonMode) {
const mode = dragonMode.dataset.mode;
state.dragonViewMode = mode === "profiles" ? "profiles" : "daily";
if (mode === "profiles") loadDragonProfiles();
else if (state.dragon) renderDragon();
else loadDragon();
return;
}
const ladderStock = event.target.closest("[data-ladder-stock]");
if (ladderStock) { openDetailSheet(ladderStock.dataset.ladderStock); return; }
const ladderExpand = event.target.closest("[data-ladder-expand]");
if (ladderExpand) {
const level = number(ladderExpand.dataset.ladderExpand);
if (expandedLadder[level]) delete expandedLadder[level];
else expandedLadder[level] = true;
renderLadder();
return;
}
const rotationSector = event.target.closest("[data-rotation-sector]");
if (rotationSector) {
openRotationMembersSheet(rotationSector.dataset.rotationSector, rotationSector.dataset.rotationDate);
return;
}
const themeCode = event.target.closest("[data-theme-code]");
if (themeCode) { openThemeDetailSheet(themeCode.dataset.themeCode); return; }
const dragonTrader = event.target.closest("[data-dragon-trader]");
if (dragonTrader) { openDragonTraderSheet(dragonTrader.dataset.dragonTrader); return; }
const profileId = event.target.closest("[data-profile-id]");
if (profileId) { openProfileSheet(profileId.dataset.profileId); return; }
const watchBtn = event.target.closest("[data-action=watch]");
if (watchBtn) { toggleWatch(); return; }
const chartTab = event.target.closest("[data-chart-tab]");
if (chartTab) { switchChartTab(chartTab.dataset.chartTab); return; }
const sourceTab = event.target.closest("[data-action=source]");
if (sourceTab) {
state.popularitySource = sourceTab.dataset.source || "combined";
renderTableBody();
document.querySelectorAll(".m-source-tab").forEach(function (tab) {
const active = tab.dataset.source === state.popularitySource;
tab.classList.toggle("active", active);
tab.setAttribute("aria-selected", String(active));
});
return;
}
const closeBtn = event.target.closest("[data-sheet-close]");
if (closeBtn) { closeSheet(); return; }
const backdrop = event.target.closest("[data-sheet-backdrop]");
if (backdrop) { closeSheet(); return; }
const quick = event.target.closest("[data-quick-date]");
if (quick) { selectDate(quick.dataset.quickDate); return; }
const calNav = event.target.closest("[data-cal-nav]");
if (calNav) {
if (calNav.disabled) return;
const dir = calNav.dataset.calNav === "next" ? 1 : -1;
const monthIndex = state.calCursor.year * 12 + state.calCursor.month + dir;
state.calCursor = { year: Math.floor(monthIndex / 12), month: ((monthIndex % 12) + 12) % 12 };
renderDateSheetBody();
return;
}
const calCell = event.target.closest("[data-date]");
if (calCell) {
if (calCell.disabled) return;
selectDate(calCell.dataset.date);
return;
}
const row = event.target.closest("[data-code]");
if (row) {
const interactive = event.target.closest("button, a, input, select, textarea");
if (!interactive) {
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);