Files
xiaobaifupan/app/frontend/m/js/pages.js
T

3758 lines
161 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
inbox: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/>',
bot: '<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>',
filter: '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
target: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
"refresh-cw": '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
send: '<path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/>',
square: '<rect width="10" height="10" x="7" y="7" rx="1"/>',
"alert-triangle": '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
};
function icon(name, size) {
const body = ICONS[name] || "";
return '<svg width="' + (size || 16) + '" height="' + (size || 16) + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + body + "</svg>";
}
const TRIANGLE_UP = '<svg width="6" height="6" viewBox="0 0 8 8" aria-hidden="true"><path d="M4 1.6 6.9 6.4H1.1Z" fill="currentColor"/></svg>';
const TRIANGLE_DOWN = '<svg width="6" height="6" viewBox="0 0 8 8" aria-hidden="true"><path d="M4 6.4 1.1 1.6h5.8Z" fill="currentColor"/></svg>';
function sortIndicatorHtml(active) {
if (active) {
const up = state.sort.dir === "asc";
return '<span class="m-sort" aria-hidden="true">' + (up ? TRIANGLE_UP : TRIANGLE_DOWN) + "</span>";
}
return '<span class="m-sort m-sort--dual" aria-hidden="true">' + TRIANGLE_UP + TRIANGLE_DOWN + "</span>";
}
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 '<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"><path d="' + d + '" fill="currentColor" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>';
}
return '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true"><path d="' + d + '"/></svg>';
}
/* ---------------------------------------------------------------- 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 '<span class="m-stock">' +
'<span class="m-stock-name">' + escapeHtml(row.name || "--") + "</span>" +
'<span class="m-stock-code">' + escapeHtml(row.code || "") + "</span>" +
"</span>";
}
function rankCell(row, index) {
const value = number(row.rank) > 0 ? number(row.rank) : index + 1;
const hot = index < 3 ? '<span class="m-rank-hot">热</span>' : "";
return '<span class="m-rank' + (index < 3 ? " is-top" : "") + '"><b>' + value + "</b>" + hot + "</span>";
}
function streakCell(value) {
const n = number(value);
if (n <= 0) return "";
const high = n >= 4 ? " is-high" : "";
return '<span class="m-pill' + high + '">' + streakLabel(n) + "</span>";
}
function numCell(value, digits, signed) {
if (value == null || value === "") return '<span class="m-num"></span>';
const n = number(value);
const sign = signed && n > 0 ? "+" : "";
return '<span class="m-num">' + sign + formatNumber(n, digits) + "</span>";
}
function changeCell(value) {
if (value == null || value === "") return '<span class="m-num"></span>';
const n = number(value);
return '<span class="m-num ' + changeClass(n) + '">' + (n > 0 ? "+" : "") + formatNumber(n, 2) + "</span>";
}
function intCell(value, hideZero) {
const n = Math.round(number(value));
if (hideZero && n === 0) return '<span class="m-num"></span>';
if (value == null || value === "") return '<span class="m-num"></span>';
return '<span class="m-num">' + n.toLocaleString("zh-CN") + "</span>";
}
function advanceCell(value) {
const n = number(value);
const cls = n === 0 ? "is-neutral" : n < 20 ? "is-warning" : "is-active";
return '<span class="m-num ' + cls + '">' + formatNumber(n, 1) + "</span>";
}
function outcomeCell(value) {
const map = { "晋级": "advance", "断板": "fail", "炸板": "broken", "跌停": "down" };
const cls = map[value] || "fail";
return '<span class="m-tag m-tag--' + cls + '">' + escapeHtml(value || "") + "</span>";
}
function heightCell(value) {
const n = number(value);
if (n <= 0) return "";
return '<span class="m-pill m-pill--plain">' + n + "</span>";
}
function moveCell(value) {
const n = value == null ? null : number(value);
if (n == null) return '<span class="m-num is-new">新</span>';
if (n > 0) return '<span class="m-num up">\u2191' + n + "</span>";
if (n < 0) return '<span class="m-num down">\u2193' + Math.abs(n) + "</span>";
return '<span class="m-num">持平</span>';
}
function conceptsCell(value) {
const list = Array.isArray(value) ? value : [];
const text = list.slice(0, 3).join("、");
return '<span class="m-cell-text" title="' + escapeHtml(list.join("、")) + '">' + escapeHtml(text) + "</span>";
}
function dualCell(value) {
const dual = Boolean(value);
return '<span class="m-tag ' + (dual ? "m-tag--dual" : "") + '">' + (dual ? "双榜共识" : "单榜入选") + "</span>";
}
function textCell(value) {
const text = String(value == null ? "" : value);
return '<span class="m-cell-text" title="' + escapeHtml(text) + '">' + escapeHtml(text) + "</span>";
}
function scoreCell(value) {
if (value == null || value === "") return '<span class="m-num"></span>';
return '<span class="m-num">' + formatNumber(number(value), 1) + "</span>";
}
function expectationCell(value) {
if (value == null || value === "") return "";
const map = { "超预期": "above", "符合预期": "matched", "低于预期": "below", "竞价一字": "one" };
return '<span class="m-tag m-tag--exp-' + (map[value] || "matched") + '">' + escapeHtml(value) + "</span>";
}
function directionCell(value) {
if (value == null || value === "") return "";
const map = { "买入": "up", "卖出": "down", "持平": "flat" };
return '<span class="m-tag m-tag--dir-' + (map[value] || "flat") + '">' + escapeHtml(value) + "</span>";
}
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) + '<span class="m-th-inner">' + escapeHtml(col.label) + indicator + "</span></th>";
}).join("");
const body = rows.map(function (row, rIndex) {
const cells = ordered.map(function (col, i) {
return cellOpen(col, i, false) + cellHtml(col, row, rIndex) + "</td>";
}).join("");
const linkable = !(opts && opts.noLink);
const code = linkable && /^\d{6}$/.test(String(row.code || "")) ? ' data-code="' + escapeHtml(row.code) + '"' : "";
return "<tr" + code + ">" + cells + "</tr>";
}).join("");
return '<table class="m-table"><thead><tr>' + head + "</tr></thead><tbody>" + body + "</tbody></table>";
}
function skeletonHtml(rowCount) {
const rows = [];
for (let i = 0; i < rowCount; i += 1) {
rows.push('<div class="m-skeleton-row"><span class="m-skeleton-bar"></span></div>');
}
return '<div class="m-skeleton" aria-hidden="true">' + rows.join("") + "</div>";
}
function emptyHtml() {
return '<div class="m-state m-motion-rise-in">' +
'<span class="m-state-icon">' + icon("inbox", 26) + "</span>" +
"<p>该交易日暂无相关数据</p>" +
'<small>可点右上角日期切换交易日</small>' +
"</div>";
}
function errorHtml(message) {
return '<div class="m-state m-state--error m-motion-fade-in">' +
'<p>' + escapeHtml(message || "数据加载失败") + "</p>" +
'<button class="m-btn-primary m-btn-retry" type="button" data-action="retry">重试</button>' +
"</div>";
}
/* ---------------------------------------------------------------- strip */
function stripSkeleton() {
const cells = [];
for (let i = 0; i < 7; i += 1) {
cells.push('<div class="m-strip-cell"><span class="m-strip-skeleton-bar"></span></div>');
}
return '<div class="m-strip m-strip--skeleton" aria-hidden="true">' + cells.join("") + "</div>";
}
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 '<div class="m-strip-cell' + (tone ? " m-strip-" + tone : "") + (extraCls ? " " + extraCls : "") + '">' +
'<span class="m-strip-label">' + escapeHtml(label) + "</span>" +
'<span class="m-strip-value">' + valueHtml + "</span></div>";
}
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 ? "--" : "<b>" + score + "</b>") +
(phase ? '<i class="m-strip-phase">' + escapeHtml(phase) + "</i>" : "");
const amount = amountLabel(overview.amount_billion);
const amountHtml = "<b>" + escapeHtml(amount.value) + "</b>" +
(amount.unit ? '<i class="m-strip-unit">' + escapeHtml(amount.unit) + "</i>" : "");
const seal = overview.seal_rate != null ? formatNumber(overview.seal_rate, 1) + "%" : "--";
const cells = [];
cells.push(stripCell("情绪", emotionHtml, "", ""));
cells.push(stripCell("涨停", "<b>" + intOrDash(overview.limit_up_count) + "</b>", "up", ""));
cells.push(stripCell("跌停", "<b>" + intOrDash(overview.limit_down_count) + "</b>", "down", ""));
cells.push(stripCell("炸板", "<b>" + intOrDash(overview.broken_count) + "</b>", "warn", ""));
cells.push(stripCell("封板率", "<b>" + seal + "</b>", "", ""));
cells.push(stripCell("成交额", amountHtml, "", amount.compact ? "m-strip-sm" : ""));
cells.push(stripCell("最高连板", "<b>" + (maxStreak > 0 ? maxStreak : "--") + "</b>", "up", ""));
return '<div class="m-strip">' + cells.join("") + "</div>";
}
/* ---------------------------------------------------------------- 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 '<div class="m-conclusion">结论:<b>' + escapeHtml(stance) + "</b>,当前情绪周期「" + escapeHtml(phase) + "」。</div>";
}
/* ---------------------------------------------------------------- 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 '<button class="m-date-btn" type="button" data-action="date" aria-label="选择日期">' +
icon("calendar", 15) +
'<span class="m-date-btn-text">' + escapeHtml(dateText || state.requestedDate || todayString()) + "</span>" +
"</button>";
}
function sourceTabsHtml(cfg) {
if (!cfg.sources || !cfg.sources.length) return "";
const labels = { combined: "双榜综合", ths: "同花顺", dc: "东方财富" };
return '<div class="m-source-tabs" role="tablist" aria-label="热榜来源">' +
cfg.sources.map(function (source) {
const active = source === state.popularitySource;
return '<button class="m-source-tab' + (active ? " active" : "") + '" type="button" role="tab" aria-selected="' + (active ? "true" : "false") + '" data-action="source" data-source="' + source + '">' + escapeHtml(labels[source] || source) + "</button>";
}).join("") +
"</div>";
}
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 = '<div class="m-page" data-page="' + escapeHtml(key) + '">' +
'<div class="m-top">' + buildStrip() + "</div>" +
sourceTabs +
'<div class="m-table-scroll" id="m-table-scroll">' + skeletonHtml(8) + "</div>" +
"</div>";
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 '<div class="m-page" data-page="' + escapeHtml(key) + '">' + bodyHtml + "</div>";
}
function complexScroll(bodyHtml) {
return '<div class="m-scroll" id="m-scroll">' + bodyHtml + "</div>";
}
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 '<span class="m-phase m-phase--' + phaseTone(phase) + '">' + escapeHtml(phase) + "</span>";
}
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 '<svg viewBox="0 0 ' + W + " " + H + '" preserveAspectRatio="none" aria-hidden="true">' +
svgAxisText(right, padT + 8, "end", "", "100") +
svgAxisText(right, padT + ph / 2 + 3, "end", "", "50") +
svgAxisText(right, padT + ph, "end", "", "0") +
'<path class="m-sentiment-area" d="' + area + '"/>' +
'<path class="m-sentiment-line" d="' + line + '"/>' +
xLabels +
"</svg>";
}
/* ----- 情绪周期 ----- */
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 '<div class="m-components">' + items.map(function (item) {
const score = clampScore(item.score);
return '<div class="m-component">' +
'<div class="m-component-head"><span>' + escapeHtml(item.label) + "</span>" +
"<b>" + formatNumber(number(item.score), 1) + "</b></div>" +
'<div class="m-component-track"><i style="width:' + score.toFixed(1) + '%"></i></div>' +
'<small>' + escapeHtml(item.summary || "") + " · 权重 " + number(item.weight) + "%</small>" +
"</div>";
}).join("") + "</div>";
}
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 '<button class="m-range-tab' + (active ? " active" : "") + '" type="button" data-sentiment-range="' + n + '" aria-pressed="' + (active ? "true" : "false") + '">' + n + "日</button>";
}).join("");
let card = "";
if (latest) {
const score = number(latest.score);
const dayChange = number(latest.day_change);
const components = latest.components || overview.sentiment_components || {};
card =
'<div class="m-sent-card">' +
'<div class="m-sent-score-row"><strong class="m-sent-score">' + score + "</strong>" +
'<div class="m-sent-score-side">' + phaseBadgeHtml(latest.phase) +
'<span class="m-sent-direction m-sent-dir--' + directionTone(latest.direction) + '">' + escapeHtml(latest.direction) + "</span></div></div>" +
'<div class="m-sent-label">' + escapeHtml(latest.label || "") + "</div>" +
'<div class="m-sent-metrics">' +
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") +
"</div>" +
'<p class="m-sent-advice">' + escapeHtml(sentimentAdvice(latest.phase)) + "</p>" +
"</div>" +
'<div class="m-section-title">情绪趋势(近 ' + rawRows.length + ' 日)<span class="m-range-tabs">' + rangeTabs + "</span></div>" +
'<div class="m-chart-card">' + sentimentTrendChart(rawRows) + "</div>" +
'<div class="m-section-title">五维分项</div>' +
sentimentComponentList(components);
}
scroll.innerHTML = card +
'<div class="m-section-title">历史明细</div>' +
'<div class="m-table-wrap" id="m-sentiment-hist"></div>';
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 '<div class="m-sent-metric' + (tone ? " m-sent-" + tone : "") + '"><span>' + escapeHtml(label) + "</span><b>" + valueHtml + "</b></div>";
}
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 ? '<span class="m-ladder-rate">较' + (level - 1) + "板 " + rate + "%</span>" : "";
let stocksHtml = "";
let foldBtn = "";
if (gap) {
stocksHtml = '<div class="m-ladder-gap">' + (level >= maxLevel ? "断层 · " + escapeHtml(label) + "及以上空缺" : "该层暂时空缺") + "</div>";
} 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 '<button class="m-ladder-stock" type="button" data-ladder-stock="' + escapeHtml(s.code) + '">' +
'<span class="m-ladder-stock-main"><strong>' + escapeHtml(s.name) + "</strong>" +
'<small>' + escapeHtml(s.code) + "</small>" +
(onePrice ? '<em class="m-ladder-tag">一字</em>' : "") +
(broken ? '<em class="m-ladder-tag is-broken">烂板×' + number(s.open_times) + "</em>" : "") +
"</span>" +
'<span class="m-ladder-stock-sub"><b>' + escapeHtml(s.sector || s.reason || "其他") + "</b>" +
"<small>" + escapeHtml(s.first_time && s.first_time !== "--" ? s.first_time : "时间待校正") + " · " + escapeHtml(amount) + "</small></span>" +
"</button>";
}).join("");
if (cap && stocks.length > cap) {
const remaining = stocks.length - cap;
foldBtn = '<button class="m-ladder-fold" type="button" data-ladder-expand="' + level + '">' +
(expanded ? "收起 \u25b4" : "展开剩余 " + remaining + " 只 \u25be") + "</button>";
}
}
return '<section class="m-ladder-tier' + (gap ? " is-gap" : "") + '">' +
'<div class="m-ladder-tier-head"><span class="m-ladder-tier-level" style="color:' + color + '">' + escapeHtml(label) + "</span>" +
'<span class="m-ladder-tier-count">' + count + " 只</span>" + rateHtml + foldBtn + "</div>" +
'<div class="m-ladder-stocks">' + stocksHtml + "</div></section>";
}
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 = '<div class="m-apex">' +
'<div class="m-apex-head"><span>空间板</span><strong>' + (maxLevel ? maxLevel + " 板" : "--") + "</strong><em>" + escapeHtml(spaceChange) + "</em></div>" +
"<small>" + escapeHtml(spaceNote) + "</small></div>";
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 '<div class="m-rate-row"><span>' + escapeHtml(p.label || "昨日" + number(p.level) + "板") + "</span>" +
'<i><b style="width:' + value.toFixed(1) + '%"></b></i><strong>' + formatNumber(value, 1) + "%</strong></div>";
}).join("");
const perfHtml = perfRows
? '<div class="m-section-title">晋级率参考(昨日梯队 → 今日)</div><div class="m-rate-list">' + perfRows + "</div>"
: "";
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 '<button class="m-rotation-chip ' + heat + '" type="button" data-rotation-sector="' + escapeHtml(sector.name) + '" data-rotation-date="' + escapeHtml(day.trade_date) + '">' +
'<span class="m-rotation-rank rank-' + Math.min(number(sector.rank), 4) + '">' + number(sector.rank) + "</span>" +
'<span class="m-rotation-copy">' +
"<strong>" + escapeHtml(sector.name) + "</strong>" +
"<small>" + number(sector.count) + " 家 · " + formatNumber(number(sector.strength), 0) + "</small>" +
"</span>" +
"</button>";
}).join("");
return '<section class="m-rotation-day' + (day.trade_date === latestDate ? " is-latest" : "") + '">' +
'<header><time>' + escapeHtml(displayCompactDate(day.trade_date).slice(5)) + "</time>" +
"<span>" + sectors.length + " 个热点</span></header>" +
'<div class="m-rotation-chips">' + chips + "</div></section>";
}).join("");
}
function loadRotationMembers(sector, date) {
const seq = nextSeq();
const url = "/api/rotation/members?trade_date=" + encodeURIComponent(date) + "&sector=" + 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(
'<div class="m-sheet-head"><h2>' + escapeHtml(sector) + "成分股</h2>" +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button></div>" +
'<div class="m-sheet-body" id="m-rotation-members-body">' + skeletonHtml(8) + "</div>",
{ 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 = '<div class="m-sheet-meta">' + escapeHtml(displayCompactDate(meta.trade_date) || "--") +
" · " + number(meta.quoted_count) + " / " + number(meta.member_count) + " 只</div>";
body.innerHTML = metaLine + '<div class="m-table-wrap" id="m-rotation-members-table"></div>';
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 '<div class="m-inline-empty">历史竞价量能尚未形成</div>';
const max = Math.max.apply(null, history.map(function (h) { return number(h.amount_billion); })) || 1;
return '<div class="m-mini-bars">' + history.map(function (h, i) {
const hgt = Math.max(8, number(h.amount_billion) / max * 100);
const current = i === history.length - 1 ? " current" : "";
return '<div class="m-mini-bar' + current + '" title="' + escapeHtml(h.trade_date) + " · " + formatNumber(number(h.amount_billion), 2) + ' 亿 · ' + number(h.stock_count) + ' 只">' +
'<span style="height:' + hgt.toFixed(1) + '%"></span><small>' + escapeHtml(String(h.trade_date || "").slice(5)) + "</small></div>";
}).join("") + "</div>";
}
function auctionThemeCarry(carry) {
if (!carry || !carry.length) return "";
const tone = { "强承接": "strong", "有承接": "steady", "分歧": "mixed", "承接弱": "weak" };
return '<div class="m-carry">' + carry.slice(0, 6).map(function (item) {
return '<div class="m-carry-row">' +
'<span class="m-carry-name">' + escapeHtml(item.name) + "</span>" +
'<span class="m-carry-leader">' + escapeHtml(item.leader || "--") + " · 昨 " + number(item.prior_limit_count) + " 只</span>" +
'<span class="m-carry-status ' + (tone[item.status] || "mixed") + '">' + escapeHtml(item.status) + "</span>" +
'<span class="m-carry-median ' + changeClass(item.median_change) + '">' +
(item.median_change == null ? "暂无候选" : (number(item.median_change) > 0 ? "+" : "") + formatNumber(number(item.median_change), 2) + "%") + "</span></div>";
}).join("") + "</div>";
}
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 '<div class="m-phase-notice m-phase-notice--' + escapeHtml(phase) + '"><strong>' + escapeHtml(copy[0]) + "</strong><span>" + escapeHtml(copy[1]) + "</span></div>";
}
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 '<div class="m-sum-metric' + (m[2] ? " m-sum-" + m[2] : "") + '"><span>' + escapeHtml(m[0]) + "</span><strong>" + m[1] + "</strong></div>";
}).join("");
const tabs = AUCTION_DATASETS.map(function (d) {
const active = state.auctionDataset === d.key;
return '<button class="m-source-tab' + (active ? " active" : "") + '" type="button" role="tab" aria-selected="' + (active ? "true" : "false") + '" data-action="auction-dataset" data-dataset="' + d.key + '">' + escapeHtml(d.label) + "</button>";
}).join("");
const carry = auctionThemeCarry(themes.carry);
scroll.innerHTML =
auctionPhaseNotice(meta) +
'<div class="m-sum-strip">' + metrics + "</div>" +
'<div class="m-section-title">竞价量能</div><div class="m-chart-card">' + auctionAmountTrend(payload.amount_history || []) + "</div>" +
(carry ? '<div class="m-section-title">昨日强势题材承接</div><div class="m-card">' + carry + "</div>" : "") +
'<div class="m-source-tabs" role="tablist" aria-label="竞价数据集">' + tabs + "</div>" +
'<div class="m-table-wrap" id="m-auction-table"></div>';
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 '<div class="m-sum-metric' + (m[3] ? " m-sum-" + m[3] : "") + '"><span>' + escapeHtml(m[0]) + "</span><strong>" + m[1] + '<small>' + escapeHtml(m[2]) + "</small></strong></div>";
}).join("");
const list = items.length ? '<div class="m-theme-list">' + items.map(function (item, index) {
return '<button class="m-theme-item" type="button" data-theme-code="' + escapeHtml(item.code) + '">' +
'<span class="m-theme-rank">' + (index + 1) + "</span>" +
'<span class="m-theme-copy"><strong>' + escapeHtml(item.name) + "</strong>" +
"<small>" + number(item.member_count) + " 只成分" + (item.hot_rank ? " · 人气第 " + number(item.hot_rank) : "") + "</small></span>" +
'<b class="' + changeClass(item.change) + '">' + (item.has_quote ? (number(item.change) > 0 ? "+" : "") + formatNumber(number(item.change), 2) + "%" : "--") + "</b>" +
"</button>";
}).join("") + "</div>" : emptyHtml();
scroll.innerHTML = '<div class="m-sum-strip">' + metrics + "</div>" + 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(
'<div class="m-sheet-head"><h2>题材详情</h2>' +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button></div>" +
'<div class="m-sheet-body" id="m-theme-detail-body">' + skeletonHtml(6) + "</div>",
{ 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 '<div class="m-sum-metric' + (m[2] ? " m-sum-" + m[2] : "") + '"><span>' + escapeHtml(m[0]) + "</span><strong>" + m[1] + "</strong></div>";
}).join("");
body.innerHTML =
'<div class="m-theme-detail-head"><strong>' + escapeHtml(theme.name || "--") + "</strong>" +
'<b class="' + changeClass(theme.change) + '">' + (number(theme.change) > 0 ? "+" : "") + formatNumber(number(theme.change), 2) + "%</b></div>" +
'<div class="m-theme-detail-code">' + escapeHtml(theme.code || "--") + " · " + escapeHtml(displayCompactDate(meta.trade_date) || "--") + "</div>" +
'<div class="m-sum-strip">' + metrics + "</div>" +
'<div class="m-section-title">成分股</div>' +
'<div class="m-table-wrap" id="m-theme-members-table"></div>';
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 = '<div class="m-state m-motion-rise-in"><span class="m-state-icon">' + icon("inbox", 26) + "</span>" +
"<p>" + escapeHtml(unavailable ? "龙虎榜数据暂不可用" : payload.meta.trade_date + " 暂无龙虎榜明细") + "</p>" +
"<small>龙虎榜明细通常在盘后陆续披露,可稍后刷新或查看前一交易日。</small></div>";
return;
}
const modeTabs = '<div class="m-source-tabs" role="tablist" aria-label="龙虎榜视图">' +
'<button class="m-source-tab active" type="button" data-action="dragon-mode" data-mode="daily">当日</button>' +
'<button class="m-source-tab" type="button" data-action="dragon-mode" data-mode="profiles">游资档案</button>' +
"</div>";
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 '<div class="m-sum-metric' + (m[2] ? " m-sum-" + m[2] : "") + '"><span>' + escapeHtml(m[0]) + "</span><strong>" + m[1] + "</strong></div>";
}).join("");
const cards = traders.map(function (trader, index) {
const desc = trader.description || number(trader.stock_count) + " 只股票," + number(trader.operation_count) + " 笔操作";
return '<button class="m-dragon-card" type="button" data-dragon-trader="' + escapeHtml(trader.id) + '">' +
'<span class="m-dragon-card-rank">' + String(index + 1).padStart(2, "0") + "</span>" +
'<span class="m-dragon-card-mono">' + escapeHtml(trader.name.slice(0, 2)) + "</span>" +
'<span class="m-dragon-card-copy"><strong>' + escapeHtml(trader.name) + "</strong>" +
"<small>" + escapeHtml(desc) + "</small></span>" +
'<span class="m-dragon-card-stats"><small>' + number(trader.stock_count) + " 股 · " + number(trader.operation_count) + " 笔</small>" +
'<b class="' + changeClass(trader.net_buy_million) + '">' + formatMoneyMillion(trader.net_buy_million) + "</b></span></button>";
}).join("");
scroll.innerHTML = modeTabs +
'<div class="m-sum-strip">' + metrics + "</div>" +
'<div class="m-section-title">上榜游资</div>' +
'<div class="m-dragon-list">' + (cards || emptyHtml()) + "</div>" +
(unclassified.length ? '<div class="m-dragon-unclassified">另有 ' + number(unclassified.length) + " 个待归类席位</div>" : "");
}
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 = '<div class="m-source-tabs" role="tablist" aria-label="龙虎榜视图">' +
'<button class="m-source-tab" type="button" data-action="dragon-mode" data-mode="daily">当日</button>' +
'<button class="m-source-tab active" type="button" data-action="dragon-mode" data-mode="profiles">游资档案</button>' +
"</div>";
const metrics = [
["收录游资", number(summary.profile_count), ""],
["已有简介", number(summary.described_count), ""],
["关联席位", number(summary.organization_count), ""],
].map(function (m) {
return '<div class="m-sum-metric"><span>' + escapeHtml(m[0]) + "</span><strong>" + m[1] + "</strong></div>";
}).join("");
const list = profiles.length ? '<div class="m-profile-list">' + profiles.map(function (profile, index) {
return '<button class="m-profile-row" type="button" data-profile-id="' + escapeHtml(profile.id) + '">' +
'<span class="m-profile-mono">' + escapeHtml(profile.name.slice(0, 2)) + "</span>" +
'<span class="m-profile-copy"><strong>' + escapeHtml(profile.name) + "</strong>" +
"<small>" + escapeHtml(profile.description || "暂未收录简介") + "</small></span>" +
'<span class="m-profile-seat">' + number(profile.organization_count) + " 席</span></button>";
}).join("") + "</div>" : emptyHtml();
scroll.innerHTML = modeTabs + '<div class="m-sum-strip">' + metrics + "</div>" +
'<div class="m-section-title">游资名录(收录 ' + number(summary.profile_count) + " 位)</div>" + 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(
'<div class="m-sheet-head m-detail-head"><div class="m-detail-head-left">' +
'<span class="m-detail-head-name">' + escapeHtml(trader.name) + "</span>" +
'<span class="m-detail-head-code">' + number(trader.stock_count) + " 股 · " + number(trader.operation_count) + " 笔</span></div>" +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button></div>" +
'<div class="m-sheet-body" id="m-dragon-ops-body">' +
'<div class="m-dragon-trader-totals">' +
totRow("买入", trader.buy_million, "up") +
totRow("卖出", trader.sell_million, "down") +
totRow("净额", trader.net_buy_million, changeClass(trader.net_buy_million)) +
"</div>" +
'<div class="m-table-wrap" id="m-dragon-ops-table"></div>' +
"</div>",
{ detail: true }
);
mountSortableTable("m-dragon-ops-table", "market/dragon/operations", function () { return ops; }, {});
}
function totRow(label, value, cls) {
return '<div class="m-total"><span>' + escapeHtml(label) + "</span><b class='" + cls + "'>" + formatMoneyMillion(value) + "</b></div>";
}
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(
'<div class="m-sheet-head"><h2>游资档案</h2>' +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button></div>" +
'<div class="m-sheet-body">' +
'<div class="m-profile-detail-head"><span class="m-profile-mono">' + escapeHtml(profile.name.slice(0, 2)) + "</span>" +
"<div><strong>" + escapeHtml(profile.name) + "</strong><small>" + (orgs.length ? "关联 " + orgs.length + " 个公开席位" : "暂无关联席位") + "</small></div></div>" +
'<div class="m-section-title">人物简介</div>' +
'<p class="m-profile-desc">' + escapeHtml(profile.description || "名录暂未收录该游资的公开简介。") + "</p>" +
(orgs.length ? '<div class="m-section-title">关联营业部</div><div class="m-profile-orgs">' +
orgs.map(function (o) { return "<span>" + escapeHtml(o) + "</span>"; }).join("") + "</div>" : "") +
(payloadNotice(state.dragonProfiles) || "") +
"</div>",
{ detail: false }
);
}
function payloadNotice(payload) {
const meta = payload && payload.meta;
return meta && meta.notice ? '<p class="m-profile-notice">' + escapeHtml(meta.notice) + "</p>" : "";
}
/* ---------------------------------------------------------------- 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(
'<div class="m-sheet-head"><h2>' + escapeHtml(title) + '</h2>' +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + '</button></div>' +
'<div class="m-sheet-body m-confirm-body">' +
(body ? '<p class="m-confirm-text">' + escapeHtml(body) + '</p>' : '') +
'<div class="m-confirm-actions">' +
'<button class="m-btn-primary m-btn-cancel" type="button" data-sheet-close>' + escapeHtml(cancelLabel) + '</button>' +
'<button class="m-btn-primary ' + (danger ? 'm-btn-danger' : '') + '" type="button" data-confirm-ok>' + escapeHtml(confirmLabel) + '</button>' +
'</div></div>',
{ 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 '<div class="m-strip m-strip--5">' +
cells.map(function (cell) {
const tone = cell.tone ? ' m-strip-' + cell.tone : '';
return '<div class="m-strip-cell' + tone + '">' +
'<span class="m-strip-label">' + escapeHtml(cell.label) + '</span>' +
'<span class="m-strip-value">' + (cell.value || '--') + '</span>' +
'</div>';
}).join('') + '</div>';
}
/* ============================================================== 智能选股 + 策略跟踪 ============================================================== */
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 =
'<button class="m-header-btn m-date-btn" type="button" data-screener-date aria-label="选择日期">' +
icon("calendar", 20) + '<span class="m-date-btn-label">' + escapeHtml(displayCompactDate(state.screener.date)) + '</span></button>';
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 =
'<div class="m-card m-screener-stage">' +
'<div class="m-screener-stage-row">' +
'<span class="m-screener-stage-label">当前阶段</span>' +
'<span class="m-screener-stage-time">' + escapeHtml(updatedAt) + '</span>' +
'</div>' +
'<div class="m-screener-stage-row">' +
'<strong class="m-screener-stage-name">' + escapeHtml(stageLabel) + '</strong>' +
(confidence != null ? '<span class="m-screener-stage-conf">置信度 ' + confidence + '%</span>' : '') +
'</div>' +
(reason ? '<p class="m-screener-stage-reason">' + escapeHtml(reason) + '</p>' : '') +
'</div>';
// 视图切换 pill 行
const tabs = Object.keys(SCREENER_VIEW_LABELS).map(function (key) {
const active = state.screener.view === key;
return '<button class="m-source-tab' + (active ? " active" : "") + '" type="button" role="tab" aria-selected="' + (active ? "true" : "false") + '" data-screener-view="' + key + '">' + escapeHtml(SCREENER_VIEW_LABELS[key]) + '</button>';
}).join("");
const viewTabs = '<div class="m-source-tabs m-screener-views" role="tablist" aria-label="选股视图">' + tabs + '</div>';
// 当前策略胶囊行
const strategyName = state.screener.strategyName || "选择策略";
const statusText = screenerStrategyStatusText(payload);
const strategyRow =
'<button class="m-card m-screener-strategy" type="button" data-screener-open-drawer>' +
'<span class="m-screener-strategy-icon">' + icon("filter", 16) + '</span>' +
'<span class="m-screener-strategy-name">' + escapeHtml(strategyName) + '</span>' +
(statusText ? '<span class="m-screener-strategy-status">' + escapeHtml(statusText) + '</span>' : '') +
'<span class="m-screener-strategy-chevron">' + icon("chevron-down", 16) + '</span>' +
'</button>';
// 表格容器
const tableWrap = '<div class="m-table-wrap" id="m-screener-table"></div>';
const foot = '<p class="m-screener-foot">结果由盘后自动更新 · 自定义策略请在电脑端新建/编译</p>';
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 = '<div class="m-state m-motion-rise-in">' +
'<span class="m-state-icon">' + icon("inbox", 26) + '</span>' +
'<p>' + escapeHtml(screenerEmptyText(payload)) + '</p>' +
'<small>可切换其他策略查看结果</small>' +
'<button class="m-btn-primary m-btn-retry" type="button" data-screener-open-drawer style="margin-top:16px;">切换策略</button>' +
'</div>';
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 '<th class="m-th ' + colAlign(col) + (isFrozen ? " m-frozen" : "") + extraCls + '" style="min-width:' + columnWidth(col) + 'px' + (isFrozen ? ";width:" + columnWidth(col) + "px;left:0" : "") + '"' + extraAttrs + '>' +
'<span class="m-th-inner">' + escapeHtml(col.label) + indicator + '</span></th>';
}).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 = '<span class="m-num m-score-up">' + formatNumber(n, 1) + '</span>';
}
}
return '<td class="' + cls + '" style="' + style + '">' + html + '</td>';
}).join("");
const code = /^\d{6}$/.test(String(row.code || "")) ? ' data-code="' + escapeHtml(row.code) + '"' : "";
return '<tr' + code + '>' + cells + '</tr>';
}).join("");
wrap.innerHTML = '<table class="m-table"><thead><tr>' + head + '</tr></thead><tbody>' + body + '</tbody></table>';
}
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(
'<div class="m-sheet-head"><h2>选择策略</h2>' +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + '</button></div>' +
'<div class="m-sheet-body" id="m-screener-drawer-body"></div>',
{ 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 '<button class="m-source-tab' + (active ? " active" : "") + '" type="button" data-screener-cat="' + escapeHtml(c.key) + '">' + escapeHtml(c.label) + '</button>';
}).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 '<button class="m-screener-drawer-row" type="button" data-screener-row="' + escapeHtml(String(s.id)) + '">' +
'<span class="m-screener-drawer-main">' +
'<span class="m-screener-drawer-name">' + escapeHtml(s.name) + (isCurrent ? ' <span class="m-strategy-check">' + icon("check", 16) + '</span>' : '') + '</span>' +
(regimeText ? '<small class="m-screener-drawer-sub">' + escapeHtml(regimeText) + '</small>' : '') +
'</span>' +
'<span class="m-strategy-status ' + statusCls + '">' + escapeHtml(status) + '</span>' +
'</button>';
}).join("") || '<div class="m-state m-state--error"><p>无匹配策略</p></div>';
return '<div class="m-form-field m-screener-drawer-search"><span class="m-screener-drawer-search-icon">' + icon("search", 16) + '</span><input type="search" placeholder="搜索策略" data-screener-drawer-search></div>' +
'<div class="m-source-tabs m-screener-drawer-cats" role="tablist">' + catPills + '</div>' +
'<div class="m-screener-drawer-list">' + rows + '</div>';
}
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 =
'<button class="m-header-btn" type="button" data-tracking-refresh aria-label="更新跟踪">' +
icon("refresh-cw", 20) + '</button>';
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 = '<div class="m-state m-motion-rise-in">' +
'<span class="m-state-icon">' + icon("target", 26) + '</span>' +
'<p>暂无跟踪记录</p>' +
'<small>在智能选股页将候选加入跟踪,5 个交易日内会在这里自动汇总表现</small>' +
'<button class="m-btn-primary m-btn-retry" type="button" data-route="#/feature/tools/screener" style="margin-top:16px;">去智能选股加跟踪</button>' +
'</div>';
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 '<th class="m-th ' + colAlign(col) + (isFrozen ? " m-frozen" : "") + extraCls + '" style="min-width:' + columnWidth(col) + 'px' + (isFrozen ? ";width:" + columnWidth(col) + "px;left:0" : "") + '"' + extraAttrs + '>' +
'<span class="m-th-inner">' + escapeHtml(col.label) + indicator + '</span></th>';
}).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 = '<span class="m-num">--</span>';
else html = '<span class="m-num ' + (Number(v) >= 0 ? "up" : "down") + '">' + (Number(v) > 0 ? "+" : "") + formatNumber(Number(v), 2) + '</span>';
} else {
html = cellHtml(col, row, rIndex);
}
return '<td class="' + cls + '" style="' + style + '">' + html + '</td>';
}).join("");
const code = /^\d{6}$/.test(String(row.code || "")) ? ' data-code="' + escapeHtml(row.code) + '" data-track-id="' + escapeHtml(String(row.track_id || "")) + '"' : '';
return '<tr' + code + '>' + cells + '</tr>';
}).join("");
const tableHtml = '<div class="m-table-wrap" id="m-tracking-table"><table class="m-table"><thead><tr>' + head + '</tr></thead><tbody>' + body + '</tbody></table></div>';
scroll.innerHTML = stripHtml + tableHtml;
}
function trackingStatusBadge(status) {
const text = status || "--";
if (text.indexOf("已完成") === 0) return '<span class="m-tag m-tag--down">' + escapeHtml(text) + '</span>';
if (text.indexOf("等待") === 0) return '<span class="m-tag m-tag--fail">' + escapeHtml(text) + '</span>';
if (text.indexOf("跟踪中") === 0) return '<span class="m-tag m-tag--dual">' + escapeHtml(text) + '</span>';
return '<span class="m-tag m-tag--fail">' + escapeHtml(text) + '</span>';
}
/* ---------------------------------------------------------------- screener/tracking detail sheets (composed on top of existing openDetailSheet) */
function appendDetailGroup(sheet, title, rows) {
return '<section class="m-detail-group"><h3 class="m-detail-group-title">' + escapeHtml(title) + '</h3>' +
rows.map(function (r) {
return '<div class="m-detail-group-row"><span class="m-detail-group-label">' + escapeHtml(r.label) + '</span>' +
'<span class="m-detail-group-value ' + (r.cls || "") + '">' + r.value + '</span></div>';
}).join("") + '</section>';
}
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: '<span style="white-space:normal;">' + escapeHtml(row.reason || "") + '</span>' }])
: "";
const riskGroup = riskFlags.length
? appendDetailGroup("风险标记", riskFlags.map(function (f) {
return { label: "", value: '<span style="white-space:normal;">' + escapeHtml(f) + '</span>' };
}))
: "";
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 +
'<div class="m-screener-detail-actions">' +
(isTracked
? '<button class="m-btn-primary m-btn-disabled" type="button" disabled>已加入跟踪</button>'
: '<button class="m-btn-primary" type="button" data-screener-track data-run-id="' + runId + '" data-code="' + escapeHtml(code) + '">加入跟踪</button>') +
'</div>';
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 = '<button class="m-btn-primary m-btn-disabled" type="button" disabled>已加入跟踪</button>';
}
}
}).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" }
]) +
'<div class="m-tracking-detail-actions">' +
'<button class="m-btn-primary m-btn-danger" type="button" data-tracking-remove data-track-id="' + escapeHtml(String(trackId)) + '">移除跟踪</button>' +
'</div>';
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('<button class="m-header-btn" type="button" data-chat-mentors aria-label="导师目录">' + icon("bot", 22) + '</button>');
}
actions.push('<button class="m-header-btn" type="button" data-chat-clear aria-label="清空对话">' + icon("trash", 22) + '</button>');
document.getElementById("m-actions").innerHTML = actions.join("");
bindChatShell(key);
}
function buildChatShell(key) {
const isAssistant = key === "assistant/chat";
return '<div class="m-page m-chat-page" data-page="' + escapeHtml(key) + '">' +
(isAssistant ? '' : '<div class="m-chat-mentor-bar" id="m-chat-mentor-bar" style="display:none"></div>') +
'<div class="m-chat-stream" id="m-chat-stream">' + chatSkeletonHtml() + '</div>' +
'<div class="m-chat-input-bar" id="m-chat-input-bar">' +
'<div class="m-chat-input-wrap">' +
'<textarea id="m-chat-input" class="m-chat-input" rows="1" placeholder="问点什么…" maxlength="2000" enterkeyhint="send"></textarea>' +
'</div>' +
'<button class="m-chat-send" type="button" data-chat-send aria-label="发送">' + icon("send", 20) + '</button>' +
'</div>' +
'</div>';
}
function chatSkeletonHtml() {
return '<div class="m-chat-skeleton">' +
'<div class="m-chat-bubble-skel m-chat-bubble-skel--ai" style="width:70%"></div>' +
'<div class="m-chat-bubble-skel m-chat-bubble-skel--user" style="width:55%;align-self:flex-end"></div>' +
'<div class="m-chat-bubble-skel m-chat-bubble-skel--ai" style="width:80%"></div>' +
'</div>';
}
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", "我是你的复盘助手,可以问我市场位置、主线、交易复盘、明日清单。") +
'<div class="m-chat-empty-hint">从下面挑一个开始,或直接输入你的问题</div>' +
chatPresetPillsHtml();
} else if (state.chat.mentorId) {
// 显示导师 tagline 原文(spec §1.5
const tagline = state.chat.mentorTagline || "今天想问点什么?";
html = chatBubbleHtml("ai", tagline) +
'<div class="m-chat-empty-hint">基于今日市场数据回答;引用数据时已带日期</div>';
} else {
html = '<div class="m-state m-state--empty"><p>请先选择一位导师</p>' +
'<button class="m-btn-primary m-btn-retry" type="button" data-chat-mentors>选择导师</button></div>';
}
} 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 = '<div class="m-state m-state--error m-motion-fade-in"><p>' + escapeHtml(message) + '</p>' +
'<button class="m-btn-primary m-btn-retry" type="button" data-chat-retry>重试</button></div>';
const btn = stream.querySelector("[data-chat-retry]");
if (btn) btn.addEventListener("click", loadChat);
}
function chatBubbleHtml(role, content) {
const isUser = role === "user";
return '<div class="m-chat-msg m-chat-msg--' + (isUser ? "user" : "ai") + ' m-motion-rise-in">' +
'<div class="m-chat-bubble">' + (isUser ? escapeHtml(content) : renderChatMarkdown(content)) + '</div>' +
'</div>';
}
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 = '<div class="m-chat-stamp">' + escapeHtml(chatTimestampLabel(msg.created_at)) + '</div>';
}
}
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("<ul>"); inList = true; }
if (inQuote) { out.push("</blockquote>"); inQuote = false; }
out.push("<li>" + line.replace(/^\s*[-*]\s+/, "").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") + "</li>");
continue;
}
if (inList) { out.push("</ul>"); inList = false; }
if (/^>\s?/.test(raw)) {
if (!inQuote) { out.push("<blockquote>"); inQuote = true; }
out.push(line.replace(/^>\s?/, "").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>"));
continue;
}
if (inQuote) { out.push("</blockquote>"); inQuote = false; }
if (/^###\s+/.test(raw)) {
out.push("<h4>" + line.replace(/^###\s+/, "").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") + "</h4>");
continue;
}
if (!line.trim()) { out.push(""); continue; }
out.push("<p>" + line.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") + "</p>");
}
if (inList) out.push("</ul>");
if (inQuote) out.push("</blockquote>");
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 ? '<span class="m-mentor-grade m-mentor-grade--' + escapeHtml(grade.toLowerCase()) + '">' + escapeHtml(grade) + '</span>' : "";
bar.innerHTML = '<button class="m-mentor-pill" type="button" data-chat-mentors>' +
'<span class="m-mentor-pill-name">' + escapeHtml(state.chat.mentorName || "") + gradeBadge + '</span>' +
'<span class="m-mentor-pill-close" data-chat-remove-mentor aria-label="清除导师">×</span>' +
'</button>';
}
function chatPresetPillsHtml() {
return '<div class="m-chat-presets">' +
'<div class="m-chat-preset-list">' +
ASSISTANT_PRESET_QUESTIONS.map(function (q) {
return '<button class="m-source-tab" type="button" data-chat-preset="' + escapeHtml(q) + '">' + escapeHtml(q) + '</button>';
}).join("") + '</div></div>';
}
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 = '<div class="m-chat-followup-row" data-chat-followup-row>' +
state.chat.followUps.slice(0, 3).map(function (q) {
return '<button class="m-source-tab" type="button" data-chat-followup="' + escapeHtml(q) + '">' + escapeHtml(q) + '</button>';
}).join("") + '</div>';
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",
'<div class="m-chat-msg m-chat-msg--ai m-chat-msg--stream" data-stream-msg>' +
'<div class="m-chat-bubble" data-stream-bubble>' +
'<span class="m-chat-typing" aria-hidden="true"><i></i><i></i><i></i></span>' +
'</div></div>');
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) + '<span class="m-chat-cursor" aria-hidden="true"></span>';
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",
'<div class="m-chat-msg m-chat-msg--ai m-chat-msg--error">' +
'<div class="m-chat-bubble m-chat-bubble--error">' +
'<span class="m-chat-error-text">' + escapeHtml((err && err.message) || "网络异常,请重试") + '</span>' +
'<button class="m-chat-retry" type="button" data-chat-retry>重试</button>' +
'</div></div>');
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 '<button class="m-mentor-row' + (isCurrent ? " is-current" : "") + '" type="button" data-mentor-row="' + escapeHtml(m.id) + '">' +
'<span class="m-mentor-row-main">' +
'<strong class="m-mentor-row-name">' + escapeHtml(m.name) + '</strong>' +
'<small class="m-mentor-row-tag">' + escapeHtml(m.tagline || "") + '</small>' +
'</span>' +
'<span class="m-mentor-row-grade m-mentor-grade m-mentor-grade--' + escapeHtml(String(grade).toLowerCase()) + '">' + escapeHtml(grade || "—") + '</span>' +
(isCurrent ? '<span class="m-mentor-row-check">' + icon("check", 16) + '</span>' : '') +
'</button>';
}).join("") || '<div class="m-state m-state--error"><p>导师目录为空</p></div>';
openSheet(
'<div class="m-sheet-head"><h2>选择导师</h2>' +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + '</button></div>' +
'<div class="m-sheet-body m-mentor-drawer-body">' +
'<div class="m-form-field m-mentor-drawer-search"><span class="m-mentor-drawer-search-icon">' + icon("search", 16) + '</span><input type="search" placeholder="搜索导师" data-mentor-drawer-search></div>' +
'<div class="m-mentor-drawer-list">' + listHtml + '</div>' +
'</div>',
{ 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 =
'<div class="m-sheet-backdrop" data-sheet-backdrop></div>' +
'<div class="m-sheet' + (opts && opts.detail ? " m-sheet--detail" : "") + '" role="dialog" aria-modal="true">' +
'<div class="m-sheet-handle" aria-hidden="true"></div>' +
content +
"</div>";
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(
'<div class="m-sheet-head">' +
'<h2>选择日期</h2>' +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button>" +
"</div>" +
'<div class="m-sheet-body" id="m-date-sheet-body"></div>',
{ 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 '<button class="m-quick" type="button" data-quick-date="' + escapeHtml(item.date) + '">' + escapeHtml(item.label) + "</button>";
}).join("");
body.innerHTML =
'<div class="m-quick-row">' + quickHtml + "</div>" +
'<div class="m-cal-head">' +
'<button class="m-cal-nav" type="button" data-cal-nav="prev"' + (prevDisabled ? " disabled" : "") + " aria-label=\"上个月\">" + icon("chevron-left", 18) + "</button>" +
'<span class="m-cal-title">' + year + " 年 " + (month + 1) + " 月</span>" +
'<button class="m-cal-nav" type="button" data-cal-nav="next"' + (nextDisabled ? " disabled" : "") + " aria-label=\"下个月\">" + icon("chevron-right", 18) + "</button>" +
"</div>" +
'<div class="m-cal-week">' + ["一", "二", "三", "四", "五", "六", "日"].map(function (d) { return "<span>" + d + "</span>"; }).join("") + "</div>" +
'<div class="m-cal-grid">' + calendarCells(year, month) + "</div>";
}
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('<span class="m-cal-cell is-empty"></span>');
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(
'<button class="m-cal-cell' + (selected ? " is-selected" : "") + '" type="button" data-date="' + dateStr + '"' + (disabled ? " disabled" : "") + ">" + d + "</button>"
);
}
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(
'<div class="m-sheet-head m-detail-head">' +
'<div class="m-detail-head-left">' +
'<span class="m-detail-head-name">--</span>' +
'<span class="m-detail-head-code">' + escapeHtml(code) + "</span>" +
"</div>" +
'<div class="m-detail-head-right">' +
'<button class="m-detail-star" type="button" data-action="watch" aria-label="加入自选" disabled>' + starIcon(false) + "</button>" +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button>" +
"</div></div>" +
'<div class="m-sheet-body" id="m-detail-sheet-body">' +
'<div class="m-skeleton">' +
'<div class="m-skeleton-row"><span class="m-skeleton-bar"></span></div>' +
'<div class="m-skeleton-row"><span class="m-skeleton-bar"></span></div>' +
'<div class="m-skeleton-row"><span class="m-skeleton-bar"></span></div>' +
"</div>" +
"</div>",
{ 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 =
'<div class="m-detail-top">' +
'<div class="m-detail-quote">' +
'<strong class="m-detail-price ' + changeClass(change) + '">' + (price ? formatNumber(price, 2) : "--") + "</strong>" +
'<span class="m-detail-change ' + changeClass(change) + '">' + (change > 0 ? "+" : "") + formatNumber(change, 2) + "%</span>" +
"</div>" +
'<div class="m-detail-meta">' + escapeHtml(industry) + " · " + escapeHtml(displayCompactDate(meta.trade_date) || "--") + " 收盘</div>" +
"</div>" +
'<div class="m-detail-tabs" role="tablist" aria-label="行情图类型">' +
'<button class="m-detail-tab active" type="button" role="tab" aria-selected="true" data-chart-tab="intraday">分时</button>' +
'<button class="m-detail-tab" type="button" role="tab" aria-selected="false" data-chart-tab="daily">日线</button>' +
'<span class="m-detail-chart-caption" id="m-detail-caption">' + escapeHtml(chartCaptionHtml("intraday")) + "</span>" +
"</div>" +
'<div class="m-detail-chart" id="m-detail-chart">' + chartHtml + "</div>" +
'<div class="m-detail-note">点按图面可读数值 · 数据来源:本地行情</div>';
}
/* ---------------------------------------------------------------- charts */
function emptyChart(message) {
return '<div class="m-chart-empty">' + escapeHtml(message || "数据暂不可用") + "</div>";
}
function svgAxisText(x, y, anchor, cls, content) {
return '<text x="' + x + '" y="' + y + '" text-anchor="' + anchor + '" class="m-chart-axis ' + cls + '">' + escapeHtml(content) + "</text>";
}
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 ? '<polyline class="m-chart-ma ' + cls + '" points="' + pts.join(" ") + '"/>' : "";
}
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 '<svg viewBox="0 0 ' + W + " " + H + '" preserveAspectRatio="none" aria-hidden="true">' +
'<line class="m-chart-prev-close" x1="' + padL + '" y1="' + y0 + '" x2="' + (W - padR) + '" y2="' + y0 + '"/>' +
'<path class="m-chart-line m-chart-avg" d="' + avgPath + '"/>' +
'<path class="m-chart-line m-chart-price" d="' + pricePath + '"/>' +
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") +
"</svg>";
}
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 '<line class="' + cls + '" x1="' + cx + '" y1="' + y(number(b.high)).toFixed(1) + '" x2="' + cx + '" y2="' + y(number(b.low)).toFixed(1) + '"/>' +
'<rect class="' + cls + '" x="' + bx + '" y="' + bodyTop + '" width="' + candleW.toFixed(1) + '" height="' + bodyH + '"/>';
}).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 '<div class="m-chart-legend" aria-hidden="true">' +
'<span class="m-chart-legend-ma5">MA5</span>' +
'<span class="m-chart-legend-ma10">MA10</span>' +
"</div>" +
'<svg viewBox="0 0 ' + W + " " + H + '" preserveAspectRatio="none" aria-hidden="true">' +
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)) +
"</svg>";
}
/* ---------------------------------------------------------------- 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);