- 新增 review 五页路由与渲染(COMPLEX_PAGES/LOADERS 注册 setupReviewPage/loadReview) - 自选股:颜色圆点+关注分列表、无排序例外、点行双图详情、移出自选本地同步 - 交易日志:五格汇总条、动作徽标、盈亏红绿、详情 Sheet 编辑/删除、全屏表单(13 字段) - 每日复盘:查看态三卡+历史复盘分组+日期切换、全屏编辑表单(三 textarea) - 个股笔记:按股票分组列表、详情抽屉、新增笔记页(股票搜索选择器) - 提醒中心:类型徽标/未读/到期规则、全部已读、新增提醒抽屉、关联股票详情 - 表单控件族(text/textarea/number/date/胶囊组/股票搜索)、字数统计、必填校验、提交条贴键盘 - 图标补充 check/check-check/star/scroll-text/calendar-check/sticky-note/bell - 全屏表单返回拦截(capture 阶段)与放弃编辑确认;零新依赖、仅动 m/ 前端 Co-authored-by: multica-agent <github@multica.ai>
5065 lines
219 KiB
JavaScript
5065 lines
219 KiB
JavaScript
(function (global) {
|
||
"use strict";
|
||
|
||
/* ---------------------------------------------------------------- helpers */
|
||
|
||
function number(value) {
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) ? parsed : 0;
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value == null ? "" : value).replace(/[&<>"']/g, function (ch) {
|
||
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch];
|
||
});
|
||
}
|
||
|
||
function formatNumber(value, digits) {
|
||
return new Intl.NumberFormat("zh-CN", {
|
||
minimumFractionDigits: digits,
|
||
maximumFractionDigits: digits,
|
||
}).format(number(value));
|
||
}
|
||
|
||
function changeClass(value) {
|
||
const n = number(value);
|
||
return n > 0 ? "up" : n < 0 ? "down" : "";
|
||
}
|
||
|
||
function streakLabel(streak) {
|
||
const value = Math.max(1, number(streak));
|
||
return value === 1 ? "首板" : value + "板";
|
||
}
|
||
|
||
function displayCompactDate(value) {
|
||
const text = String(value || "").replaceAll("-", "");
|
||
if (text.length !== 8) return value || "--";
|
||
return text.slice(0, 4) + "-" + text.slice(4, 6) + "-" + text.slice(6, 8);
|
||
}
|
||
|
||
function localDateString(value) {
|
||
const year = value.getFullYear();
|
||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||
const day = String(value.getDate()).padStart(2, "0");
|
||
return year + "-" + month + "-" + day;
|
||
}
|
||
|
||
function todayString() {
|
||
return localDateString(new Date());
|
||
}
|
||
|
||
function parseLocalDate(value) {
|
||
const parts = String(value || "").split("-").map(Number);
|
||
return new Date(parts[0], (parts[1] || 1) - 1, parts[2] || 1);
|
||
}
|
||
|
||
function addDays(value, delta) {
|
||
const date = parseLocalDate(value);
|
||
date.setDate(date.getDate() + delta);
|
||
return localDateString(date);
|
||
}
|
||
|
||
function previousWeekday(value) {
|
||
let date = parseLocalDate(value);
|
||
date.setDate(date.getDate() - 1);
|
||
while (date.getDay() === 0 || date.getDay() === 6) date.setDate(date.getDate() - 1);
|
||
return localDateString(date);
|
||
}
|
||
|
||
const ICONS = {
|
||
calendar: '<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"/>',
|
||
};
|
||
|
||
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,
|
||
},
|
||
};
|
||
|
||
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) + "§or=" + encodeURIComponent(sector);
|
||
global.MobileAPI.request(url).then(function (payload) {
|
||
if (seq !== state.seq) return;
|
||
state.rotationMembers = payload;
|
||
renderRotationMembersSheet(sector, payload);
|
||
}).catch(function (error) {
|
||
if (seq !== state.seq) return;
|
||
state.rotationMembers = { error: error.message || "成分股加载失败" };
|
||
renderRotationMembersSheet(sector, state.rotationMembers);
|
||
});
|
||
}
|
||
|
||
function openRotationMembersSheet(sector, date) {
|
||
openSheet(
|
||
'<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,
|
||
};
|
||
|
||
const COMPLEX_LOADERS = {
|
||
"market/sentiment": loadSentiment,
|
||
"market/ladder": loadLadder,
|
||
"market/rotation": loadRotation,
|
||
"market/auction": loadAuction,
|
||
"market/themes": loadThemes,
|
||
"market/dragon": loadDragon,
|
||
"tools/screener": loadScreener,
|
||
"tools/tracking": loadTracking,
|
||
"tools/mentor": loadChat,
|
||
"assistant/chat": loadChat,
|
||
"review/watchlist": loadReview,
|
||
"review/trades": loadReview,
|
||
"review/daily": loadReview,
|
||
"review/notes": loadReview,
|
||
"review/alerts": loadReview,
|
||
};
|
||
|
||
function isComplexPage(key) {
|
||
return Boolean(COMPLEX_PAGES[key]);
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- sheets */
|
||
|
||
function ensureSheetRoot() {
|
||
let root = document.getElementById("m-sheet-root");
|
||
if (!root) {
|
||
root = document.createElement("div");
|
||
root.id = "m-sheet-root";
|
||
root.className = "m-sheet-root";
|
||
document.getElementById("m-app").appendChild(root);
|
||
}
|
||
return root;
|
||
}
|
||
|
||
function openSheet(content, opts) {
|
||
const root = ensureSheetRoot();
|
||
sheetToken += 1;
|
||
root.innerHTML =
|
||
'<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 showToast(message) {
|
||
const root = document.getElementById("m-sheet-root");
|
||
if (!root) return;
|
||
let toast = document.getElementById("m-toast");
|
||
if (!toast) {
|
||
toast = document.createElement("div");
|
||
toast.id = "m-toast";
|
||
toast.className = "m-toast";
|
||
root.appendChild(toast);
|
||
}
|
||
toast.textContent = message;
|
||
toast.classList.remove("is-visible");
|
||
void toast.offsetWidth;
|
||
toast.classList.add("is-visible");
|
||
global.clearTimeout(toast._timer);
|
||
toast._timer = global.setTimeout(function () {
|
||
toast.classList.remove("is-visible");
|
||
}, 1500);
|
||
}
|
||
|
||
function renderDetail(payload) {
|
||
const body = document.getElementById("m-detail-sheet-body");
|
||
if (!body) return;
|
||
const stock = payload.stock || {};
|
||
const meta = payload.meta || {};
|
||
const name = stock.name && stock.name !== "--" ? stock.name : "--";
|
||
const price = number(stock.price);
|
||
const change = number(stock.change);
|
||
const industry = stock.industry && stock.industry !== "其他" ? stock.industry : (stock.sector || "其他");
|
||
|
||
state.detail = {
|
||
code: stock.code || "",
|
||
name: name === "--" ? "" : name,
|
||
sector: industry === "其他" ? "" : industry,
|
||
watchlist: Boolean(stock.watchlist),
|
||
tab: "intraday",
|
||
payload: payload,
|
||
};
|
||
|
||
const headName = document.querySelector(".m-detail-head-name");
|
||
const headCode = document.querySelector(".m-detail-head-code");
|
||
if (headName) headName.textContent = name;
|
||
if (headCode) headCode.textContent = stock.code || "";
|
||
updateStarButton();
|
||
|
||
const intradayOk = meta.intraday_status === "available";
|
||
const chartHtml = intradayOk ? intradayChart(payload) : emptyChart(meta.intraday_notice || "分时数据暂不可用");
|
||
|
||
body.innerHTML =
|
||
'<div class="m-detail-top">' +
|
||
'<div class="m-detail-quote">' +
|
||
'<strong class="m-detail-price ' + changeClass(change) + '">' + (price ? formatNumber(price, 2) : "--") + "</strong>" +
|
||
'<span class="m-detail-change ' + changeClass(change) + '">' + (change > 0 ? "+" : "") + formatNumber(change, 2) + "%</span>" +
|
||
"</div>" +
|
||
'<div class="m-detail-meta">' + escapeHtml(industry) + " · " + escapeHtml(displayCompactDate(meta.trade_date) || "--") + " 收盘</div>" +
|
||
"</div>" +
|
||
'<div class="m-detail-tabs" role="tablist" aria-label="行情图类型">' +
|
||
'<button class="m-detail-tab active" type="button" role="tab" aria-selected="true" data-chart-tab="intraday">分时</button>' +
|
||
'<button class="m-detail-tab" type="button" role="tab" aria-selected="false" data-chart-tab="daily">日线</button>' +
|
||
'<span class="m-detail-chart-caption" id="m-detail-caption">' + escapeHtml(chartCaptionHtml("intraday")) + "</span>" +
|
||
"</div>" +
|
||
'<div class="m-detail-chart" id="m-detail-chart">' + chartHtml + "</div>" +
|
||
'<div class="m-detail-note">点按图面可读数值 · 数据来源:本地行情</div>';
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- charts */
|
||
|
||
function emptyChart(message) {
|
||
return '<div class="m-chart-empty">' + escapeHtml(message || "数据暂不可用") + "</div>";
|
||
}
|
||
|
||
function svgAxisText(x, y, anchor, cls, content) {
|
||
return '<text x="' + x + '" y="' + y + '" text-anchor="' + anchor + '" class="m-chart-axis ' + cls + '">' + escapeHtml(content) + "</text>";
|
||
}
|
||
|
||
function dateMMDD(value) {
|
||
const s = String(value || "");
|
||
return s.length >= 10 ? s.slice(5, 10) : s;
|
||
}
|
||
|
||
function movingAverage(values, period) {
|
||
const out = [];
|
||
for (let i = 0; i < values.length; i += 1) {
|
||
if (i < period - 1) { out.push(null); continue; }
|
||
let sum = 0;
|
||
for (let j = i - period + 1; j <= i; j += 1) sum += values[j];
|
||
out.push(sum / period);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function maPath(ma, xf, yf, cls) {
|
||
const pts = [];
|
||
for (let i = 0; i < ma.length; i += 1) {
|
||
if (ma[i] == null) continue;
|
||
pts.push(xf(i).toFixed(1) + " " + yf(ma[i]).toFixed(1));
|
||
}
|
||
return pts.length > 1 ? '<polyline class="m-chart-ma ' + cls + '" points="' + pts.join(" ") + '"/>' : "";
|
||
}
|
||
|
||
function intradayChart(payload) {
|
||
const W = 360, H = 240, padL = 8, padR = 52, padT = 10, padB = 22;
|
||
const pw = W - padL - padR;
|
||
const ph = H - padT - padB;
|
||
const points = payload.intraday || [];
|
||
const meta = payload.meta || {};
|
||
const prevClose = number(meta.intraday_previous_close) || (points.length ? number(points[0].close) : 0);
|
||
|
||
if (!points.length || prevClose <= 0) {
|
||
return emptyChart(meta.intraday_notice || "分时数据暂不可用");
|
||
}
|
||
|
||
let maxPct = 2;
|
||
points.forEach(function (p) {
|
||
const cp = number(p.close);
|
||
const ap = number(p.average);
|
||
if (cp > 0) maxPct = Math.max(maxPct, Math.abs((cp - prevClose) / prevClose * 100));
|
||
if (ap > 0) maxPct = Math.max(maxPct, Math.abs((ap - prevClose) / prevClose * 100));
|
||
});
|
||
maxPct = Math.max(0.01, Math.ceil(maxPct * 100) / 100);
|
||
|
||
function x(i) {
|
||
return padL + (points.length <= 1 ? pw / 2 : i / (points.length - 1) * pw);
|
||
}
|
||
function y(close) {
|
||
const pct = (close - prevClose) / prevClose * 100;
|
||
return padT + (maxPct - pct) / (2 * maxPct) * ph;
|
||
}
|
||
|
||
const pricePath = points.map(function (p, i) {
|
||
return (i ? "L" : "M") + x(i).toFixed(1) + " " + y(number(p.close)).toFixed(1);
|
||
}).join(" ");
|
||
|
||
const avgPts = [];
|
||
points.forEach(function (p, i) {
|
||
const a = number(p.average);
|
||
if (a <= 0) return;
|
||
avgPts.push((avgPts.length ? "L" : "M") + x(i).toFixed(1) + " " + y(a).toFixed(1));
|
||
});
|
||
const avgPath = avgPts.join(" ");
|
||
|
||
const y0 = y(prevClose).toFixed(1);
|
||
const right = W - 4;
|
||
|
||
return '<svg viewBox="0 0 ' + W + " " + H + '" preserveAspectRatio="none" aria-hidden="true">' +
|
||
'<line class="m-chart-prev-close" x1="' + padL + '" y1="' + y0 + '" x2="' + (W - padR) + '" y2="' + y0 + '"/>' +
|
||
'<path class="m-chart-line m-chart-avg" d="' + avgPath + '"/>' +
|
||
'<path class="m-chart-line m-chart-price" d="' + pricePath + '"/>' +
|
||
svgAxisText(right, padT + 8, "end", "is-up", "+" + maxPct.toFixed(2) + "%") +
|
||
svgAxisText(right, padT + ph / 2 + 3, "end", "", "0.00%") +
|
||
svgAxisText(right, padT + ph, "end", "is-down", "-" + maxPct.toFixed(2) + "%") +
|
||
svgAxisText(padL, H - 6, "start", "", "09:30") +
|
||
svgAxisText(padL + pw / 2, H - 6, "middle", "", "11:30/13:00") +
|
||
svgAxisText(padL + pw, H - 6, "end", "", "15:00") +
|
||
"</svg>";
|
||
}
|
||
|
||
function dailyChart(payload) {
|
||
const W = 360, H = 240, padL = 8, padR = 52, padT = 10, padB = 22;
|
||
const pw = W - padL - padR;
|
||
const ph = H - padT - padB;
|
||
const prices = (payload.prices || []).slice(-48);
|
||
|
||
if (prices.length < 2) return emptyChart("日线数据暂不可用");
|
||
|
||
const closes = prices.map(function (b) { return number(b.close); });
|
||
const highs = prices.map(function (b) { return number(b.high); });
|
||
const lows = prices.map(function (b) { return number(b.low); });
|
||
let max = Math.max.apply(null, highs);
|
||
let min = Math.min.apply(null, lows);
|
||
if (max - min <= 0) { max += 1; min -= 1; }
|
||
const padRange = (max - min) * 0.08;
|
||
max += padRange;
|
||
min -= padRange;
|
||
|
||
function x(i) { return padL + (i + 0.5) / prices.length * pw; }
|
||
function y(v) { return padT + (max - v) / (max - min) * ph; }
|
||
|
||
const candleW = Math.max(2, Math.min(7, pw / prices.length * 0.7));
|
||
const candles = prices.map(function (b, i) {
|
||
const up = number(b.close) >= number(b.open);
|
||
const cls = "m-chart-candle" + (up ? " is-up" : " is-down");
|
||
const cx = x(i).toFixed(1);
|
||
const bodyTop = y(Math.max(number(b.open), number(b.close))).toFixed(1);
|
||
const bodyH = Math.max(1, Math.abs(y(number(b.open)) - y(number(b.close)))).toFixed(1);
|
||
const bx = (x(i) - candleW / 2).toFixed(1);
|
||
return '<line class="' + cls + '" x1="' + cx + '" y1="' + y(number(b.high)).toFixed(1) + '" x2="' + cx + '" y2="' + y(number(b.low)).toFixed(1) + '"/>' +
|
||
'<rect class="' + cls + '" x="' + bx + '" y="' + bodyTop + '" width="' + candleW.toFixed(1) + '" height="' + bodyH + '"/>';
|
||
}).join("");
|
||
|
||
const ma5 = movingAverage(closes, 5);
|
||
const ma10 = movingAverage(closes, 10);
|
||
|
||
const firstDate = prices[0].trade_date;
|
||
const midDate = prices[Math.floor(prices.length / 2)].trade_date;
|
||
const lastDate = prices[prices.length - 1].trade_date;
|
||
const right = W - 4;
|
||
|
||
return '<div class="m-chart-legend" aria-hidden="true">' +
|
||
'<span class="m-chart-legend-ma5">MA5</span>' +
|
||
'<span class="m-chart-legend-ma10">MA10</span>' +
|
||
"</div>" +
|
||
'<svg viewBox="0 0 ' + W + " " + H + '" preserveAspectRatio="none" aria-hidden="true">' +
|
||
candles +
|
||
maPath(ma5, x, y, "is-ma5") +
|
||
maPath(ma10, x, y, "is-ma10") +
|
||
svgAxisText(right, padT + 8, "end", "is-up", formatNumber(max, 2)) +
|
||
svgAxisText(right, padT + ph, "end", "is-down", formatNumber(min, 2)) +
|
||
svgAxisText(padL, H - 6, "start", "", dateMMDD(firstDate)) +
|
||
svgAxisText(padL + pw / 2, H - 6, "middle", "", dateMMDD(midDate)) +
|
||
svgAxisText(padL + pw, H - 6, "end", "", dateMMDD(lastDate)) +
|
||
"</svg>";
|
||
}
|
||
|
||
/* ============================================================== 我的复盘(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-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) {
|
||
state.review.form = { kind: kind, opts: opts || {}, action: "buy", emotion: "calm", stock: null };
|
||
state.review.dirty = false;
|
||
const title = kind === "trade" ? ((opts && opts.trade) ? "编辑交易" : "新增交易") : kind === "daily" ? "写复盘" : "新增笔记";
|
||
global.MobileRouter.updateHeader({ title: title, back: true, actions: "" });
|
||
document.getElementById("m-view").classList.add("m-view-feature");
|
||
document.getElementById("m-view").innerHTML = renderForm();
|
||
bindForm();
|
||
}
|
||
|
||
function closeForm() {
|
||
if (state.review.dirty) {
|
||
openConfirmSheet("放弃本次编辑?", "返回后已填写的内容将不会保存。", {
|
||
confirmLabel: "放弃",
|
||
onConfirm: function () { exitForm(); }
|
||
});
|
||
return;
|
||
}
|
||
exitForm();
|
||
}
|
||
|
||
function exitForm() {
|
||
const key = state.key;
|
||
const wasDaily = state.review.subKey === "daily";
|
||
const keepDate = state.requestedDate;
|
||
state.review.form = null;
|
||
state.review.dirty = false;
|
||
setupReviewPage(key);
|
||
if (wasDaily) state.requestedDate = keepDate;
|
||
loadReview();
|
||
}
|
||
|
||
function formFieldHtml(label, controlHtml, required, hintHtml) {
|
||
return '<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>";
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- events */
|
||
|
||
function bindEvents() {
|
||
window.addEventListener("hashchange", closeSheet);
|
||
|
||
// 全屏表单页的返回:优先回到列表/查看态,而非离开路由(capture 阶段先于路由层返回)
|
||
document.getElementById("m-back").addEventListener("click", function (event) {
|
||
if (state.review && state.review.form) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
closeForm();
|
||
}
|
||
}, true);
|
||
|
||
document.addEventListener("click", function (event) {
|
||
const dateBtn = event.target.closest("[data-action=date]");
|
||
if (dateBtn) { openDateSheet(); return; }
|
||
|
||
const retry = event.target.closest("[data-action=retry]");
|
||
if (retry) { reloadCurrent(); return; }
|
||
|
||
const sortHead = event.target.closest("[data-sort-key]");
|
||
if (sortHead) { toggleSort(sortHead.dataset.sortKey); return; }
|
||
|
||
const rangeTab = event.target.closest("[data-sentiment-range]");
|
||
if (rangeTab) {
|
||
state.sentimentRange = number(rangeTab.dataset.sentimentRange) || 20;
|
||
document.querySelectorAll("[data-sentiment-range]").forEach(function (tab) {
|
||
const active = number(tab.dataset.sentimentRange) === state.sentimentRange;
|
||
tab.classList.toggle("active", active);
|
||
tab.setAttribute("aria-pressed", String(active));
|
||
});
|
||
loadSentiment();
|
||
return;
|
||
}
|
||
|
||
const auctionDataset = event.target.closest("[data-action=auction-dataset]");
|
||
if (auctionDataset) {
|
||
state.auctionDataset = auctionDataset.dataset.dataset || "focus";
|
||
document.querySelectorAll("[data-action=auction-dataset]").forEach(function (tab) {
|
||
const active = tab.dataset.dataset === state.auctionDataset;
|
||
tab.classList.toggle("active", active);
|
||
tab.setAttribute("aria-selected", String(active));
|
||
});
|
||
renderAuctionTable();
|
||
return;
|
||
}
|
||
|
||
const dragonMode = event.target.closest("[data-action=dragon-mode]");
|
||
if (dragonMode) {
|
||
const mode = dragonMode.dataset.mode;
|
||
state.dragonViewMode = mode === "profiles" ? "profiles" : "daily";
|
||
if (mode === "profiles") loadDragonProfiles();
|
||
else if (state.dragon) renderDragon();
|
||
else loadDragon();
|
||
return;
|
||
}
|
||
|
||
const ladderStock = event.target.closest("[data-ladder-stock]");
|
||
if (ladderStock) { openDetailSheet(ladderStock.dataset.ladderStock); return; }
|
||
|
||
const ladderExpand = event.target.closest("[data-ladder-expand]");
|
||
if (ladderExpand) {
|
||
const level = number(ladderExpand.dataset.ladderExpand);
|
||
if (expandedLadder[level]) delete expandedLadder[level];
|
||
else expandedLadder[level] = true;
|
||
renderLadder();
|
||
return;
|
||
}
|
||
|
||
const rotationSector = event.target.closest("[data-rotation-sector]");
|
||
if (rotationSector) {
|
||
openRotationMembersSheet(rotationSector.dataset.rotationSector, rotationSector.dataset.rotationDate);
|
||
return;
|
||
}
|
||
|
||
const themeCode = event.target.closest("[data-theme-code]");
|
||
if (themeCode) { openThemeDetailSheet(themeCode.dataset.themeCode); return; }
|
||
|
||
const dragonTrader = event.target.closest("[data-dragon-trader]");
|
||
if (dragonTrader) { openDragonTraderSheet(dragonTrader.dataset.dragonTrader); return; }
|
||
|
||
const profileId = event.target.closest("[data-profile-id]");
|
||
if (profileId) { openProfileSheet(profileId.dataset.profileId); return; }
|
||
|
||
const watchBtn = event.target.closest("[data-action=watch]");
|
||
if (watchBtn) { toggleWatch(); return; }
|
||
|
||
const chartTab = event.target.closest("[data-chart-tab]");
|
||
if (chartTab) { switchChartTab(chartTab.dataset.chartTab); return; }
|
||
|
||
const sourceTab = event.target.closest("[data-action=source]");
|
||
if (sourceTab) {
|
||
state.popularitySource = sourceTab.dataset.source || "combined";
|
||
renderTableBody();
|
||
document.querySelectorAll(".m-source-tab").forEach(function (tab) {
|
||
const active = tab.dataset.source === state.popularitySource;
|
||
tab.classList.toggle("active", active);
|
||
tab.setAttribute("aria-selected", String(active));
|
||
});
|
||
return;
|
||
}
|
||
|
||
const closeBtn = event.target.closest("[data-sheet-close]");
|
||
if (closeBtn) { closeSheet(); return; }
|
||
|
||
const backdrop = event.target.closest("[data-sheet-backdrop]");
|
||
if (backdrop) { closeSheet(); return; }
|
||
|
||
const quick = event.target.closest("[data-quick-date]");
|
||
if (quick) { selectDate(quick.dataset.quickDate); return; }
|
||
|
||
const calNav = event.target.closest("[data-cal-nav]");
|
||
if (calNav) {
|
||
if (calNav.disabled) return;
|
||
const dir = calNav.dataset.calNav === "next" ? 1 : -1;
|
||
const monthIndex = state.calCursor.year * 12 + state.calCursor.month + dir;
|
||
state.calCursor = { year: Math.floor(monthIndex / 12), month: ((monthIndex % 12) + 12) % 12 };
|
||
renderDateSheetBody();
|
||
return;
|
||
}
|
||
|
||
const calCell = event.target.closest("[data-date]");
|
||
if (calCell) {
|
||
if (calCell.disabled) return;
|
||
selectDate(calCell.dataset.date);
|
||
return;
|
||
}
|
||
|
||
// 我的复盘:新增/编辑/删除/筛选/提交等操作
|
||
const reviewAddTrade = event.target.closest("[data-review-add-trade]");
|
||
if (reviewAddTrade) { openForm("trade"); return; }
|
||
const reviewAddNote = event.target.closest("[data-review-add-note]");
|
||
if (reviewAddNote) { openForm("note"); return; }
|
||
const reviewAddAlert = event.target.closest("[data-review-add-alert]");
|
||
if (reviewAddAlert) { openAlertAddDrawer(); return; }
|
||
const reviewReadAll = event.target.closest("[data-review-read-all]");
|
||
if (reviewReadAll) { markAllAlertsRead(); return; }
|
||
const reviewEditTrade = event.target.closest("[data-review-edit-trade]");
|
||
if (reviewEditTrade) {
|
||
const id = reviewEditTrade.dataset.tradeId;
|
||
const data = state.review.trades || { items: [] };
|
||
const item = (data.items || []).find(function (it) { return String(it.id) === String(id); });
|
||
closeSheet();
|
||
if (item) openForm("trade", { trade: item });
|
||
return;
|
||
}
|
||
const reviewDeleteTrade = event.target.closest("[data-review-delete-trade]");
|
||
if (reviewDeleteTrade) { deleteTrade(reviewDeleteTrade.dataset.tradeId); return; }
|
||
const reviewEditDaily = event.target.closest("[data-review-edit-daily]");
|
||
if (reviewEditDaily) {
|
||
const note = dailyNoteFor(state.requestedDate || todayString());
|
||
openForm("daily", { note: note });
|
||
return;
|
||
}
|
||
const reviewDeleteDaily = event.target.closest("[data-review-delete-daily]");
|
||
if (reviewDeleteDaily) { deleteDailyNote(reviewDeleteDaily.dataset.noteId); return; }
|
||
const dailyDateRow = event.target.closest("[data-daily-date]");
|
||
if (dailyDateRow) {
|
||
state.requestedDate = dailyDateRow.dataset.dailyDate;
|
||
updateHeader("每日复盘", state.requestedDate);
|
||
renderReviewDaily();
|
||
return;
|
||
}
|
||
const reviewDeleteNote = event.target.closest("[data-review-delete-note]");
|
||
if (reviewDeleteNote) { deleteNote(reviewDeleteNote.dataset.noteId); return; }
|
||
const noteCard = event.target.closest("[data-note-id]");
|
||
if (noteCard) { openNoteDetail(noteCard.dataset.noteId); return; }
|
||
const alertRow = event.target.closest("[data-alert-id]");
|
||
if (alertRow) { openAlertDetail(alertRow.dataset.alertId); return; }
|
||
const alertRead = event.target.closest("[data-review-alert-read]");
|
||
if (alertRead) { markAlertRead(alertRead.dataset.alertId); return; }
|
||
const alertDelete = event.target.closest("[data-review-alert-delete]");
|
||
if (alertDelete) { deleteAlert(alertDelete.dataset.alertId); return; }
|
||
const alertFilter = event.target.closest("[data-review-alert-filter]");
|
||
if (alertFilter) {
|
||
state.review.alertStatus = alertFilter.dataset.reviewAlertFilter === "unread" ? "unread" : "all";
|
||
loadReviewAlerts();
|
||
return;
|
||
}
|
||
const reviewSubmit = event.target.closest("[data-review-submit]");
|
||
if (reviewSubmit) { submitForm(); return; }
|
||
const alertSave = event.target.closest("[data-alert-save]");
|
||
if (alertSave) { submitAlertAdd(); return; }
|
||
const alertStock = event.target.closest("[data-alert-stock]");
|
||
if (alertStock) { openDetailSheet(alertStock.dataset.code); return; }
|
||
|
||
const row = event.target.closest("[data-code]");
|
||
if (row) {
|
||
const interactive = event.target.closest("button, a, input, select, textarea");
|
||
if (!interactive) {
|
||
if (state.key === "review/trades" && row.dataset.tradeId) {
|
||
openTradeDetail(row.dataset.tradeId);
|
||
} else if (state.key === "tools/tracking" && row.dataset.trackId) {
|
||
openTrackingDetailSheet(row.dataset.code, row.dataset.trackId);
|
||
} else if (state.key === "tools/screener") {
|
||
openScreenerDetailSheet(row.dataset.code);
|
||
} else {
|
||
openDetailSheet(row.dataset.code);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 智能选股:视图切换
|
||
const screenerView = event.target.closest("[data-screener-view]");
|
||
if (screenerView) {
|
||
const view = screenerView.dataset.screenerView;
|
||
if (view && view !== state.screener.view) {
|
||
state.screener.view = view;
|
||
state.sort = { key: "", dir: null };
|
||
renderScreener();
|
||
}
|
||
return;
|
||
}
|
||
// 智能选股:策略胶囊行(开抽屉)
|
||
const screenerOpen = event.target.closest("[data-screener-open-drawer]");
|
||
if (screenerOpen) { openScreenerStrategyDrawer(); return; }
|
||
// 智能选股:日期按钮
|
||
const screenerDate = event.target.closest("[data-screener-date]");
|
||
if (screenerDate) { openDateSheet(); return; }
|
||
// 策略跟踪:刷新按钮
|
||
const trackingRefresh = event.target.closest("[data-tracking-refresh]");
|
||
if (trackingRefresh) {
|
||
const icon = trackingRefresh.querySelector("svg");
|
||
if (icon) icon.classList.add("is-spinning");
|
||
refreshTracking();
|
||
return;
|
||
}
|
||
|
||
// 聊天工作台(复盘助手 / 问师):标题区操作位
|
||
const chatClear = event.target.closest("[data-chat-clear]");
|
||
if (chatClear) {
|
||
if (state.chat.page === "assistant/chat") {
|
||
openConfirmSheet("清空对话", "将删除当前账号的全部复盘助手对话历史,无法恢复。", {
|
||
danger: true,
|
||
confirmLabel: "清空",
|
||
onConfirm: clearAssistantMessages
|
||
});
|
||
} else if (state.chat.page === "tools/mentor") {
|
||
if (!state.chat.mentorId) { showToast("请先选择导师"); return; }
|
||
openConfirmSheet("清空当日对话", "将清空该导师今天的对话记录,无法恢复。", {
|
||
danger: true,
|
||
confirmLabel: "清空",
|
||
onConfirm: function () { clearMentorMessages(state.chat.mentorId, todayString()); }
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
const chatMentors = event.target.closest("[data-chat-mentors]");
|
||
if (chatMentors) { openMentorDrawer(); return; }
|
||
const chatSend = event.target.closest("[data-chat-send]");
|
||
if (chatSend) {
|
||
if (state.chat.streaming) stopChatStream();
|
||
else submitChatMessage();
|
||
return;
|
||
}
|
||
const chatPreset = event.target.closest("[data-chat-preset]");
|
||
if (chatPreset) { sendChatMessage(chatPreset.dataset.chatPreset); return; }
|
||
const chatFollowup = event.target.closest("[data-chat-followup]");
|
||
if (chatFollowup) { sendChatMessage(chatFollowup.dataset.chatFollowup); return; }
|
||
const chatRemoveMentor = event.target.closest("[data-chat-remove-mentor]");
|
||
if (chatRemoveMentor) {
|
||
state.chat.mentorId = "";
|
||
state.chat.mentorName = "";
|
||
state.chat.mentorTagline = "";
|
||
state.chat.mentorGrade = "";
|
||
state.chat.followUps = [];
|
||
state.chat.messages = [];
|
||
renderChatStream();
|
||
renderChatMentorBar();
|
||
return;
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- init */
|
||
|
||
bindEvents();
|
||
|
||
global.MobilePages = {
|
||
render: renderPage,
|
||
has: function (key) { return Boolean(pageConfig(key) || isComplexPage(key)); },
|
||
};
|
||
})(window);
|