Files
xiaobai-review/frontend/m/js/pages.js
T
cefc86917d fix(HEL-233): 去掉系统管理按钮属性多余引号,恢复点击
五个无值 data-system-* 属性名后多写了引号,选择器匹配失败导致按钮完全无效。
toast 改为在未打开抽屉时也能挂载,并补源码与真实点击回归。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 13:14:23 +08:00

5696 lines
252 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"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
"check-check": '<path d="M18 6 7 17l-5-5"/><path d="m22 10-7.5 7.5L13 16"/>',
star: '<polygon points="12 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"/>',
"scroll-text": '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
"calendar-check": '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="m9 16 2 2 4-4"/>',
"sticky-note": '<path d="M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8Z"/><path d="M15 3v4a2 2 0 0 0 2 2h4"/>',
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
};
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,
},
review: {
subKey: "",
watchlist: null,
trades: null,
notes: null,
dailyItems: null,
alerts: null,
alertStatus: "all",
form: null,
dirty: false,
},
system: {
account: null,
admin: null,
adminTab: "market",
},
};
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;
}
// 持续有效 / 入选历史:生产 /api/screener/setup 只返回 recent_results / latest_results
// 从 recent_results 里筛出当前策略(meta.strategy_name 匹配,与 screenerRunForCurrentStrategy 同口径)的运行,逐候选组装。
function screenerArchiveRows(payload, view) {
if (!payload) return [];
const strategy = screenerCurrentStrategy(payload);
const strategyName = strategy ? strategy.name : "";
if (!strategyName) return [];
const rows = [];
(payload.recent_results || []).forEach(function (run) {
if (!run || !run.meta || run.meta.strategy_name !== strategyName) return;
const meta = run.meta;
const active = screenerSignalActive(run, payload);
if (view === "active" && !active) return;
(run.candidates || []).forEach(function (c) {
const code = String(c.code || "");
if (!code) return;
rows.push({
code: code,
name: c.name || "",
sector: c.sector || "",
pct_chg: c.pct_chg,
return_5d: c.return_5d,
volume_ratio_5d: c.volume_ratio_5d,
sector_strength: c.sector_strength,
historical_probability: c.historical_probability,
score_display: c.score_display,
selection_date: displayCompactDate(String(meta.trade_date || ""))
});
});
});
rows.sort(function (a, b) {
return String(b.selection_date || "").localeCompare(String(a.selection_date || ""));
});
return rows;
}
// 信号是否仍在有效期:阶段/精选策略按「阶段不变即有效」(meta.regime == 当前阶段);
// 量化策略按策略频率对应的交易日数,以 meta.trade_date 与数据日比较(无交易日历时按自然日近似)。
function screenerSignalActive(run, payload) {
const meta = (run && run.meta) || {};
const mode = String(meta.mode || "smart");
if (mode === "smart" || mode === "curated") {
const regime = payload && payload.regime ? String(payload.regime.id || "") : "";
return regime ? String(meta.regime || "") === regime : true;
}
const freq = String((((run && run.formula) || {}).meta || {}).frequency || "每日");
const days = { "每周": 5, "双周": 10, "月度": 20, "事件驱动": 5 }[freq] || 1;
const selected = String(meta.trade_date || "").replaceAll("-", "");
const asOf = String((payload && payload.trade_date) || "").replaceAll("-", "");
if (!selected || !asOf || selected === asOf) return true;
const start = parseLocalDate(displayCompactDate(selected));
const end = parseLocalDate(displayCompactDate(asOf));
const diff = Math.round((end.getTime() - start.getTime()) / 86400000);
return diff >= 0 && diff < days;
}
function screenerViewKey(payload, view) {
if (!payload) return [];
if (view === "active") return screenerArchiveRows(payload, "active");
if (view === "history") return screenerArchiveRows(payload, "history");
const run = screenerRunForCurrentStrategy(payload);
if (!run) return [];
return run.candidates || [];
}
function screenerCurrentRun(payload) {
if (!payload) return null;
if (state.screener.view === "active") return null;
if (state.screener.view === "history") return null;
return screenerRunForCurrentStrategy(payload);
}
function screenerCurrentRunId() {
const payload = state.screener.data;
if (!payload) return 0;
const run = screenerCurrentRun(payload);
return run ? Number(run.run_id || 0) : 0;
}
function screenerFollowedCodes() {
const tracking = state.tracking.data;
if (!tracking || !tracking.batches) return {};
const map = {};
tracking.batches.forEach(function (batch) {
(batch.items || []).forEach(function (item) {
if (item && item.code) map[String(item.code)] = item;
});
});
return map;
}
function findScreenerStrategy(payload, strategyId) {
const list = (payload && payload.strategies) || [];
if (!strategyId) return null;
for (let i = 0; i < list.length; i += 1) {
if (String(list[i].id) === String(strategyId)) return list[i];
}
return null;
}
function findScreenerStrategyByName(payload, name) {
const list = (payload && payload.strategies) || [];
if (!name) return null;
for (let i = 0; i < list.length; i += 1) {
if (list[i].name === name) return list[i];
}
return null;
}
function pickInitialScreenerStrategy(payload) {
const list = (payload && payload.strategies) || [];
// 默认选 smart 阶段策略
const stage = list.find(function (s) {
const lib = s.formula && s.formula.meta && s.formula.meta.library;
return lib === "smart";
});
if (stage) return stage;
if (list.length) return list[0];
return null;
}
function setupScreenerPage(key) {
document.getElementById("m-view").classList.add("m-view-feature");
state.screener = {
data: null,
date: todayString(),
view: "latest",
strategyId: "",
strategyName: "",
loading: false,
};
updateHeader("智能选股", displayCompactDate(state.screener.date));
document.getElementById("m-actions").innerHTML =
'<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) {
const status = screenerStrategyStatusText(payload);
if (status === "数据不足") return "该策略数据不足,暂无法出候选";
if (status === "等待盘后") return "该策略等待盘后更新";
if (status === "选择策略") return "请先选择一个策略查看结果";
if (state.screener.view === "active") return "该策略暂无持续有效信号";
if (state.screener.view === "history") return "该策略暂无入选历史";
return "该策略当日暂无信号";
}
function formatScreenerUpdatedAt(value) {
if (!value) return "盘后已更新";
const text = String(value);
if (text.length >= 5 && text.indexOf(":") > 0) {
return "盘后 " + text + " 已更新";
}
if (text.length >= 10) return displayCompactDate(text) + " 已更新";
return "盘后已更新";
}
function renderScreenerTable() {
const wrap = document.getElementById("m-screener-table");
if (!wrap) return;
const payload = state.screener.data;
if (!payload) { wrap.innerHTML = ""; return; }
const rows = screenerViewKey(payload, state.screener.view);
if (!rows.length) {
wrap.innerHTML = '<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' + (isCurrent ? ' is-current' : '') + '" 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-section">' +
'<span class="m-screener-drawer-section-title">选择策略</span>' +
'<span class="m-screener-drawer-section-count">' + filtered.length + ' 套</span>' +
'</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,
"review/watchlist": setupReviewPage,
"review/trades": setupReviewPage,
"review/daily": setupReviewPage,
"review/notes": setupReviewPage,
"review/alerts": setupReviewPage,
"system/profile": setupSystemPage,
"system/password": setupSystemPage,
"system/membership": setupSystemPage,
"system/admin": setupSystemPage,
"system/members": setupSystemPage,
};
const COMPLEX_LOADERS = {
"market/sentiment": loadSentiment,
"market/ladder": loadLadder,
"market/rotation": loadRotation,
"market/auction": loadAuction,
"market/themes": loadThemes,
"market/dragon": loadDragon,
"tools/screener": loadScreener,
"tools/tracking": loadTracking,
"tools/mentor": loadChat,
"assistant/chat": loadChat,
"review/watchlist": loadReview,
"review/trades": loadReview,
"review/daily": loadReview,
"review/notes": loadReview,
"review/alerts": loadReview,
"system/profile": loadSystem,
"system/password": loadSystem,
"system/membership": loadSystem,
"system/admin": loadSystem,
"system/members": loadSystem,
};
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 ? "已加入自选" : "已移出自选");
// 自选股列表页在背后:同步移除该行,避免关 Sheet 后仍显示已移除标的
if (!adding && state.review && state.review.watchlist) {
state.review.watchlist.items = (state.review.watchlist.items || []).filter(function (it) {
return String(it.code) !== String(code);
});
renderReviewWatchlist();
}
}).catch(function () {
updateStarButton();
showToast("操作失败,请重试");
});
}
function openRemarkEditSheet() {
const detail = state.detail;
if (!detail || !detail.code) return;
const item = detail.watchItem || {};
const current = item.remark || "";
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">' +
'<label class="m-form-field"><span>跟踪备注</span>' +
'<div class="m-field-textarea">' +
'<textarea id="m-remark-input" maxlength="240" rows="4" data-maxlength="240">' + escapeHtml(current) + "</textarea>" +
'<span class="m-field-counter" data-counter-for="m-remark-input">' + String(current).length + "/240</span>" +
"</div></label>" +
'<div class="m-confirm-actions"><button class="m-btn-primary" type="button" data-remark-save>保存</button></div>' +
"</div>",
{ detail: false }
);
const input = document.getElementById("m-remark-input");
if (input) {
input.addEventListener("input", function () {
const counter = document.querySelector('[data-counter-for="m-remark-input"]');
if (counter) counter.textContent = input.value.length + "/240";
});
}
}
function saveRemark() {
const detail = state.detail;
if (!detail || !detail.code) return;
const item = detail.watchItem || {};
const code = detail.code;
const remark = document.getElementById("m-remark-input").value.trim();
const payload = {
code: code,
name: item.name || detail.name || code,
sector: item.sector || detail.sector || "其他",
color: item.color || "red",
remark: remark
};
global.MobileAPI.request("/api/watchlist", "POST", payload).then(function () {
const refresh = state.review && state.review.watchlist
? global.MobileAPI.request("/api/watchlist?trade_date=" + encodeURIComponent(todayString())).then(function (res) {
state.review.watchlist = res || state.review.watchlist;
renderReviewWatchlist();
})
: Promise.resolve();
return refresh;
}).then(function () {
closeSheet();
openDetailSheet(code);
showToast("备注已保存");
}).catch(function (error) {
showToast(error && error.message ? error.message : "保存失败");
});
}
function showToast(message) {
const root = ensureSheetRoot();
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),
watchItem: stock.watchlist || null,
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 || "分时数据暂不可用");
const watchItem = stock.watchlist || null;
const remarkRow = watchItem
? '<div class="m-remark-row">' +
'<span class="m-remark-label">跟踪备注</span>' +
'<span class="m-remark-text">' + escapeHtml(watchItem.remark || "未填写") + "</span>" +
'<button class="m-remark-edit" type="button" data-remark-edit>编辑</button>' +
"</div>"
: "";
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>" +
remarkRow +
'<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>";
}
/* ============================================================== 我的复盘(P3b ============================================================== */
const TRADE_ACTION_OPTIONS = [
{ key: "buy", label: "买入", tone: "up" },
{ key: "add", label: "加仓", tone: "up" },
{ key: "sell", label: "卖出", tone: "down" },
{ key: "trim", label: "减仓", tone: "down" },
{ key: "watch", label: "观察", tone: "flat" }
];
const TRADE_EMOTION_OPTIONS = [
{ key: "calm", label: "平静" },
{ key: "confident", label: "笃定" },
{ key: "hesitant", label: "犹豫" },
{ key: "anxious", label: "焦虑" },
{ key: "impulsive", label: "冲动" }
];
function reviewSubKey(key) {
const parts = String(key || "").split("/");
return parts[1] || "";
}
function reviewTitle(key) {
const map = {
watchlist: "自选股",
trades: "交易日志",
daily: "每日复盘",
notes: "个股笔记",
alerts: "提醒中心"
};
return map[reviewSubKey(key)] || "我的复盘";
}
function headerActionButton(dataAttr, iconName, ariaLabel) {
return '<button class="m-header-btn" type="button" data-' + dataAttr + ' aria-label="' + escapeHtml(ariaLabel) + '">' + icon(iconName, 20) + "</button>";
}
function setupReviewPage(key) {
const subKey = reviewSubKey(key);
state.key = key;
state.requestedDate = todayString();
state.sort = { key: "", dir: null };
state.sortTable = { cols: null, reapply: null };
state.detail = null;
state.review.subKey = subKey;
state.review.form = null;
state.review.dirty = false;
state.review.watchlist = null;
state.review.trades = null;
state.review.notes = null;
state.review.dailyItems = null;
state.review.alerts = null;
state.review.alertStatus = "all";
document.getElementById("m-view").classList.add("m-view-feature");
const title = reviewTitle(key);
let actions = "";
if (subKey === "trades") {
actions = headerActionButton("review-add-trade", "plus", "新增交易");
} else if (subKey === "notes") {
actions = headerActionButton("review-add-note", "plus", "新增笔记");
} else if (subKey === "alerts") {
actions =
headerActionButton("review-add-alert", "plus", "新增提醒") +
'<button class="m-header-btn" type="button" data-review-read-all aria-label="全部已读" hidden>' + icon("check-check", 20) + "</button>";
}
if (subKey === "daily") {
updateHeader(title, state.requestedDate);
} else {
global.MobileRouter.updateHeader({ title: title, back: true, actions: actions });
}
document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(6)));
}
function loadReview() {
const subKey = state.review.subKey;
if (subKey === "watchlist") loadReviewWatchlist();
else if (subKey === "trades") loadReviewTrades();
else if (subKey === "daily") loadReviewDaily();
else if (subKey === "notes") loadReviewNotes();
else if (subKey === "alerts") loadReviewAlerts();
}
function reviewState(kind) {
return '<div class="m-state m-state--error m-motion-fade-in"><p>' + escapeHtml(kind) + "加载失败</p>" +
'<button class="m-btn-primary m-btn-retry" type="button" data-action="retry">重试</button></div>';
}
/* ----- 自选股 ----- */
function loadReviewWatchlist() {
const seq = nextSeq();
global.MobileAPI.request("/api/watchlist?trade_date=" + encodeURIComponent(todayString())).then(function (payload) {
if (seq !== state.seq) return;
state.review.watchlist = payload || { items: [] };
renderReviewWatchlist();
}).catch(function (error) {
if (seq !== state.seq) return;
const scroll = document.getElementById("m-scroll");
if (scroll) scroll.innerHTML = reviewState("自选股");
});
}
function watchDotColor(color) {
return { red: true, blue: true, green: true, amber: true }[color] ? color : "red";
}
function watchStockCell(row) {
const color = watchDotColor(row.color);
return '<span class="m-stock m-stock--watch">' +
'<span class="m-watch-dot m-watch-dot--' + color + '" aria-hidden="true"></span>' +
'<span class="m-stock-copy">' +
'<span class="m-stock-name">' + escapeHtml(row.name || "--") + "</span>" +
'<span class="m-stock-code">' + escapeHtml(row.code || "") + "</span>" +
"</span></span>";
}
function renderReviewWatchlist() {
const scroll = document.getElementById("m-scroll");
if (!scroll) return;
const data = state.review.watchlist || { items: [] };
const rows = data.items || [];
if (!rows.length) {
scroll.innerHTML = '<div class="m-state m-motion-rise-in">' +
'<span class="m-state-icon">' + icon("star", 26) + "</span>" +
"<p>还没有自选股</p>" +
"<small>在任意股票详情点星形加入</small>" +
"</div>";
return;
}
const cols = {
frozenColumns: [{ key: "stock", label: "股票", type: "stock", width: 112 }],
primaryColumns: [
{ key: "change", label: "当日涨幅", type: "change" },
{ key: "attention_score", label: "关注分", type: "score" }
],
scrollColumns: [
{ key: "sector", label: "板块", type: "text" },
{ key: "return_5d", label: "5日涨幅", type: "change" },
{ key: "remark", label: "跟踪备注", type: "text" }
]
};
state.sortTable = { cols: cols, reapply: renderReviewWatchlist };
const sorted = sortedRows(rows, cols);
scroll.innerHTML = '<div class="m-table-wrap">' + buildReviewTable(cols, sorted, {
noSort: true,
cellRenderer: function (col, row) {
if (col.key === "stock") return watchStockCell(row);
if (col.key === "attention_score") {
if (row.attention_score == null || row.attention_score === "") return '<span class="m-num"></span>';
return '<span class="m-num m-num-strong">' + formatNumber(number(row.attention_score), 1) + "</span>";
}
if (col.key === "remark") {
const text = String(row.remark == null ? "" : row.remark);
if (!text) return '<span class="m-cell-text m-cell-text--empty">--</span>';
return '<span class="m-cell-text" title="' + escapeHtml(text) + '">' + escapeHtml(text) + "</span>";
}
return null;
}
}) + "</div>";
}
/* ----- 交易日志 ----- */
function loadReviewTrades() {
const seq = nextSeq();
global.MobileAPI.request("/api/trades").then(function (payload) {
if (seq !== state.seq) return;
state.review.trades = payload || { items: [], summary: {} };
renderReviewTrades();
}).catch(function (error) {
if (seq !== state.seq) return;
const scroll = document.getElementById("m-scroll");
if (scroll) scroll.innerHTML = reviewState("交易日志");
});
}
function tradeActionBadge(action) {
const opt = TRADE_ACTION_OPTIONS.find(function (o) { return o.key === action; });
const label = opt ? opt.label : (action || "--");
const tone = opt ? opt.tone : "flat";
return '<span class="m-trade-badge m-trade-badge--' + tone + '">' + escapeHtml(label) + "</span>";
}
function renderReviewTrades() {
const scroll = document.getElementById("m-scroll");
if (!scroll) return;
const data = state.review.trades || { items: [], summary: {} };
const items = data.items || [];
const summary = data.summary || {};
if (!items.length) {
scroll.innerHTML = '<div class="m-state m-motion-rise-in">' +
'<span class="m-state-icon">' + icon("scroll-text", 26) + "</span>" +
"<p>还没有交易记录</p>" +
"<small>记下每一次买卖,回头复盘更有据</small>" +
'<button class="m-btn-primary m-btn-retry" type="button" data-review-add-trade style="margin-top:16px;">记一笔</button>' +
"</div>";
return;
}
const pnlAmount = summary.pnl_amount;
const avgPos = summary.average_position;
const strip = renderStrip5([
{ label: "记录", value: String(summary.total != null ? summary.total : items.length) },
{ label: "已实现", value: String(summary.realized != null ? summary.realized : 0) },
{ label: "胜率", value: summary.win_rate != null ? summary.win_rate + "%" : "--" },
{ label: "累计盈亏", value: pnlAmount == null ? "--" : (pnlAmount > 0 ? "+" : "") + formatNumber(pnlAmount, 2), tone: pnlAmount == null ? "" : (pnlAmount > 0 ? "up" : (pnlAmount < 0 ? "down" : "")) },
{ label: "平均仓位", value: avgPos == null ? "--" : avgPos + "%" }
]);
const cols = {
frozenColumns: [{ key: "stock", label: "股票", type: "stock", width: 96 }],
primaryColumns: [
{ key: "action_label", label: "动作", type: "text" },
{ key: "pnl_pct", label: "盈亏%", type: "change" }
],
scrollColumns: [
{ key: "trade_date", label: "日期", type: "text" },
{ key: "position_pct", label: "仓位%", type: "rate" },
{ key: "pnl_amount", label: "盈亏额", type: "change" },
{ key: "emotion_label", label: "情绪", type: "text" }
]
};
const rows = items.map(function (it) {
return {
code: it.code,
name: it.name,
action: it.action,
action_label: it.action_label,
pnl_pct: it.pnl_pct,
trade_date: displayCompactDate(it.trade_date),
position_pct: it.position_pct,
pnl_amount: it.pnl_amount,
emotion_label: it.emotion_label,
id: it.id,
_raw: it
};
});
state.sortTable = { cols: cols, reapply: renderReviewTrades };
const sorted = sortedRows(rows, cols);
const table = buildReviewTable(cols, sorted, {
cellRenderer: function (col, row) {
if (col.key === "action_label") return tradeActionBadge(row.action);
if (col.key === "pnl_pct") {
if (row.pnl_pct == null || row.pnl_pct === "") return '<span class="m-num">--</span>';
return '<span class="m-num ' + changeClass(row.pnl_pct) + '">' + (number(row.pnl_pct) > 0 ? "+" : "") + formatNumber(number(row.pnl_pct), 2) + "</span>";
}
if (col.key === "pnl_amount") {
if (row.pnl_amount == null || row.pnl_amount === "") return '<span class="m-num">--</span>';
return '<span class="m-num ' + changeClass(row.pnl_amount) + '">' + (number(row.pnl_amount) > 0 ? "+" : "") + formatNumber(number(row.pnl_amount), 2) + "</span>";
}
if (col.key === "position_pct") {
if (row.position_pct == null || row.position_pct === "") return '<span class="m-num">--</span>';
return '<span class="m-num">' + formatNumber(number(row.position_pct), 1) + "</span>";
}
return null;
},
rowAttrs: function (row) {
return ' data-trade-id="' + escapeHtml(String(row.id || "")) + '"';
}
});
scroll.innerHTML = strip + '<div class="m-table-wrap">' + table + "</div>";
}
function openTradeDetail(tradeId) {
const data = state.review.trades || { items: [] };
const item = (data.items || []).find(function (it) { return String(it.id) === String(tradeId); });
if (!item) return;
openSheet(
'<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-trade-detail-body">' + tradeDetailBody(item) + "</div>",
{ detail: false }
);
}
function tradeDetailBody(item) {
const rows = [
["股票", (item.name || "--") + " " + (item.code || "")],
["日期", displayCompactDate(item.trade_date)],
["动作", item.action_label || "--"],
["价格", item.price != null ? formatNumber(number(item.price), 2) : "--"],
["数量", item.quantity != null ? intOrDash(item.quantity) : "--"],
["仓位", item.position_pct != null ? formatNumber(number(item.position_pct), 1) + "%" : "--"],
["盈亏额", item.pnl_amount == null ? "--" : (number(item.pnl_amount) > 0 ? "+" : "") + formatNumber(number(item.pnl_amount), 2)],
["盈亏%", item.pnl_pct == null ? "--" : (number(item.pnl_pct) > 0 ? "+" : "") + formatNumber(number(item.pnl_pct), 2) + "%"],
["情绪", item.emotion_label || "--"],
["标签", (item.tags && item.tags.length) ? item.tags.join("、") : "--"]
];
const toneRow = item.pnl_amount == null ? "" : (number(item.pnl_amount) > 0 ? "up" : number(item.pnl_amount) < 0 ? "down" : "");
const grid = rows.map(function (pair) {
const isPnl = pair[0] === "盈亏额";
return '<div class="m-trade-detail-row"><span class="m-trade-detail-label">' + escapeHtml(pair[0]) + "</span>" +
'<span class="m-trade-detail-value' + (isPnl ? " " + toneRow : "") + '">' + escapeHtml(pair[1]) + "</span></div>";
}).join("");
const block = function (title, text) {
return '<div class="m-detail-group"><div class="m-detail-group-title">' + escapeHtml(title) + "</div>" +
'<div class="m-detail-text">' + escapeHtml(text || "尚未填写") + "</div></div>";
};
return '<div class="m-trade-detail">' + grid + "</div>" +
'<div class="m-detail-foot">' +
block("交易逻辑", item.thesis) +
block("执行复核", item.execution) +
'<div class="m-confirm-actions">' +
'<button class="m-btn-primary" type="button" data-review-edit-trade data-trade-id="' + escapeHtml(String(item.id)) + '">编辑</button>' +
'<button class="m-btn-primary m-btn-danger" type="button" data-review-delete-trade data-trade-id="' + escapeHtml(String(item.id)) + '">删除</button>' +
"</div></div>";
}
function deleteTrade(tradeId) {
openConfirmSheet("删除交易记录", "删除后无法恢复,确定删除这条交易记录吗?", {
danger: true,
confirmLabel: "删除",
onConfirm: function () {
global.MobileAPI.request("/api/trades/" + encodeURIComponent(tradeId), "DELETE").then(function (payload) {
state.review.trades = { items: payload.items || [], summary: payload.summary || {} };
closeSheet();
renderReviewTrades();
showToast("交易记录已删除");
}).catch(function (error) {
showToast(error && error.message ? error.message : "删除失败");
});
}
});
}
/* ----- 每日复盘 ----- */
function loadReviewDaily() {
const seq = nextSeq();
global.MobileAPI.request("/api/notes?scope=daily").then(function (payload) {
if (seq !== state.seq) return;
state.review.dailyItems = (payload && payload.items) || [];
updateHeader("每日复盘", state.requestedDate);
renderReviewDaily();
}).catch(function (error) {
if (seq !== state.seq) return;
const scroll = document.getElementById("m-scroll");
if (scroll) scroll.innerHTML = reviewState("每日复盘");
});
}
function dailyNoteFor(dateStr) {
const items = state.review.dailyItems || [];
const compact = String(dateStr || "").replaceAll("-", "");
return items.find(function (it) { return String(it.trade_date || "").replaceAll("-", "") === compact; }) || null;
}
function renderReviewDaily() {
const scroll = document.getElementById("m-scroll");
if (!scroll) return;
const dateStr = state.requestedDate || todayString();
const note = dailyNoteFor(dateStr);
const items = state.review.dailyItems || [];
const dateLine = '<div class="m-daily-head">' +
'<span class="m-daily-date">' + escapeHtml(displayCompactDate(dateStr)) + "</span>" +
'<button class="m-daily-edit" type="button" data-review-edit-daily>' + (note ? "编辑" : "写复盘") + "</button>" +
"</div>";
if (!note) {
const history = dailyHistoryBlock(items, dateStr);
scroll.innerHTML = '<div class="m-state m-motion-rise-in" style="margin:24px 0;">' +
'<span class="m-state-icon">' + icon("calendar-check", 26) + "</span>" +
"<p>今日还未复盘</p>" +
'<small>花几分钟回顾今天的盘面与操作</small>' +
'<button class="m-btn-primary m-btn-retry" type="button" data-review-edit-daily style="margin-top:16px;">写复盘</button>' +
"</div>" + history;
return;
}
const card = function (title, text) {
return '<div class="m-card m-daily-card">' +
'<div class="m-daily-card-title">' + escapeHtml(title) + "</div>" +
'<p class="m-daily-card-body">' + escapeHtml(text || "尚未填写") + "</p>" +
"</div>";
};
const history = dailyHistoryBlock(items, dateStr);
scroll.innerHTML = dateLine +
card("今日盘面", note.summary) +
card("做对做错", note.content) +
card("明日计划", note.plan) +
'<button class="m-btn-primary m-btn-danger m-daily-delete" type="button" data-review-delete-daily data-note-id="' + escapeHtml(String(note.id)) + '">删除此日复盘</button>' +
history;
}
function dailyHistoryBlock(items, currentDate) {
const sorted = (items || []).slice().sort(function (a, b) {
return String(b.trade_date || "").localeCompare(String(a.trade_date || ""));
});
if (!sorted.length) return "";
const rows = sorted.map(function (it) {
const active = String(it.trade_date || "").replaceAll("-", "") === String(currentDate || "").replaceAll("-", "");
const oneLine = String(it.summary || "").trim() || "(无盘面摘要)";
return '<button class="m-daily-history-row' + (active ? " is-active" : "") + '" type="button" data-daily-date="' + escapeHtml(displayCompactDate(it.trade_date)) + '">' +
'<span class="m-daily-history-date">' + escapeHtml(displayCompactDate(it.trade_date)) + "</span>" +
'<span class="m-daily-history-line">' + escapeHtml(oneLine) + "</span>" +
"</button>";
}).join("");
return '<div class="m-daily-history">' +
'<div class="m-section-title">历史复盘</div>' +
'<div class="m-daily-history-list">' + rows + "</div>" +
"</div>";
}
function deleteDailyNote(noteId) {
openConfirmSheet("删除此日复盘", "删除后无法恢复,确定删除这一天的复盘吗?", {
danger: true,
confirmLabel: "删除",
onConfirm: function () {
global.MobileAPI.request("/api/notes/" + encodeURIComponent(noteId), "DELETE").then(function () {
state.review.dailyItems = (state.review.dailyItems || []).filter(function (it) { return String(it.id) !== String(noteId); });
closeSheet();
renderReviewDaily();
showToast("复盘已删除");
}).catch(function (error) {
showToast(error && error.message ? error.message : "删除失败");
});
}
});
}
/* ----- 个股笔记 ----- */
function loadReviewNotes() {
const seq = nextSeq();
global.MobileAPI.request("/api/notes?scope=stock").then(function (payload) {
if (seq !== state.seq) return;
state.review.notes = (payload && payload.items) || [];
renderReviewNotes();
}).catch(function (error) {
if (seq !== state.seq) return;
const scroll = document.getElementById("m-scroll");
if (scroll) scroll.innerHTML = reviewState("个股笔记");
});
}
function groupNotesByStock(items) {
const groups = {};
(items || []).forEach(function (it) {
const key = it.code || "_";
if (!groups[key]) groups[key] = { code: it.code, name: it.stock_name || it.code || "--", notes: [] };
groups[key].notes.push(it);
});
return Object.keys(groups).map(function (k) { return groups[k]; });
}
function renderReviewNotes() {
const scroll = document.getElementById("m-scroll");
if (!scroll) return;
const items = state.review.notes || [];
if (!items.length) {
scroll.innerHTML = '<div class="m-state m-motion-rise-in">' +
'<span class="m-state-icon">' + icon("sticky-note", 26) + "</span>" +
"<p>还没有个股笔记</p>" +
"<small>把对个股的观察与计划记下来</small>" +
'<button class="m-btn-primary m-btn-retry" type="button" data-review-add-note style="margin-top:16px;">记一条笔记</button>' +
"</div>";
return;
}
const groups = groupNotesByStock(items);
scroll.innerHTML = groups.map(function (g) {
const cards = g.notes.map(function (it) {
const plan = it.plan ? '<span class="m-note-card-plan">计划:' + escapeHtml(it.plan) + "</span>" : "";
return '<button class="m-card m-note-card" type="button" data-note-id="' + escapeHtml(String(it.id)) + '">' +
'<span class="m-note-card-date">' + escapeHtml(displayCompactDate(it.trade_date)) + "</span>" +
'<span class="m-note-card-body">' + escapeHtml(it.content || "(无内容)") + "</span>" +
plan +
"</button>";
}).join("");
return '<div class="m-note-group">' +
'<div class="m-note-group-head">' +
'<span class="m-note-group-name">' + escapeHtml(g.name) + "</span>" +
'<span class="m-note-group-code">' + escapeHtml(g.code || "") + "</span>" +
'<span class="m-note-group-count">' + g.notes.length + " 条</span>" +
"</div>" +
'<div class="m-note-group-list">' + cards + "</div>" +
"</div>";
}).join("");
}
function openNoteDetail(noteId) {
const item = (state.review.notes || []).find(function (it) { return String(it.id) === String(noteId); });
if (!item) return;
openSheet(
'<div class="m-sheet-head"><h2>' + escapeHtml((item.stock_name || item.code || "笔记") + " · " + displayCompactDate(item.trade_date)) + "</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-note-detail-body">' +
'<div class="m-note-detail-block"><div class="m-detail-group-title">笔记</div><div class="m-detail-text">' + escapeHtml(item.content || "尚未填写") + "</div></div>" +
'<div class="m-note-detail-block"><div class="m-detail-group-title">计划</div><div class="m-detail-text">' + escapeHtml(item.plan || "尚未填写") + "</div></div>" +
'<div class="m-confirm-actions">' +
'<button class="m-btn-primary m-btn-danger" type="button" data-review-delete-note data-note-id="' + escapeHtml(String(item.id)) + '">删除</button>' +
"</div></div>",
{ detail: false }
);
}
function deleteNote(noteId) {
openConfirmSheet("删除笔记", "删除后无法恢复,确定删除这条笔记吗?", {
danger: true,
confirmLabel: "删除",
onConfirm: function () {
global.MobileAPI.request("/api/notes/" + encodeURIComponent(noteId), "DELETE").then(function () {
state.review.notes = (state.review.notes || []).filter(function (it) { return String(it.id) !== String(noteId); });
closeSheet();
renderReviewNotes();
showToast("笔记已删除");
}).catch(function (error) {
showToast(error && error.message ? error.message : "删除失败");
});
}
});
}
/* ----- 提醒中心 ----- */
function loadReviewAlerts() {
const seq = nextSeq();
const status = state.review.alertStatus;
global.MobileAPI.request("/api/alerts?status=" + status + "&as_of=" + encodeURIComponent(todayString())).then(function (payload) {
if (seq !== state.seq) return;
state.review.alerts = payload || { items: [], unread_count: 0 };
renderReviewAlerts();
}).catch(function (error) {
if (seq !== state.seq) return;
const scroll = document.getElementById("m-scroll");
if (scroll) scroll.innerHTML = reviewState("提醒中心");
});
}
function alertKindLabel(kind) {
if (kind === "manual") return "自定提醒";
if (kind === "strategy_t5") return "跟踪完成";
return "策略反馈";
}
function alertKindBadge(kind) {
const tone = kind === "manual" ? "action" : kind === "strategy_t5" ? "down" : "warn";
return '<span class="m-alert-kind m-alert-kind--' + tone + '">' + escapeHtml(alertKindLabel(kind)) + "</span>";
}
function renderReviewAlerts() {
const scroll = document.getElementById("m-scroll");
if (!scroll) return;
const data = state.review.alerts || { items: [], unread_count: 0 };
const items = data.items || [];
const unreadCount = number(data.unread_count);
const readAllBtn = document.querySelector("[data-review-read-all]");
if (readAllBtn) readAllBtn.hidden = unreadCount <= 0;
const filterTabs = ["all", "unread"].map(function (f) {
const active = state.review.alertStatus === f;
const label = f === "all" ? "全部" : "未读";
const dot = f === "unread" && unreadCount > 0 ? '<span class="m-alert-dot" aria-hidden="true"></span>' : "";
return '<button class="m-source-tab' + (active ? " active" : "") + '" type="button" role="tab" aria-selected="' + (active ? "true" : "false") + '" data-review-alert-filter="' + f + '">' + label + dot + "</button>";
}).join("");
const tabs = '<div class="m-source-tabs m-alert-tabs" role="tablist" aria-label="提醒筛选">' + filterTabs + "</div>";
if (!items.length) {
const emptyText = state.review.alertStatus === "unread" ? "没有未读提醒" : "暂无提醒";
scroll.innerHTML = tabs + '<div class="m-state m-motion-rise-in" style="margin:24px 0;">' +
'<span class="m-state-icon">' + icon("bell", 26) + "</span>" +
"<p>" + escapeHtml(emptyText) + "</p>" +
(state.review.alertStatus === "all" ? '<small>重要节点给自己留个备忘</small><button class="m-btn-primary m-btn-retry" type="button" data-review-add-alert style="margin-top:16px;">新增提醒</button>' : "") +
"</div>";
return;
}
const rows = items.map(function (it) {
const upcoming = !it.due;
const unread = !it.is_read;
const dot = unread && it.due ? '<span class="m-alert-unread-dot" aria-hidden="true"></span>' : "";
const dateText = displayCompactDate(it.available_date);
return '<button class="m-alert-row' + (unread ? " is-unread" : " is-read") + '" type="button" data-alert-id="' + escapeHtml(String(it.id)) + '">' +
'<span class="m-alert-row-main">' +
'<span class="m-alert-row-top">' + alertKindBadge(it.kind) + '<span class="m-alert-row-date">' + escapeHtml(dateText) + (upcoming ? "(未到期)" : "") + "</span></span>" +
'<span class="m-alert-row-title">' + dot + escapeHtml(it.title) + "</span>" +
(it.content ? '<span class="m-alert-row-summary">' + escapeHtml(it.content) + "</span>" : "") +
"</span>" +
'<span class="m-alert-row-chevron">' + icon("chevron-right", 16) + "</span>" +
"</button>";
}).join("");
scroll.innerHTML = tabs + '<div class="m-alert-list">' + rows + "</div>";
}
function openAlertDetail(alertId) {
const item = (state.review.alerts && state.review.alerts.items || []).find(function (it) { return String(it.id) === String(alertId); });
if (!item) return;
const upcoming = !item.due;
const canRead = !item.is_read && !upcoming;
openSheet(
'<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-alert-detail">' +
alertKindBadge(item.kind) +
'<h3 class="m-alert-detail-title">' + escapeHtml(item.title) + "</h3>" +
'<div class="m-alert-detail-meta">' + escapeHtml(displayCompactDate(item.available_date)) + (upcoming ? "(未到期)" : "") + "</div>" +
(item.content ? '<div class="m-detail-text">' + escapeHtml(item.content) + "</div>" : "") +
(item.code ? '<button class="m-alert-stock" type="button" data-alert-stock data-code="' + escapeHtml(item.code) + '">' + escapeHtml(item.code) + "</button>" : "") +
"</div>" +
'<div class="m-confirm-actions">' +
(canRead ? '<button class="m-btn-primary" type="button" data-review-alert-read data-alert-id="' + escapeHtml(String(item.id)) + '">标为已读</button>' : "") +
'<button class="m-btn-primary m-btn-danger" type="button" data-review-alert-delete data-alert-id="' + escapeHtml(String(item.id)) + '">删除</button>' +
"</div></div>",
{ detail: false }
);
}
function markAlertRead(alertId) {
global.MobileAPI.request("/api/alerts/" + encodeURIComponent(alertId) + "/read", "POST", {}).then(function (payload) {
state.review.alerts = payload || state.review.alerts;
closeSheet();
renderReviewAlerts();
showToast("已标为已读");
}).catch(function (error) {
showToast(error && error.message ? error.message : "操作失败");
});
}
function deleteAlert(alertId) {
openConfirmSheet("删除提醒", "删除后无法恢复,确定删除这条提醒吗?", {
danger: true,
confirmLabel: "删除",
onConfirm: function () {
global.MobileAPI.request("/api/alerts/" + encodeURIComponent(alertId), "DELETE").then(function (payload) {
state.review.alerts = payload || state.review.alerts;
closeSheet();
renderReviewAlerts();
showToast("提醒已删除");
}).catch(function (error) {
showToast(error && error.message ? error.message : "删除失败");
});
}
});
}
function markAllAlertsRead() {
global.MobileAPI.request("/api/alerts/read-all", "POST", { as_of: todayString() }).then(function (payload) {
state.review.alerts = payload || state.review.alerts;
renderReviewAlerts();
showToast("全部已读");
}).catch(function (error) {
showToast(error && error.message ? error.message : "操作失败");
});
}
/* ----- 全屏表单页 ----- */
function openForm(kind, opts) {
const t = (opts && opts.trade) || {};
state.review.form = { kind: kind, opts: opts || {}, action: t.action || "buy", emotion: t.emotion || "calm", stock: null };
state.review.dirty = false;
const title = kind === "trade" ? ((opts && opts.trade) ? "编辑交易" : "新增交易") : kind === "daily" ? "写复盘" : "新增笔记";
global.MobileRouter.updateHeader({ title: title, back: true, actions: "" });
document.getElementById("m-view").classList.add("m-view-feature");
document.getElementById("m-view").innerHTML = renderForm();
bindForm();
}
function closeForm() {
if (state.review.dirty) {
openConfirmSheet("放弃本次编辑?", "返回后已填写的内容将不会保存。", {
confirmLabel: "放弃",
onConfirm: function () { exitForm(); }
});
return;
}
exitForm();
}
function exitForm() {
const key = state.key;
const wasDaily = state.review.subKey === "daily";
const keepDate = state.requestedDate;
state.review.form = null;
state.review.dirty = false;
setupReviewPage(key);
if (wasDaily) state.requestedDate = keepDate;
loadReview();
}
function formFieldHtml(label, controlHtml, required, hintHtml) {
return '<label class="m-form-field">' +
'<span>' + escapeHtml(label) + (required ? '<em class="m-field-req">*</em>' : "") + "</span>" +
controlHtml +
(hintHtml || "") +
"</label>";
}
function textInputHtml(id, value, attrs) {
return '<input id="' + id + '" type="text" value="' + escapeHtml(value == null ? "" : value) + '"' + (attrs || "") + ">";
}
function numberInputHtml(id, value, attrs) {
return '<input id="' + id + '" type="text" inputmode="decimal" value="' + escapeHtml(value == null ? "" : value) + '"' + (attrs || "") + ">";
}
function textareaHtml(id, value, maxlength, rows) {
return '<div class="m-field-textarea">' +
'<textarea id="' + id + '" maxlength="' + maxlength + '" rows="' + (rows || 3) + '" data-maxlength="' + maxlength + '">' + escapeHtml(value == null ? "" : value) + "</textarea>" +
'<span class="m-field-counter" data-counter-for="' + id + '">0/' + maxlength + "</span>" +
"</div>";
}
function dateInputHtml(id, value) {
return '<div class="m-input-date">' +
'<input id="' + id + '" type="date" value="' + escapeHtml(value || todayString()) + '">' +
'<span class="m-input-date-icon">' + icon("calendar", 16) + "</span>" +
"</div>";
}
function segHtml(group, options, selected) {
return '<div class="m-seg" role="radiogroup" data-seg-group="' + group + '">' +
options.map(function (opt) {
const active = opt.key === selected;
return '<button class="m-seg-opt' + (active ? " active" : "") + '" type="button" role="radio" aria-checked="' + (active ? "true" : "false") + '" data-seg-key="' + escapeHtml(opt.key) + '">' + escapeHtml(opt.label) + "</button>";
}).join("") +
"</div>";
}
function stockPickerHtml(id, stock) {
const chip = stock
? '<span class="m-stock-chip">' +
'<span class="m-stock-chip-name">' + escapeHtml(stock.name) + "</span>" +
'<span class="m-stock-chip-code">' + escapeHtml(stock.code) + "</span>" +
'<button class="m-stock-chip-clear" type="button" data-stock-clear aria-label="清除">' + icon("close", 14) + "</button>" +
"</span>"
: "";
return '<div class="m-stock-picker" data-stock-picker>' +
(chip || '<input id="' + id + '" type="text" placeholder="搜索股票名称或代码" autocomplete="off">') +
'<div class="m-stock-picker-results" data-stock-results hidden></div>' +
"</div>";
}
function renderForm() {
const form = state.review.form;
if (form.kind === "trade") {
return renderTradeForm(form);
}
if (form.kind === "daily") {
return renderDailyForm(form);
}
return renderNoteForm(form);
}
function renderTradeForm(form) {
const t = form.opts.trade || {};
const tags = (t.tags || []).join(", ");
const fields = [
formFieldHtml("日期", dateInputHtml("m-trade-date", displayCompactDate(t.trade_date)), true),
formFieldHtml("代码", textInputHtml("m-trade-code", t.code, 'inputmode="numeric" maxlength="6" placeholder="6 位代码"'), true),
formFieldHtml("名称", textInputHtml("m-trade-name", t.name, 'placeholder="股票名称"'), true),
formFieldHtml("动作", segHtml("action", TRADE_ACTION_OPTIONS, form.action || t.action || "buy"), true),
formFieldHtml("价格", numberInputHtml("m-trade-price", t.price, 'placeholder="成交价格"'), true),
formFieldHtml("数量", numberInputHtml("m-trade-quantity", t.quantity, 'inputmode="numeric" placeholder="成交数量"'), false),
formFieldHtml("仓位%", '<div class="m-input-unit">' + numberInputHtml("m-trade-position", t.position_pct, 'placeholder="0"') + '<span class="m-input-unit-suffix">%</span></div>', false),
formFieldHtml("盈亏额", numberInputHtml("m-trade-pnl-amount", t.pnl_amount, 'placeholder="可留空"'), false),
formFieldHtml("盈亏%", '<div class="m-input-unit">' + numberInputHtml("m-trade-pnl-pct", t.pnl_pct, 'placeholder="可留空"') + '<span class="m-input-unit-suffix">%</span></div>', false),
formFieldHtml("情绪", segHtml("emotion", TRADE_EMOTION_OPTIONS, form.emotion || t.emotion || "calm"), true),
formFieldHtml("标签", textInputHtml("m-trade-tags", tags, 'placeholder="逗号分隔,如 打板、龙头"'), false),
formFieldHtml("交易逻辑", textareaHtml("m-trade-thesis", t.thesis, 2000, 4), false),
formFieldHtml("执行复核", textareaHtml("m-trade-execution", t.execution, 2000, 4), false)
];
return '<div class="m-page m-form-page" data-page="' + escapeHtml(state.key) + '">' +
'<div class="m-scroll" id="m-scroll">' +
'<div class="m-form-body">' + fields.join("") + "</div>" +
"</div>" +
'<div class="m-form-bar"><button class="m-btn-primary" type="button" data-review-submit>保存</button></div>' +
"</div>";
}
function renderDailyForm() {
const note = state.review.form.opts.note || {};
const fields = [
formFieldHtml("日期", dateInputHtml("m-daily-date", state.requestedDate || todayString()), true),
formFieldHtml("今日盘面", textareaHtml("m-daily-summary", note.summary, 500, 3), false),
formFieldHtml("做对做错", textareaHtml("m-daily-content", note.content, 5000, 8), false),
formFieldHtml("明日计划", textareaHtml("m-daily-plan", note.plan, 2000, 5), false)
];
return '<div class="m-page m-form-page" data-page="' + escapeHtml(state.key) + '">' +
'<div class="m-scroll" id="m-scroll">' +
'<div class="m-form-body">' + fields.join("") + "</div>" +
"</div>" +
'<div class="m-form-bar"><button class="m-btn-primary" type="button" data-review-submit>保存</button></div>' +
"</div>";
}
function renderNoteForm() {
const fields = [
formFieldHtml("股票", stockPickerHtml("m-note-stock", state.review.form.stock), true),
formFieldHtml("笔记内容", textareaHtml("m-note-content", "", 5000, 6), false),
formFieldHtml("计划", textareaHtml("m-note-plan", "", 2000, 4), false)
];
return '<div class="m-page m-form-page" data-page="' + escapeHtml(state.key) + '">' +
'<div class="m-scroll" id="m-scroll">' +
'<div class="m-form-body">' + fields.join("") + "</div>" +
"</div>" +
'<div class="m-form-bar"><button class="m-btn-primary" type="button" data-review-submit>保存</button></div>' +
"</div>";
}
function markDirty() {
if (state.review.form) state.review.dirty = true;
}
function bindForm() {
const scroll = document.getElementById("m-scroll");
if (!scroll) return;
scroll.addEventListener("input", function (event) {
if (event.target.matches("input, textarea")) markDirty();
if (event.target.dataset && event.target.dataset.maxlength) {
const counter = document.querySelector('[data-counter-for="' + event.target.id + '"]');
if (counter) counter.textContent = event.target.value.length + "/" + event.target.dataset.maxlength;
}
});
scroll.querySelectorAll("textarea[data-maxlength]").forEach(function (ta) {
const counter = document.querySelector('[data-counter-for="' + ta.id + '"]');
if (counter) counter.textContent = ta.value.length + "/" + ta.dataset.maxlength;
});
scroll.addEventListener("click", function (event) {
const seg = event.target.closest("[data-seg-key]");
if (seg) {
const group = seg.parentElement && seg.parentElement.dataset.segGroup;
seg.parentElement.querySelectorAll("[data-seg-key]").forEach(function (b) {
b.classList.toggle("active", b === seg);
b.setAttribute("aria-checked", String(b === seg));
});
state.review.form[group] = seg.dataset.segKey;
markDirty();
return;
}
});
const picker = scroll.querySelector("[data-stock-picker]");
if (picker) bindStockPicker(picker, state.review.form);
}
function bindStockPicker(picker, target) {
const t = target || { stock: null };
let timer = null;
let requestSeq = 0;
function hideResults() {
const results = picker.querySelector("[data-stock-results]");
if (results) { results.hidden = true; results.innerHTML = ""; }
}
function renderPickerContent() {
picker.innerHTML = stockPickerHtml("", t.stock);
}
function runStockSearch(q) {
const seq = ++requestSeq;
global.MobileAPI.request("/api/search?q=" + encodeURIComponent(q) + "&trade_date=" + encodeURIComponent(todayString())).then(function (payload) {
if (seq !== requestSeq) return;
const stocks = (payload && payload.groups && payload.groups.stocks) || [];
const results = picker.querySelector("[data-stock-results]");
if (!results) return;
results.hidden = false;
results.innerHTML = stocks.length
? stocks.map(function (s) {
return '<button class="m-stock-picker-row" type="button" data-stock-pick="' + escapeHtml(s.code) + '" data-stock-name="' + escapeHtml(s.name) + '" data-stock-sector="' + escapeHtml(s.industry || "其他") + '">' +
'<span class="m-stock-picker-row-name">' + escapeHtml(s.name) + "</span>" +
'<span class="m-stock-picker-row-sub">' + escapeHtml(s.industry || "其他") + "</span>" +
'<span class="m-stock-picker-row-code">' + escapeHtml(s.code) + "</span>" +
"</button>";
}).join("")
: '<div class="m-stock-picker-empty">没有找到匹配的股票</div>';
}).catch(function () {
if (seq !== requestSeq) return;
const results = picker.querySelector("[data-stock-results]");
if (!results) return;
results.hidden = false;
results.innerHTML = '<div class="m-stock-picker-empty">搜索失败</div>';
});
}
picker.addEventListener("input", function (event) {
if (event.target.tagName !== "INPUT") return;
markDirty();
clearTimeout(timer);
const q = event.target.value.trim();
if (!q) { hideResults(); return; }
timer = setTimeout(function () { runStockSearch(q); }, 180);
});
picker.addEventListener("focusin", function (event) {
if (event.target.tagName !== "INPUT") return;
const q = event.target.value.trim();
if (q) runStockSearch(q);
});
picker.addEventListener("click", function (event) {
const row = event.target.closest("[data-stock-pick]");
if (row) {
t.stock = { code: row.dataset.stockPick, name: row.dataset.stockName, sector: row.dataset.stockSector };
hideResults();
renderPickerContent();
markDirty();
return;
}
const clear = event.target.closest("[data-stock-clear]");
if (clear) {
t.stock = null;
renderPickerContent();
markDirty();
return;
}
});
document.addEventListener("click", function onDoc(event) {
if (!picker.contains(event.target)) hideResults();
});
}
function fieldError(id, message) {
const input = document.getElementById(id);
if (!input) return;
const label = input.closest(".m-form-field");
if (!label) return;
label.classList.add("is-invalid");
let err = label.querySelector(".m-field-error");
if (!err) {
err = document.createElement("span");
err.className = "m-field-error";
label.appendChild(err);
}
err.textContent = message;
}
function clearFieldError(id) {
const input = document.getElementById(id);
if (!input) return;
const label = input.closest(".m-form-field");
if (!label) return;
label.classList.remove("is-invalid");
const err = label.querySelector(".m-field-error");
if (err) err.remove();
}
function numberValue(id) {
const el = document.getElementById(id);
const raw = el ? el.value.trim() : "";
return raw === "" ? "" : number(raw);
}
function submitForm() {
const form = state.review.form;
if (!form) return;
if (form.kind === "trade") submitTradeForm();
else if (form.kind === "daily") submitDailyForm();
else submitNoteForm();
}
function setSubmitting(btn, submitting) {
if (!btn) return;
btn.disabled = submitting;
if (submitting) {
btn.innerHTML = '<span class="m-btn-spinner" aria-hidden="true"></span><span>保存中…</span>';
} else {
btn.innerHTML = "保存";
}
}
function submitTradeForm() {
const form = state.review.form;
const trade = form.opts.trade || {};
const tradeDate = document.getElementById("m-trade-date").value;
const code = document.getElementById("m-trade-code").value.trim();
const name = document.getElementById("m-trade-name").value.trim();
const price = numberValue("m-trade-price");
const position = numberValue("m-trade-position");
const pnlAmount = numberValue("m-trade-pnl-amount");
const pnlPct = numberValue("m-trade-pnl-pct");
const tags = document.getElementById("m-trade-tags").value.trim();
clearFieldError("m-trade-code");
clearFieldError("m-trade-name");
clearFieldError("m-trade-price");
let invalid = false;
if (!code) { fieldError("m-trade-code", "请输入股票代码"); invalid = true; }
if (!name) { fieldError("m-trade-name", "请输入股票名称"); invalid = true; }
if (price === "") { fieldError("m-trade-price", "请输入成交价格"); invalid = true; }
if (invalid) return;
const payload = {
trade_date: tradeDate || todayString(),
code: code,
name: name,
action: form.action,
price: price,
quantity: number(document.getElementById("m-trade-quantity").value || 0),
position_pct: position === "" ? 0 : position,
pnl_amount: pnlAmount === "" ? null : pnlAmount,
pnl_pct: pnlPct === "" ? null : pnlPct,
emotion: form.emotion,
tags: tags ? tags.split(/[,]/).map(function (s) { return s.trim(); }).filter(Boolean) : [],
thesis: document.getElementById("m-trade-thesis").value.trim(),
execution: document.getElementById("m-trade-execution").value.trim()
};
if (trade.id) payload.id = trade.id;
const btn = document.querySelector("[data-review-submit]");
setSubmitting(btn, true);
global.MobileAPI.request("/api/trades", "POST", payload).then(function () {
state.review.dirty = false;
exitForm();
showToast("交易已保存");
}).catch(function (error) {
setSubmitting(btn, false);
showToast(error && error.message ? error.message : "保存失败");
});
}
function submitDailyForm() {
const form = state.review.form;
const note = form.opts.note || {};
const summary = document.getElementById("m-daily-summary").value.trim();
const content = document.getElementById("m-daily-content").value.trim();
const plan = document.getElementById("m-daily-plan").value.trim();
const tradeDate = document.getElementById("m-daily-date").value || todayString();
if (!summary && !content && !plan) {
showToast("复盘内容不能全部为空");
return;
}
const payload = {
trade_date: tradeDate,
summary: summary,
content: content,
plan: plan
};
if (note.id) payload.id = note.id;
const btn = document.querySelector("[data-review-submit]");
setSubmitting(btn, true);
global.MobileAPI.request("/api/notes", "POST", payload).then(function () {
state.review.dirty = false;
state.requestedDate = tradeDate;
exitForm();
showToast("已保存");
}).catch(function (error) {
setSubmitting(btn, false);
showToast(error && error.message ? error.message : "保存失败");
});
}
function submitNoteForm() {
const form = state.review.form;
const stock = form.stock;
if (!stock || !stock.code) {
showToast("请先选择股票");
return;
}
const payload = {
code: stock.code,
stock_name: stock.name,
trade_date: todayString(),
content: document.getElementById("m-note-content").value.trim(),
plan: document.getElementById("m-note-plan").value.trim()
};
if (!payload.content && !payload.plan) {
showToast("笔记内容不能为空");
return;
}
const btn = document.querySelector("[data-review-submit]");
setSubmitting(btn, true);
global.MobileAPI.request("/api/notes", "POST", payload).then(function () {
state.review.dirty = false;
exitForm();
showToast("笔记已保存");
}).catch(function (error) {
setSubmitting(btn, false);
showToast(error && error.message ? error.message : "保存失败");
});
}
/* ----- 提醒新增抽屉 ----- */
function openAlertAddDrawer() {
state.review.alertDraft = { stock: null };
openSheet(
'<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-alert-add-body">' + alertAddDrawerBody() + "</div>",
{ detail: false }
);
const body = document.getElementById("m-alert-add-body");
const picker = body.querySelector("[data-stock-picker]");
if (picker) bindStockPicker(picker, state.review.alertDraft);
}
function alertAddDrawerBody() {
return '<label class="m-form-field"><span>标题<em class="m-field-req">*</em></span>' +
'<input id="m-alert-title" type="text" maxlength="80" placeholder="提醒标题"></label>' +
formFieldHtml("提醒日期", dateInputHtml("m-alert-date", todayString()), true) +
formFieldHtml("关联股票", stockPickerHtml("m-alert-stock", state.review.alertDraft ? state.review.alertDraft.stock : null), false) +
formFieldHtml("内容", textareaHtml("m-alert-content", "", 500, 3), false) +
'<div class="m-confirm-actions"><button class="m-btn-primary" type="button" data-alert-save>保存</button></div>';
}
function submitAlertAdd() {
const title = document.getElementById("m-alert-title").value.trim();
if (!title) { showToast("请输入提醒标题"); return; }
const stock = state.review.alertDraft && state.review.alertDraft.stock;
const payload = {
title: title,
remind_date: document.getElementById("m-alert-date").value || todayString(),
code: stock ? stock.code : "",
content: document.getElementById("m-alert-content").value.trim()
};
global.MobileAPI.request("/api/alerts", "POST", payload).then(function (res) {
state.review.alerts = res || state.review.alerts;
closeSheet();
renderReviewAlerts();
showToast("已添加提醒");
}).catch(function (error) {
showToast(error && error.message ? error.message : "保存失败");
});
}
function buildReviewTable(cols, rows, opts) {
const o = opts || {};
const frozen = cols.frozenColumns || [];
const primary = cols.primaryColumns || [];
const scroll = cols.scrollColumns || [];
const ordered = frozen.concat(primary, scroll);
let left = 0;
const frozenLeft = frozen.map(function (col) { const off = left; left += columnWidth(col); return off; });
function cellOpen(col, index, isHead, extraCls, extraAttrs) {
const isFrozen = index < frozen.length;
const width = columnWidth(col);
const clamp = (col.type === "text" || col.type === "concepts") ? ";max-width:" + width + "px" : "";
const style = "min-width:" + width + "px" + clamp + (isFrozen ? ";width:" + width + "px;left:" + frozenLeft[index] + "px" : "");
const cls = (isHead ? "m-th" : "m-td") + " " + colAlign(col) + (isFrozen ? " m-frozen" : "") + (extraCls ? " " + extraCls : "");
return '<' + (isHead ? "th" : "td") + ' class="' + cls + '" style="' + style + '"' + (extraAttrs || "") + '>';
}
const head = ordered.map(function (col, i) {
const sortable = !o.noSort && sortableColumn(col);
let extraCls = "";
let extraAttrs = "";
let indicator = "";
if (sortable) {
const active = state.sort.key === col.key && Boolean(state.sort.dir);
extraCls = " m-sortable" + (active ? " is-sorted" : "");
extraAttrs = ' data-sort-key="' + escapeHtml(col.key) + '" aria-sort="' + (active ? (state.sort.dir === "asc" ? "ascending" : "descending") : "none") + '"';
indicator = sortIndicatorHtml(active);
}
return cellOpen(col, i, true, extraCls, extraAttrs) + '<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) {
const custom = o.cellRenderer ? o.cellRenderer(col, row, rIndex) : null;
const html = custom != null ? custom : cellHtml(col, row, rIndex);
return cellOpen(col, i, false) + html + "</td>";
}).join("");
const linkable = !o.noLink && /^\d{6}$/.test(String(row.code || ""));
const code = linkable ? ' data-code="' + escapeHtml(row.code) + '"' : "";
const extra = o.rowAttrs ? o.rowAttrs(row) : "";
return "<tr" + code + extra + ">" + cells + "</tr>";
}).join("");
return '<table class="m-table"><thead><tr>' + head + "</tr></thead><tbody>" + body + "</tbody></table>";
}
/* ---------------------------------------------------------------- 系统管理(恢复桌面端已有能力,禁止再走占位页) */
function isSystemPage(key) {
return String(key || state.key || "").indexOf("system/") === 0;
}
function systemForbiddenHtml() {
return '<div class="m-state m-motion-rise-in" data-system-page="forbidden">' +
'<span class="m-state-icon">' + icon("lock", 26) + "</span>" +
"<p>仅管理员可访问</p>" +
"<small>系统设置和会员管理需要管理员权限。</small>" +
"</div>";
}
function systemFill(html) {
const scroll = document.getElementById("m-scroll");
if (scroll) scroll.innerHTML = html;
}
function membershipDateLabel(value) {
if (!value) return "";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return String(value).slice(0, 10);
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(parsed);
}
function setupSystemPage(key) {
state.key = key;
state.requestedDate = todayString();
state.sort = { key: "", dir: null };
state.sortTable = { cols: null, reapply: null };
state.detail = null;
if (key === "system/admin") state.system.adminTab = "market";
document.getElementById("m-view").classList.add("m-view-feature");
global.MobileRouter.updateHeader({ title: findLabel(key) || key, back: true, actions: "" });
document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(6)));
}
function loadSystem() {
const key = state.key;
if ((key === "system/admin" || key === "system/members") && !global.MobileSession.isAdmin()) {
systemFill(systemForbiddenHtml());
return;
}
if (key === "system/password") {
renderSystemPassword();
return;
}
const seq = nextSeq();
const url = (key === "system/admin" || key === "system/members") ? "/api/admin/settings" : "/api/account/status";
global.MobileAPI.request(url).then(function (payload) {
if (seq !== state.seq || state.key !== key) return;
if (key === "system/admin" || key === "system/members") {
state.system.admin = payload || {};
renderSystemAdmin(key);
} else {
state.system.account = payload || {};
if (key === "system/profile") renderSystemProfile();
else renderSystemMembership();
}
}).catch(function (error) {
if (seq !== state.seq || state.key !== key) return;
systemFill(errorHtml(error && error.message ? error.message : "加载失败"));
});
}
function currentUsername() {
const user = global.MobileSession && global.MobileSession.state ? global.MobileSession.state.user : null;
return user && user.username ? user.username : "当前账号";
}
function renderSystemProfile() {
const payload = state.system.account || {};
const birth = payload.birth_profile || {};
let birthDate = "";
let birthTime = "";
if (birth.birth_datetime) {
const parts = String(birth.birth_datetime).split("T");
birthDate = parts[0] || "";
birthTime = (parts[1] || "").slice(0, 5);
}
const gender = birth.gender || "unspecified";
const configured = Boolean(payload.birth_profile_configured);
const html =
'<div class="m-form-body" data-system-page="profile">' +
'<div class="m-card"><strong>' + escapeHtml(currentUsername()) + "</strong><p class=\"m-sys-lead\">出生信息仅对当前账号可见并加密保存。</p></div>" +
'<p class="m-sys-lead">原始信息加密保存且不在观气页回显;智能解读只使用排盘后的派生结果。</p>' +
formFieldHtml("出生日期", dateInputHtml("m-sys-birth-date", birthDate), true) +
formFieldHtml("出生时间", '<input id="m-sys-birth-time" type="time" value="' + escapeHtml(birthTime) + '">', true) +
formFieldHtml("性别", '<select id="m-sys-birth-gender">' +
'<option value="unspecified"' + (gender === "unspecified" ? " selected" : "") + ">不指定</option>" +
'<option value="male"' + (gender === "male" ? " selected" : "") + ">男</option>" +
'<option value="female"' + (gender === "female" ? " selected" : "") + ">女</option>" +
"</select>", false) +
'<p class="m-sys-lead">资料状态:' + (configured ? "已加密保存" : "尚未设置") + "</p>" +
'<button class="m-btn-primary m-btn-danger" type="button" data-system-delete-birth' + (configured ? "" : " disabled") + ">删除资料</button>" +
'<div class="m-card m-sys-account-actions">' +
'<button class="m-btn-primary" type="button" data-system-switch>切换账号</button>' +
'<button class="m-btn-primary m-btn-danger" type="button" data-system-logout>退出当前账号</button>' +
"</div></div>";
const page = document.querySelector(".m-page");
if (page) {
page.innerHTML = '<div class="m-scroll" id="m-scroll">' + html + "</div>" +
'<div class="m-form-bar"><button class="m-btn-primary" type="button" data-system-save-birth>保存资料</button></div>';
}
}
function renderSystemPassword() {
const html =
'<div class="m-form-body" data-system-page="password">' +
'<p class="m-sys-lead">仅修改当前账号密码,不会保存在这台设备上。</p>' +
formFieldHtml("当前密码", '<input id="m-sys-password-current" type="password" autocomplete="current-password">', true) +
formFieldHtml("新密码", '<input id="m-sys-password-new" type="password" minlength="8" maxlength="128" autocomplete="new-password">', true) +
formFieldHtml("确认新密码", '<input id="m-sys-password-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password">', true) +
"</div>";
const page = document.querySelector(".m-page");
if (page) {
page.innerHTML = '<div class="m-scroll" id="m-scroll">' + html + "</div>" +
'<div class="m-form-bar"><button class="m-btn-primary" type="button" data-system-save-password>更新密码</button></div>';
}
}
function renderSystemMembership() {
const payload = state.system.account || {};
const access = payload.llm_access || {};
const membership = access.membership || {};
const badge = membership.subscribed ? "会员有效" : membership.is_admin ? "管理员权限" : "普通用户";
const stateLabel = membership.subscribed ? "已开通" : membership.is_admin ? "管理员可用" : "未开通";
const remaining = membership.subscribed && membership.expires_at
? number(membership.remaining_days) + " 天"
: (membership.is_admin || membership.subscribed ? "长期有效" : "--");
const detail = membership.subscribed
? ((membership.plan || "会员") + (membership.expires_at ? " · 有效至 " + membershipDateLabel(membership.expires_at) : " · 长期有效"))
: membership.is_admin
? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。"
: "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。";
const quota = "会员默认每日智能分析额度 " + number(access.daily_limit) + " 次,由管理员统一设置。";
const usage = membership.active ? ("今日已用 " + number(access.used_today) + " 次") : "今日智能分析:--";
const usageSummary = membership.active ? (number(access.used_today) + " / " + number(access.daily_limit)) : "--";
const remainingCalls = membership.is_admin ? "不限" : membership.active ? (number(access.remaining_calls) + " 次") : "--";
const html =
'<div class="m-form-body" data-system-page="membership">' +
'<div class="m-card"><strong>' + escapeHtml(badge) + "</strong><p class=\"m-sys-lead\">" + escapeHtml(detail) + "</p><p class=\"m-sys-lead\">" + escapeHtml(quota) + "</p></div>" +
'<div class="m-sys-grid">' +
"<div><span>开通状态</span><strong>" + escapeHtml(stateLabel) + "</strong></div>" +
"<div><span>剩余时长</span><strong>" + escapeHtml(remaining) + "</strong></div>" +
"<div><span>今日智能分析</span><strong>" + escapeHtml(usageSummary) + "</strong></div>" +
"<div><span>剩余智能分析</span><strong>" + escapeHtml(remainingCalls) + "</strong></div>" +
"</div>" +
'<div class="m-card"><p class="m-sys-lead">' + escapeHtml(usage) + "</p>" +
'<p class="m-sys-lead">行情、搜索、自选与复盘:普通用户可用。智能选股、问师、问天、复盘助手:仅会员可用。</p></div>' +
"</div>";
systemFill(html);
}
function adminTabHtml() {
const tab = state.system.adminTab === "models" ? "models" : "market";
return '<div class="m-source-tabs" role="tablist" aria-label="系统设置分类">' +
'<button class="m-source-tab' + (tab === "market" ? " active" : "") + '" type="button" role="tab" data-system-admin-tab="market">行情管理</button>' +
'<button class="m-source-tab' + (tab === "models" ? " active" : "") + '" type="button" role="tab" data-system-admin-tab="models">模型池</button>' +
"</div>";
}
function renderSystemAdmin(key) {
if (key === "system/members") {
renderSystemMembers();
return;
}
const payload = state.system.admin || {};
const data = payload.data || {};
const ifind = data.ifind || {};
const llm = payload.llm || {};
const status = "Tushare " + (data.configured ? "已配置" : "未配置") +
" · iFinD " + (ifind.configured ? "已配置" : "未配置") +
" · " + number(data.snapshot_dates) + " 个交易日";
const refreshLabel = data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停";
const tab = state.system.adminTab === "models" ? "models" : "market";
const marketHtml =
'<div class="m-form-body" data-system-admin-panel="market">' +
'<div class="m-card"><strong>公共行情</strong><p class="m-sys-lead">' + escapeHtml(status) + "</p><p class=\"m-sys-lead\">" + escapeHtml(refreshLabel) + "</p></div>" +
formFieldHtml("Tushare Token", '<input id="m-sys-token" type="password" autocomplete="off" minlength="20" placeholder="留空保留现有 Token">', false) +
formFieldHtml("iFinD Refresh Token", '<input id="m-sys-ifind" type="password" autocomplete="off" maxlength="2048" placeholder="留空保留现有 Token">', false) +
formFieldHtml("交易时段后台刷新", '<input id="m-sys-bg-refresh" type="checkbox"' + (data.background_refresh_enabled ? " checked" : "") + ">", false) +
'<p class="m-sys-lead">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p>' +
'<button class="m-btn-primary" type="button" data-system-refresh>立即后台刷新</button>' +
'<div class="m-card"><strong>历史数据回补</strong></div>' +
formFieldHtml("开始日期", dateInputHtml("m-sys-backfill-start", ""), false) +
formFieldHtml("结束日期", dateInputHtml("m-sys-backfill-end", ""), false) +
'<button class="m-btn-primary" type="button" data-system-backfill>开始回补</button>' +
"</div>";
const modelsHtml =
'<div class="m-form-body" data-system-admin-panel="models">' +
formFieldHtml("主模型", '<select id="m-sys-primary-model"></select>', false) +
formFieldHtml("辅助模型", '<select id="m-sys-fallback-model"></select>', false) +
'<div id="m-sys-model-list">' + renderModelPoolHtml(llm.models || []) + "</div>" +
'<button class="m-btn-primary" type="button" data-system-add-model>添加模型</button>' +
"</div>";
const page = document.querySelector(".m-page");
if (!page) return;
const bar = tab === "models"
? '<div class="m-form-bar"><button class="m-btn-primary" type="button" data-system-save-models>保存模型池</button></div>'
: '<div class="m-form-bar"><button class="m-btn-primary" type="button" data-system-save-market>保存行情配置</button></div>';
page.innerHTML = adminTabHtml() + '<div class="m-scroll" id="m-scroll">' + (tab === "models" ? modelsHtml : marketHtml) + "</div>" + bar;
if (tab === "models") updateSystemModelRoleOptions(llm.primary_model_id || "", llm.fallback_model_id || "");
}
function renderModelPoolHtml(models) {
if (!models.length) {
return '<div class="m-state"><p>模型池为空,请先添加模型</p></div>';
}
return models.map(function (item, index) {
return '<article class="m-card" data-model-id="' + escapeHtml(item.id) + '">' +
"<strong>" + escapeHtml(item.name || ("模型 " + (index + 1))) + "</strong>" +
'<p class="m-sys-lead">' + (item.configured ? "已保存密钥" : "待配置") + "</p>" +
formFieldHtml("显示名称", '<input data-model-field="name" maxlength="50" value="' + escapeHtml(item.name || "") + '">', true) +
formFieldHtml("API Base URL", '<input data-model-field="base_url" type="url" value="' + escapeHtml(item.base_url || "https://api.openai.com/v1") + '">', true) +
formFieldHtml("模型标识", '<input data-model-field="model" maxlength="100" value="' + escapeHtml(item.model || "") + '">', true) +
formFieldHtml("API Key", '<input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="' + (item.configured ? "留空保留已保存的 Key" : "输入 API Key") + '">', !item.configured) +
'<button class="m-btn-primary" type="button" data-system-test-model>测试连接</button>' +
'<p class="m-sys-lead" data-model-test-status>未测试</p>' +
'<button class="m-btn-primary m-btn-danger" type="button" data-system-delete-model>删除模型</button>' +
"</article>";
}).join("");
}
function collectSystemModelPool() {
const models = (state.system.admin && state.system.admin.llm && state.system.admin.llm.models) || [];
const saved = new Map(models.map(function (item) { return [item.id, item]; }));
return Array.prototype.map.call(document.querySelectorAll("#m-sys-model-list [data-model-id]"), function (row) {
function fieldValue(name) {
const input = row.querySelector("[data-model-field='" + name + "']");
return String(input && input.value != null ? input.value : "").trim();
}
return {
id: row.dataset.modelId,
name: fieldValue("name"),
base_url: fieldValue("base_url"),
model: fieldValue("model"),
api_key: fieldValue("api_key"),
configured: Boolean(saved.get(row.dataset.modelId) && saved.get(row.dataset.modelId).configured),
};
});
}
function updateSystemModelRoleOptions(primaryId, fallbackId) {
const models = collectSystemModelPool();
const options = models.map(function (item) {
return '<option value="' + escapeHtml(item.id) + '">' + escapeHtml(item.name || item.model || "未命名模型") + "</option>";
}).join("");
const primary = document.getElementById("m-sys-primary-model");
const fallback = document.getElementById("m-sys-fallback-model");
if (!primary || !fallback) return;
primary.innerHTML = models.length ? options : '<option value="">暂无模型</option>';
fallback.innerHTML = '<option value="">不启用辅助模型</option>' + options;
const keepPrimary = models.some(function (item) { return item.id === primaryId; }) ? primaryId : (models[0] && models[0].id) || "";
primary.value = keepPrimary;
fallback.value = models.some(function (item) { return item.id === fallbackId; }) && fallbackId !== keepPrimary ? fallbackId : "";
}
function renderSystemMembers() {
const payload = state.system.admin || {};
const membership = payload.membership || {};
const users = payload.users || [];
const userHtml = users.map(function (user) {
const admin = user.role === "admin";
const member = Boolean(user.membership_subscribed);
const identity = [admin ? "管理员" : "", member ? "会员有效" : "普通用户"].filter(Boolean).join(" · ");
const expiry = member
? (user.membership_expires_at ? "有效至 " + membershipDateLabel(user.membership_expires_at) : "永久有效")
: user.membership_status === "suspended"
? "会员已停用"
: "尚未开通";
return '<article class="m-card" data-admin-user="' + number(user.id) + '">' +
"<strong>" + escapeHtml(user.username) + "</strong>" +
'<p class="m-sys-lead">' + escapeHtml(identity) + " · 今日调用 " + number(user.used_today) + "</p>" +
'<p class="m-sys-lead">' + escapeHtml(expiry) + "</p>" +
formFieldHtml("状态", '<select data-member-status>' +
'<option value="inactive"' + (user.membership_status === "inactive" ? " selected" : "") + ">未开通</option>" +
'<option value="active"' + (user.membership_status === "active" ? " selected" : "") + ">有效</option>" +
'<option value="suspended"' + (user.membership_status === "suspended" ? " selected" : "") + ">停用</option>" +
"</select>", false) +
formFieldHtml("开通 / 续期时长", '<select data-member-duration><option value="">选择时长</option>' +
'<option value="1_month">1个月</option><option value="3_months">3个月</option>' +
'<option value="12_months">12个月</option><option value="3_years">3年</option>' +
'<option value="permanent">永久</option></select>', false) +
'<button class="m-btn-primary" type="button" data-system-save-member>应用</button>' +
"</article>";
}).join("") || '<div class="m-state"><p>暂无注册用户</p></div>';
const html =
'<div class="m-form-body" data-system-page="members">' +
'<div class="m-card"><strong>会员调用额度</strong><p class="m-sys-lead">每日自动重置</p></div>' +
formFieldHtml("会员每日智能分析上限", '<input id="m-sys-member-limit" type="number" min="1" max="1000" value="' + (number(membership.member_daily_limit) || 50) + '">', false) +
'<button class="m-btn-primary" type="button" data-system-save-limit>保存调用额度</button>' +
'<div class="m-card"><strong>会员账号</strong><p class="m-sys-lead">手动开通与续期</p></div>' +
userHtml +
"</div>";
systemFill(html);
}
function saveSystemBirth() {
const birthDate = (document.getElementById("m-sys-birth-date") || {}).value;
const birthTime = (document.getElementById("m-sys-birth-time") || {}).value;
if (!birthDate || !birthTime) {
showToast("请填写完整出生日期和时间");
return;
}
const button = document.querySelector("[data-system-save-birth]");
if (button) button.disabled = true;
global.MobileAPI.request("/api/account/birth-profile", "POST", {
birth_datetime: birthDate + "T" + birthTime,
gender: (document.getElementById("m-sys-birth-gender") || {}).value || "unspecified",
trade_date: todayString(),
}).then(function () {
showToast("个人命理资料已保存到当前账号");
loadSystem();
}).catch(function (error) {
showToast(error && error.message ? error.message : "个人命理资料保存失败");
}).then(function () {
if (button) button.disabled = false;
});
}
function deleteSystemBirth() {
openConfirmSheet("删除资料", "确定删除当前账号保存的个人命理资料吗?", {
danger: true,
confirmLabel: "删除",
onConfirm: function () {
global.MobileAPI.request("/api/account/birth-profile", "DELETE").then(function () {
closeSheet();
showToast("个人命理资料已删除");
loadSystem();
}).catch(function (error) {
showToast(error && error.message ? error.message : "删除失败");
});
}
});
}
function saveSystemPassword() {
const current = (document.getElementById("m-sys-password-current") || {}).value || "";
const next = (document.getElementById("m-sys-password-new") || {}).value || "";
const confirm = (document.getElementById("m-sys-password-confirm") || {}).value || "";
const button = document.querySelector("[data-system-save-password]");
if (button) button.disabled = true;
global.MobileAPI.request("/api/account/password", "POST", {
current_password: current,
new_password: next,
confirm_password: confirm,
}).then(function () {
const formIds = ["m-sys-password-current", "m-sys-password-new", "m-sys-password-confirm"];
formIds.forEach(function (id) {
const input = document.getElementById(id);
if (input) input.value = "";
});
showToast("密码已更新");
}).catch(function (error) {
showToast(error && error.message ? error.message : "密码更新失败");
}).then(function () {
if (button) button.disabled = false;
});
}
function switchSystemAccount() {
global.location.assign("/login/");
}
function logoutSystemAccount() {
openConfirmSheet("退出当前账号", "退出后需要重新登录。本机已记录的其他账号仍可直接切换。", {
danger: true,
confirmLabel: "退出",
onConfirm: function () {
global.MobileSession.logout().then(function () {
closeSheet();
global.MobileRouter.replace("#/auth");
}).catch(function (error) {
showToast(error && error.message ? error.message : "退出失败");
});
}
});
}
function saveSystemMarket() {
const button = document.querySelector("[data-system-save-market]");
if (button) button.disabled = true;
global.MobileAPI.request("/api/admin/settings", "POST", {
tushare_token: ((document.getElementById("m-sys-token") || {}).value || "").trim(),
ifind_refresh_token: ((document.getElementById("m-sys-ifind") || {}).value || "").trim(),
background_refresh_enabled: Boolean((document.getElementById("m-sys-bg-refresh") || {}).checked),
}).then(function () {
showToast("行情配置已保存");
loadSystem();
}).catch(function (error) {
showToast(error && error.message ? error.message : "系统配置保存失败");
}).then(function () {
if (button) button.disabled = false;
});
}
function saveSystemModels() {
const button = document.querySelector("[data-system-save-models]");
if (button) button.disabled = true;
global.MobileAPI.request("/api/admin/settings", "POST", {
models: collectSystemModelPool(),
primary_model_id: (document.getElementById("m-sys-primary-model") || {}).value || "",
fallback_model_id: (document.getElementById("m-sys-fallback-model") || {}).value || "",
}).then(function () {
showToast("模型池已保存");
loadSystem();
}).catch(function (error) {
showToast(error && error.message ? error.message : "模型池保存失败");
}).then(function () {
if (button) button.disabled = false;
});
}
function addSystemModel() {
const models = collectSystemModelPool();
const id = "model-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
models.push({ id: id, name: "模型 " + (models.length + 1), base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false });
const list = document.getElementById("m-sys-model-list");
if (list) list.innerHTML = renderModelPoolHtml(models);
updateSystemModelRoleOptions(id, (document.getElementById("m-sys-fallback-model") || {}).value || "");
}
function deleteSystemModel(row) {
if (!row) return;
const id = row.dataset.modelId;
const primary = (document.getElementById("m-sys-primary-model") || {}).value;
const fallback = (document.getElementById("m-sys-fallback-model") || {}).value;
if (id === primary || id === fallback) {
showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型");
return;
}
const models = collectSystemModelPool().filter(function (item) { return item.id !== id; });
const list = document.getElementById("m-sys-model-list");
if (list) list.innerHTML = renderModelPoolHtml(models);
updateSystemModelRoleOptions(primary, fallback);
}
function testSystemModel(row) {
if (!row) return;
const status = row.querySelector("[data-model-test-status]");
const button = row.querySelector("[data-system-test-model]");
const profile = collectSystemModelPool().find(function (item) { return item.id === row.dataset.modelId; }) || {};
if (button) button.disabled = true;
if (status) status.textContent = "连接中";
global.MobileAPI.request("/api/admin/settings/test", "POST", { model_id: row.dataset.modelId, profile: profile }).then(function (payload) {
if (status) status.textContent = "已连通 · " + number(payload.result && payload.result.latency_ms) + " ms";
}).catch(function (error) {
if (status) status.textContent = error && error.message ? error.message : "测试失败";
}).then(function () {
if (button) button.disabled = false;
});
}
function startSystemRefresh() {
const button = document.querySelector("[data-system-refresh]");
if (button) button.disabled = true;
global.MobileAPI.request("/api/admin/refresh", "POST", { trade_date: todayString() }).then(function (payload) {
showToast((payload && payload.message) || "后台刷新已提交");
}).catch(function (error) {
showToast(error && error.message ? error.message : "后台刷新启动失败");
}).then(function () {
if (button) button.disabled = false;
});
}
function startSystemBackfill() {
const button = document.querySelector("[data-system-backfill]");
if (button) button.disabled = true;
global.MobileAPI.request("/api/backfill", "POST", {
start_date: (document.getElementById("m-sys-backfill-start") || {}).value,
end_date: (document.getElementById("m-sys-backfill-end") || {}).value,
}).then(function (payload) {
const count = payload && payload.results ? payload.results.length : 0;
showToast("历史回补完成,共处理 " + count + " 个工作日");
loadSystem();
}).catch(function (error) {
showToast(error && error.message ? error.message : "回补失败");
}).then(function () {
if (button) button.disabled = false;
});
}
function saveSystemMemberLimit() {
const button = document.querySelector("[data-system-save-limit]");
if (button) button.disabled = true;
global.MobileAPI.request("/api/admin/settings", "POST", {
member_daily_limit: number((document.getElementById("m-sys-member-limit") || {}).value),
}).then(function () {
showToast("会员调用额度已保存");
loadSystem();
}).catch(function (error) {
showToast(error && error.message ? error.message : "会员调用额度保存失败");
}).then(function () {
if (button) button.disabled = false;
});
}
function saveSystemMember(card) {
if (!card) return;
const button = card.querySelector("[data-system-save-member]");
if (button) button.disabled = true;
global.MobileAPI.request("/api/admin/membership", "POST", {
user_id: card.dataset.adminUser,
status: (card.querySelector("[data-member-status]") || {}).value,
duration: (card.querySelector("[data-member-duration]") || {}).value,
}).then(function (payload) {
if (state.system.admin) state.system.admin.users = payload.users || [];
showToast("会员状态已更新");
renderSystemMembers();
}).catch(function (error) {
showToast(error && error.message ? error.message : "会员状态保存失败");
}).then(function () {
if (button) button.disabled = false;
});
}
/* ---------------------------------------------------------------- events */
function bindEvents() {
window.addEventListener("hashchange", closeSheet);
// 全屏表单页的返回:优先回到列表/查看态,而非离开路由(capture 阶段先于路由层返回)
document.getElementById("m-back").addEventListener("click", function (event) {
if (state.review && state.review.form) {
event.preventDefault();
event.stopPropagation();
closeForm();
}
}, true);
document.addEventListener("click", function (event) {
const dateBtn = event.target.closest("[data-action=date]");
if (dateBtn) { openDateSheet(); return; }
const retry = event.target.closest("[data-action=retry]");
if (retry) { reloadCurrent(); return; }
const sortHead = event.target.closest("[data-sort-key]");
if (sortHead) { toggleSort(sortHead.dataset.sortKey); return; }
const rangeTab = event.target.closest("[data-sentiment-range]");
if (rangeTab) {
state.sentimentRange = number(rangeTab.dataset.sentimentRange) || 20;
document.querySelectorAll("[data-sentiment-range]").forEach(function (tab) {
const active = number(tab.dataset.sentimentRange) === state.sentimentRange;
tab.classList.toggle("active", active);
tab.setAttribute("aria-pressed", String(active));
});
loadSentiment();
return;
}
const auctionDataset = event.target.closest("[data-action=auction-dataset]");
if (auctionDataset) {
state.auctionDataset = auctionDataset.dataset.dataset || "focus";
document.querySelectorAll("[data-action=auction-dataset]").forEach(function (tab) {
const active = tab.dataset.dataset === state.auctionDataset;
tab.classList.toggle("active", active);
tab.setAttribute("aria-selected", String(active));
});
renderAuctionTable();
return;
}
const dragonMode = event.target.closest("[data-action=dragon-mode]");
if (dragonMode) {
const mode = dragonMode.dataset.mode;
state.dragonViewMode = mode === "profiles" ? "profiles" : "daily";
if (mode === "profiles") loadDragonProfiles();
else if (state.dragon) renderDragon();
else loadDragon();
return;
}
const ladderStock = event.target.closest("[data-ladder-stock]");
if (ladderStock) { openDetailSheet(ladderStock.dataset.ladderStock); return; }
const ladderExpand = event.target.closest("[data-ladder-expand]");
if (ladderExpand) {
const level = number(ladderExpand.dataset.ladderExpand);
if (expandedLadder[level]) delete expandedLadder[level];
else expandedLadder[level] = true;
renderLadder();
return;
}
const rotationSector = event.target.closest("[data-rotation-sector]");
if (rotationSector) {
openRotationMembersSheet(rotationSector.dataset.rotationSector, rotationSector.dataset.rotationDate);
return;
}
const themeCode = event.target.closest("[data-theme-code]");
if (themeCode) { openThemeDetailSheet(themeCode.dataset.themeCode); return; }
const dragonTrader = event.target.closest("[data-dragon-trader]");
if (dragonTrader) { openDragonTraderSheet(dragonTrader.dataset.dragonTrader); return; }
const profileId = event.target.closest("[data-profile-id]");
if (profileId) { openProfileSheet(profileId.dataset.profileId); return; }
const watchBtn = event.target.closest("[data-action=watch]");
if (watchBtn) { toggleWatch(); return; }
const chartTab = event.target.closest("[data-chart-tab]");
if (chartTab) { switchChartTab(chartTab.dataset.chartTab); return; }
const sourceTab = event.target.closest("[data-action=source]");
if (sourceTab) {
state.popularitySource = sourceTab.dataset.source || "combined";
renderTableBody();
document.querySelectorAll(".m-source-tab").forEach(function (tab) {
const active = tab.dataset.source === state.popularitySource;
tab.classList.toggle("active", active);
tab.setAttribute("aria-selected", String(active));
});
return;
}
const closeBtn = event.target.closest("[data-sheet-close]");
if (closeBtn) { closeSheet(); return; }
const backdrop = event.target.closest("[data-sheet-backdrop]");
if (backdrop) { closeSheet(); return; }
const quick = event.target.closest("[data-quick-date]");
if (quick) { selectDate(quick.dataset.quickDate); return; }
const calNav = event.target.closest("[data-cal-nav]");
if (calNav) {
if (calNav.disabled) return;
const dir = calNav.dataset.calNav === "next" ? 1 : -1;
const monthIndex = state.calCursor.year * 12 + state.calCursor.month + dir;
state.calCursor = { year: Math.floor(monthIndex / 12), month: ((monthIndex % 12) + 12) % 12 };
renderDateSheetBody();
return;
}
const calCell = event.target.closest("[data-date]");
if (calCell) {
if (calCell.disabled) return;
selectDate(calCell.dataset.date);
return;
}
const systemAdminTab = event.target.closest("[data-system-admin-tab]");
if (systemAdminTab) {
state.system.adminTab = systemAdminTab.dataset.systemAdminTab === "models" ? "models" : "market";
renderSystemAdmin("system/admin");
return;
}
if (event.target.closest("[data-system-save-birth]")) { saveSystemBirth(); return; }
if (event.target.closest("[data-system-delete-birth]")) { deleteSystemBirth(); return; }
if (event.target.closest("[data-system-save-password]")) { saveSystemPassword(); return; }
if (event.target.closest("[data-system-switch]")) { switchSystemAccount(); return; }
if (event.target.closest("[data-system-logout]")) { logoutSystemAccount(); return; }
if (event.target.closest("[data-system-save-market]")) { saveSystemMarket(); return; }
if (event.target.closest("[data-system-save-models]")) { saveSystemModels(); return; }
if (event.target.closest("[data-system-add-model]")) { addSystemModel(); return; }
const deleteModel = event.target.closest("[data-system-delete-model]");
if (deleteModel) { deleteSystemModel(deleteModel.closest("[data-model-id]")); return; }
const testModel = event.target.closest("[data-system-test-model]");
if (testModel) { testSystemModel(testModel.closest("[data-model-id]")); return; }
if (event.target.closest("[data-system-refresh]")) { startSystemRefresh(); return; }
if (event.target.closest("[data-system-backfill]")) { startSystemBackfill(); return; }
if (event.target.closest("[data-system-save-limit]")) { saveSystemMemberLimit(); return; }
const saveMember = event.target.closest("[data-system-save-member]");
if (saveMember) { saveSystemMember(saveMember.closest("[data-admin-user]")); return; }
// 我的复盘:新增/编辑/删除/筛选/提交等操作
const reviewAddTrade = event.target.closest("[data-review-add-trade]");
if (reviewAddTrade) { openForm("trade"); return; }
const reviewAddNote = event.target.closest("[data-review-add-note]");
if (reviewAddNote) { openForm("note"); return; }
const reviewAddAlert = event.target.closest("[data-review-add-alert]");
if (reviewAddAlert) { openAlertAddDrawer(); return; }
const reviewReadAll = event.target.closest("[data-review-read-all]");
if (reviewReadAll) { markAllAlertsRead(); return; }
const reviewEditTrade = event.target.closest("[data-review-edit-trade]");
if (reviewEditTrade) {
const id = reviewEditTrade.dataset.tradeId;
const data = state.review.trades || { items: [] };
const item = (data.items || []).find(function (it) { return String(it.id) === String(id); });
closeSheet();
if (item) openForm("trade", { trade: item });
return;
}
const reviewDeleteTrade = event.target.closest("[data-review-delete-trade]");
if (reviewDeleteTrade) { deleteTrade(reviewDeleteTrade.dataset.tradeId); return; }
const reviewEditDaily = event.target.closest("[data-review-edit-daily]");
if (reviewEditDaily) {
const note = dailyNoteFor(state.requestedDate || todayString());
openForm("daily", { note: note });
return;
}
const reviewDeleteDaily = event.target.closest("[data-review-delete-daily]");
if (reviewDeleteDaily) { deleteDailyNote(reviewDeleteDaily.dataset.noteId); return; }
const dailyDateRow = event.target.closest("[data-daily-date]");
if (dailyDateRow) {
state.requestedDate = dailyDateRow.dataset.dailyDate;
updateHeader("每日复盘", state.requestedDate);
renderReviewDaily();
return;
}
const reviewDeleteNote = event.target.closest("[data-review-delete-note]");
if (reviewDeleteNote) { deleteNote(reviewDeleteNote.dataset.noteId); return; }
const noteCard = event.target.closest("[data-note-id]");
if (noteCard) { openNoteDetail(noteCard.dataset.noteId); return; }
const alertRead = event.target.closest("[data-review-alert-read]");
if (alertRead) { markAlertRead(alertRead.dataset.alertId); return; }
const alertDelete = event.target.closest("[data-review-alert-delete]");
if (alertDelete) { deleteAlert(alertDelete.dataset.alertId); return; }
const alertRow = event.target.closest(".m-alert-row[data-alert-id]");
if (alertRow) { openAlertDetail(alertRow.dataset.alertId); return; }
const alertFilter = event.target.closest("[data-review-alert-filter]");
if (alertFilter) {
state.review.alertStatus = alertFilter.dataset.reviewAlertFilter === "unread" ? "unread" : "all";
loadReviewAlerts();
return;
}
const reviewSubmit = event.target.closest("[data-review-submit]");
if (reviewSubmit) { submitForm(); return; }
const alertSave = event.target.closest("[data-alert-save]");
if (alertSave) { submitAlertAdd(); return; }
const alertStock = event.target.closest("[data-alert-stock]");
if (alertStock) { openDetailSheet(alertStock.dataset.code); return; }
const remarkEdit = event.target.closest("[data-remark-edit]");
if (remarkEdit) { openRemarkEditSheet(); return; }
const remarkSave = event.target.closest("[data-remark-save]");
if (remarkSave) { saveRemark(); return; }
const row = event.target.closest("[data-code]");
if (row) {
const interactive = event.target.closest("button, a, input, select, textarea");
if (!interactive) {
if (state.key === "review/trades" && row.dataset.tradeId) {
openTradeDetail(row.dataset.tradeId);
} else if (state.key === "tools/tracking" && row.dataset.trackId) {
openTrackingDetailSheet(row.dataset.code, row.dataset.trackId);
} else if (state.key === "tools/screener") {
openScreenerDetailSheet(row.dataset.code);
} else {
openDetailSheet(row.dataset.code);
}
}
}
// 智能选股:视图切换
const screenerView = event.target.closest("[data-screener-view]");
if (screenerView) {
const view = screenerView.dataset.screenerView;
if (view && view !== state.screener.view) {
state.screener.view = view;
state.sort = { key: "", dir: null };
renderScreener();
}
return;
}
// 智能选股:策略胶囊行(开抽屉)
const screenerOpen = event.target.closest("[data-screener-open-drawer]");
if (screenerOpen) { openScreenerStrategyDrawer(); return; }
// 智能选股:日期按钮
const screenerDate = event.target.closest("[data-screener-date]");
if (screenerDate) { openDateSheet(); return; }
// 策略跟踪:刷新按钮
const trackingRefresh = event.target.closest("[data-tracking-refresh]");
if (trackingRefresh) {
const icon = trackingRefresh.querySelector("svg");
if (icon) icon.classList.add("is-spinning");
refreshTracking();
return;
}
// 聊天工作台(复盘助手 / 问师):标题区操作位
const chatClear = event.target.closest("[data-chat-clear]");
if (chatClear) {
if (state.chat.page === "assistant/chat") {
openConfirmSheet("清空对话", "将删除当前账号的全部复盘助手对话历史,无法恢复。", {
danger: true,
confirmLabel: "清空",
onConfirm: clearAssistantMessages
});
} else if (state.chat.page === "tools/mentor") {
if (!state.chat.mentorId) { showToast("请先选择导师"); return; }
openConfirmSheet("清空当日对话", "将清空该导师今天的对话记录,无法恢复。", {
danger: true,
confirmLabel: "清空",
onConfirm: function () { clearMentorMessages(state.chat.mentorId, todayString()); }
});
}
return;
}
const chatMentors = event.target.closest("[data-chat-mentors]");
if (chatMentors) { openMentorDrawer(); return; }
const chatSend = event.target.closest("[data-chat-send]");
if (chatSend) {
if (state.chat.streaming) stopChatStream();
else submitChatMessage();
return;
}
const chatPreset = event.target.closest("[data-chat-preset]");
if (chatPreset) { sendChatMessage(chatPreset.dataset.chatPreset); return; }
const chatFollowup = event.target.closest("[data-chat-followup]");
if (chatFollowup) { sendChatMessage(chatFollowup.dataset.chatFollowup); return; }
const chatRemoveMentor = event.target.closest("[data-chat-remove-mentor]");
if (chatRemoveMentor) {
state.chat.mentorId = "";
state.chat.mentorName = "";
state.chat.mentorTagline = "";
state.chat.mentorGrade = "";
state.chat.followUps = [];
state.chat.messages = [];
renderChatStream();
renderChatMentorBar();
return;
}
});
}
/* ---------------------------------------------------------------- init */
bindEvents();
global.MobilePages = {
render: renderPage,
has: function (key) { return Boolean(pageConfig(key) || isComplexPage(key)); },
};
})(window);