diff --git a/app/frontend/m/css/features.css b/app/frontend/m/css/features.css
index 943bc86..9c979d0 100644
--- a/app/frontend/m/css/features.css
+++ b/app/frontend/m/css/features.css
@@ -2139,10 +2139,573 @@
.m-tag--dir-down { background: var(--market-down-soft); color: var(--market-down); }
.m-tag--dir-flat { background: var(--surface-muted); color: var(--text-secondary); }
+/* ===== 第四批(P3):智能选股 / 策略跟踪 / 复盘助手 / 问师 ===== */
+
+/* 日期按钮右侧文本 */
+.m-date-btn .m-date-btn-label {
+ font-size: var(--font-size-caption);
+ font-variant-numeric: tabular-nums;
+}
+
+/* 通用:旋转动画(用于刷新按钮/等待 spinner) */
+@keyframes m-spin {
+ to { transform: rotate(360deg); }
+}
+.is-spinning { animation: m-spin 800ms linear infinite; transform-origin: 50% 50%; }
+
+/* ============== 聊天工作台(复盘助手 + 问师)============== */
+.m-chat-page {
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - var(--mobile-header-height) - var(--mobile-tabbar-height) - var(--mobile-safe-bottom));
+ height: calc(100dvh - var(--mobile-header-height) - var(--mobile-tabbar-height) - var(--mobile-safe-bottom));
+ background: var(--canvas);
+ overflow: hidden;
+}
+
+.m-chat-stream {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ overscroll-behavior: contain;
+ padding: 12px 12px 4px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.m-chat-msg {
+ display: flex;
+ max-width: 82%;
+ margin-top: 8px;
+ word-break: break-word;
+ white-space: pre-wrap;
+}
+.m-chat-msg:first-child { margin-top: 12px; }
+
+.m-chat-msg--user { align-self: flex-end; }
+.m-chat-msg--ai { align-self: flex-start; }
+
+.m-chat-bubble {
+ padding: 10px 12px;
+ border-radius: 12px;
+ font-size: 14px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+}
+.m-chat-msg--user .m-chat-bubble {
+ background: var(--action);
+ color: var(--text-inverse);
+ border-top-right-radius: 4px;
+}
+.m-chat-msg--ai .m-chat-bubble {
+ background: var(--surface);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+ border-top-left-radius: 4px;
+}
+.m-chat-bubble p { margin: 0 0 6px; white-space: normal; }
+.m-chat-bubble p:last-child { margin-bottom: 0; }
+.m-chat-bubble ul { margin: 0; padding-left: 16px; }
+.m-chat-bubble li { margin: 2px 0; }
+.m-chat-bubble h4 { margin: 6px 0 4px; font-size: 14px; font-weight: 600; }
+.m-chat-bubble blockquote {
+ margin: 4px 0;
+ padding-left: 8px;
+ border-left: 3px solid var(--border-strong);
+ color: var(--text-secondary);
+}
+
+.m-chat-bubble--error {
+ background: var(--warning-soft);
+ border-color: var(--warning-soft);
+ color: var(--text-primary);
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+}
+.m-chat-retry {
+ appearance: none;
+ border: none;
+ background: transparent;
+ color: var(--action);
+ font-size: 13px;
+ padding: 0;
+ cursor: pointer;
+ font-weight: 600;
+}
+
+.m-chat-stamp {
+ align-self: center;
+ font-size: 11.5px;
+ color: var(--text-tertiary);
+ margin: 12px 0 4px;
+}
+
+.m-chat-skeleton {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 4px 0;
+}
+.m-chat-bubble-skel {
+ height: 44px;
+ background: var(--surface-muted);
+ border-radius: 12px;
+ animation: m-skeleton 1.2s ease-in-out infinite;
+}
+.m-chat-bubble-skel--ai { align-self: flex-start; }
+.m-chat-bubble-skel--user { background: var(--action-soft); }
+
+@keyframes m-skeleton {
+ 0%, 100% { opacity: 0.55; }
+ 50% { opacity: 1; }
+}
+
+/* 流式光标 */
+.m-chat-cursor {
+ display: inline-block;
+ width: 8px;
+ height: 16px;
+ background: var(--action);
+ vertical-align: text-bottom;
+ margin-left: 2px;
+ animation: m-cursor-blink 800ms steps(1) infinite;
+}
+@keyframes m-cursor-blink {
+ 0%, 50% { opacity: 1; }
+ 50.01%, 100% { opacity: 0; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .m-chat-cursor { animation: none; }
+ .m-chat-typing i { animation: none; }
+}
+
+/* 等待三点 */
+.m-chat-typing {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+.m-chat-typing i {
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ background: var(--text-tertiary);
+ border-radius: 50%;
+ animation: m-typing 1.2s ease-in-out infinite;
+}
+.m-chat-typing i:nth-child(2) { animation-delay: 0.15s; }
+.m-chat-typing i:nth-child(3) { animation-delay: 0.3s; }
+@keyframes m-typing {
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
+ 30% { transform: translateY(-3px); opacity: 1; }
+}
+
+/* 引导问题 / 继续追问胶囊 */
+.m-chat-presets {
+ flex: 0 0 auto;
+ padding: 4px 12px 0;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+}
+.m-chat-presets::-webkit-scrollbar { display: none; }
+.m-chat-preset-list {
+ display: inline-flex;
+ gap: 8px;
+ padding-bottom: 4px;
+ white-space: nowrap;
+}
+.m-chat-preset-list .m-source-tab {
+ border: 1px solid var(--border-strong);
+ background: var(--surface);
+ color: var(--text-secondary);
+ height: 32px;
+ border-radius: 999px;
+ padding: 0 14px;
+ font-size: 13px;
+ font-weight: 400;
+ display: inline-flex;
+ align-items: center;
+}
+
+.m-chat-followup-row {
+ margin-top: 8px;
+ align-self: flex-start;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ max-width: 82%;
+}
+.m-chat-followup-row .m-source-tab {
+ border: 1px solid var(--border-strong);
+ background: var(--surface);
+ color: var(--text-secondary);
+ height: 32px;
+ border-radius: 999px;
+ padding: 0 14px;
+ font-size: 13px;
+ font-weight: 400;
+ display: inline-flex;
+ align-items: center;
+}
+
+.m-chat-empty-hint {
+ margin-top: 8px;
+ font-size: 12.5px;
+ color: var(--text-tertiary);
+ align-self: center;
+}
+
+/* 输入区 */
+.m-chat-input-bar {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: flex-end;
+ gap: 8px;
+ padding: 8px 12px calc(8px + var(--mobile-safe-bottom));
+ background: var(--surface);
+ border-top: 1px solid var(--border);
+ /* 键盘弹起时由 --m-keyboard-inset 推上 */
+ transform: translateY(calc(-1 * var(--m-keyboard-inset, 0px)));
+ padding-bottom: calc(8px + var(--mobile-safe-bottom) + var(--m-keyboard-inset, 0px));
+}
+
+.m-chat-input-wrap {
+ flex: 1 1 auto;
+ background: var(--surface-muted);
+ border-radius: 12px;
+ padding: 4px 12px;
+ display: flex;
+ align-items: center;
+}
+.m-chat-input {
+ width: 100%;
+ min-height: 36px;
+ max-height: 116px;
+ resize: none;
+ border: none;
+ background: transparent;
+ color: var(--text-primary);
+ font-size: 14px; /* 防止 iOS 聚焦自动放大 */
+ line-height: 1.5;
+ font-family: inherit;
+ outline: none;
+ padding: 8px 0;
+}
+.m-chat-input::placeholder { color: var(--text-tertiary); }
+
+.m-chat-send {
+ flex: 0 0 44px;
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ border: none;
+ background: var(--action);
+ color: var(--text-inverse);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+ transition: transform var(--motion-press) var(--ease-press),
+ background-color var(--motion-press-release) var(--ease-press),
+ opacity var(--motion-press-release) var(--ease-press);
+}
+.m-chat-send:active { transform: scale(0.94); }
+.m-chat-send:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+.m-chat-send--stop {
+ background: var(--surface-muted);
+ color: var(--text-secondary);
+}
+.m-chat-send--stop svg { width: 14px; height: 14px; fill: currentColor; stroke: currentColor; }
+
+/* 导师胶囊条(消息区顶部) */
+.m-chat-mentor-bar {
+ flex: 0 0 auto;
+ padding: 8px 12px 0;
+ display: flex;
+ gap: 8px;
+}
+.m-mentor-pill {
+ appearance: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 32px;
+ padding: 0 12px;
+ border-radius: 999px;
+ background: var(--action-soft);
+ color: var(--action);
+ border: none;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+}
+.m-mentor-pill:active { transform: scale(0.97); }
+.m-mentor-pill-close {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ margin-left: 2px;
+ border-radius: 50%;
+ font-size: 14px;
+ line-height: 1;
+ color: var(--action);
+ background: transparent;
+ cursor: pointer;
+}
+.m-mentor-grade {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ margin-left: 6px;
+}
+.m-mentor-grade--a { background: var(--warning-soft); color: var(--warning); }
+.m-mentor-grade--b { background: var(--action-soft); color: var(--action); }
+.m-mentor-grade--c { background: var(--surface-muted); color: var(--text-secondary); }
+
+/* ============== 导师抽屉 ============== */
+.m-mentor-drawer-body { padding-bottom: 16px; }
+.m-mentor-drawer-search {
+ position: relative;
+ margin: 0 0 12px;
+}
+.m-mentor-drawer-search .m-form-field,
+.m-mentor-drawer-search.m-form-field {
+ padding-left: 36px;
+}
+.m-mentor-drawer-search-icon {
+ position: absolute;
+ left: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--text-tertiary);
+ display: inline-flex;
+ align-items: center;
+}
+.m-mentor-drawer-list {
+ display: flex;
+ flex-direction: column;
+}
+.m-mentor-row {
+ appearance: none;
+ background: var(--surface);
+ border: none;
+ border-bottom: 1px solid var(--border);
+ padding: 12px 4px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 64px;
+ text-align: left;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+}
+.m-mentor-row:active { background: var(--surface-hover); }
+.m-mentor-row.is-current { background: var(--surface-selected); }
+.m-mentor-row-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
+.m-mentor-row-name { font-size: 15px; font-weight: 600; color: var(--text-primary); }
+.m-mentor-row-tag { font-size: 12px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.m-mentor-row-grade { margin-left: auto; }
+.m-mentor-row-check { color: var(--action); display: inline-flex; }
+
+/* ============== 智能选股 ============== */
+.m-screener-stage {
+ margin: 12px 12px 8px;
+ padding: 12px;
+ border-radius: 8px;
+ background: var(--surface);
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.m-screener-stage-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+.m-screener-stage-label { font-size: 12px; color: var(--text-secondary); }
+.m-screener-stage-time { font-size: 11.5px; color: var(--text-tertiary); }
+.m-screener-stage-name { font-size: 15px; font-weight: 600; color: var(--action); }
+.m-screener-stage-conf { font-size: 12px; color: var(--text-tertiary); margin-left: 8px; }
+.m-screener-stage-reason {
+ margin: 4px 0 0;
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.5;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.m-screener-views {
+ margin: 0 12px 8px;
+ flex-wrap: wrap;
+}
+
+.m-screener-strategy {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0 12px 8px;
+ padding: 0 12px;
+ height: 44px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ appearance: none;
+ text-align: left;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+ color: var(--text-primary);
+}
+.m-screener-strategy:active { background: var(--surface-hover); }
+.m-screener-strategy-icon { color: var(--action); display: inline-flex; }
+.m-screener-strategy-name { font-size: 14px; font-weight: 600; }
+.m-screener-strategy-status { font-size: 12px; color: var(--text-tertiary); margin-left: auto; }
+.m-screener-strategy-chevron { color: var(--text-tertiary); display: inline-flex; }
+
+.m-screener-foot {
+ margin: 12px 12px 24px;
+ font-size: 11.5px;
+ color: var(--text-tertiary);
+ line-height: 1.5;
+}
+
+.m-screener-drawer-search {
+ position: relative;
+ margin-bottom: 12px;
+}
+.m-screener-drawer-search .m-form-field,
+.m-screener-drawer-search.m-form-field {
+ padding-left: 36px;
+}
+.m-screener-drawer-search-icon {
+ position: absolute;
+ left: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--text-tertiary);
+ display: inline-flex;
+ align-items: center;
+}
+.m-screener-drawer-cats {
+ margin-bottom: 8px;
+ flex-wrap: wrap;
+}
+.m-screener-drawer-list {
+ display: flex;
+ flex-direction: column;
+}
+.m-screener-drawer-row {
+ appearance: none;
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid var(--border);
+ padding: 12px 4px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 56px;
+ text-align: left;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+ color: var(--text-primary);
+}
+.m-screener-drawer-row:active { background: var(--surface-hover); }
+.m-screener-drawer-name {
+ font-size: 14px;
+ font-weight: 600;
+ flex: 1 1 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.m-strategy-status { font-size: 12px; color: var(--text-primary); }
+.m-strategy-status--warn { color: var(--warning); }
+.m-strategy-status--muted { color: var(--text-secondary); }
+.m-strategy-check { color: var(--action); display: inline-flex; }
+
+.m-score-up { color: var(--market-up); font-weight: 600; }
+
+/* 详情 Sheet 内嵌的选股 / 跟踪分组 */
+.m-detail-foot { margin-top: 16px; padding: 0 12px 16px; }
+.m-detail-group { margin-bottom: 16px; }
+.m-detail-group-title {
+ margin: 0 0 8px;
+ font-size: var(--font-size-label);
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+.m-detail-group-row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: var(--surface);
+ border-radius: 8px;
+ margin-bottom: 4px;
+ font-size: 13.5px;
+ gap: 12px;
+}
+.m-detail-group-label { color: var(--text-secondary); flex: 0 0 auto; }
+.m-detail-group-value { color: var(--text-primary); text-align: right; font-variant-numeric: tabular-nums; }
+.m-detail-group-value.up { color: var(--market-up); }
+.m-detail-group-value.down { color: var(--market-down); }
+
+.m-screener-detail-actions,
+.m-tracking-detail-actions { margin-top: 16px; padding: 0 4px; }
+.m-screener-detail-actions .m-btn-primary,
+.m-tracking-detail-actions .m-btn-primary { width: 100%; }
+
+.m-btn-danger { background: var(--market-up); }
+.m-btn-danger:active { background: var(--market-up); transform: scale(0.97); }
+.m-btn-disabled,
+.m-btn-primary:disabled { background: var(--surface-muted); color: var(--text-secondary); cursor: not-allowed; transform: none; }
+
+/* 确认抽屉 */
+.m-confirm-body { padding-bottom: 8px; }
+.m-confirm-text { font-size: 14px; color: var(--text-primary); line-height: 1.5; margin: 0 0 16px; }
+.m-confirm-actions { display: flex; flex-direction: column; gap: 8px; padding: 0 4px 8px; }
+.m-confirm-actions .m-btn-cancel { background: var(--surface-muted); color: var(--text-primary); }
+.m-confirm-actions .m-btn-cancel:active { background: var(--surface-hover); }
+
+/* ============== 策略跟踪 ============== */
+.m-strip--5 { grid-template-columns: repeat(5, 1fr); }
+@media (max-width: 360px) {
+ .m-strip--5 .m-strip-value { font-size: 11px; }
+ .m-strip--5 .m-strip-label { font-size: 9.5px; }
+}
+
@media (prefers-reduced-motion: reduce) {
.m-rotation-chip:active,
.m-dragon-card:active,
- .m-range-tab:active {
+ .m-range-tab:active,
+ .m-screener-strategy:active,
+ .m-mentor-pill:active,
+ .m-chat-send:active,
+ .m-screener-drawer-row:active,
+ .m-mentor-row:active {
transform: none;
}
}
diff --git a/app/frontend/m/js/api.js b/app/frontend/m/js/api.js
index 07237e2..1c5e2a8 100644
--- a/app/frontend/m/js/api.js
+++ b/app/frontend/m/js/api.js
@@ -20,5 +20,11 @@
return global.XiaobaiAPI.request(url, method, body);
}
- global.MobileAPI = { setCsrfToken: setCsrfToken, request: request };
+ // 流式聊天复用共享层的 streamNdjson:每解析一行 NDJSON 就回调 onEvent,
+ // 调用方通过 { signal } 传入 AbortController 以支持聊天页的「停止」按钮。
+ function streamNdjson(url, options) {
+ return global.XiaobaiAPI.streamNdjson(url, options || {});
+ }
+
+ global.MobileAPI = { setCsrfToken: setCsrfToken, request: request, streamNdjson: streamNdjson };
})(window);
diff --git a/app/frontend/m/js/pages.js b/app/frontend/m/js/pages.js
index e8b8b71..102b380 100644
--- a/app/frontend/m/js/pages.js
+++ b/app/frontend/m/js/pages.js
@@ -71,7 +71,18 @@
close: '',
"chevron-left": '',
"chevron-right": '',
+ "chevron-down": '',
inbox: '',
+ trash: '',
+ bot: '',
+ filter: '',
+ target: '',
+ "refresh-cw": '',
+ plus: '',
+ search: '',
+ send: '',
+ square: '',
+ "alert-triangle": '',
};
function icon(name, size) {
@@ -126,6 +137,37 @@
dragonProfiles: null,
dragonViewMode: "daily",
dragonSelectedTrader: "",
+ chat: {
+ page: "",
+ messages: [],
+ mentorId: "",
+ mentorName: "",
+ mentorTagline: "",
+ mentorGrade: "",
+ mentorFocus: [],
+ mentorSetup: null,
+ followUps: [],
+ streaming: false,
+ streamController: null,
+ streamBuffer: "",
+ streamBubble: null,
+ loading: false,
+ historyLoaded: false,
+ aborter: null,
+ },
+ screener: {
+ data: null,
+ date: "",
+ view: "latest",
+ strategyId: "",
+ strategyName: "",
+ loading: false,
+ },
+ tracking: {
+ data: null,
+ loading: false,
+ refreshing: false,
+ },
};
let sheetToken = 0;
@@ -1526,6 +1568,1350 @@
return meta && meta.notice ? '
' + escapeHtml(meta.notice) + "
" : "";
}
+ /* ---------------------------------------------------------------- helpers shared by new pages */
+
+ function openConfirmSheet(title, body, options) {
+ const opts = options || {};
+ const confirmLabel = opts.confirmLabel || "确定";
+ const cancelLabel = opts.cancelLabel || "取消";
+ const danger = Boolean(opts.danger);
+ openSheet(
+ '' + escapeHtml(title) + '
' +
+ '' +
+ '' +
+ (body ? '
' + escapeHtml(body) + '
' : '') +
+ '
' +
+ '' +
+ '' +
+ '
',
+ { detail: false }
+ );
+ const ok = document.querySelector('[data-confirm-ok]');
+ if (ok && typeof opts.onConfirm === 'function') {
+ ok.addEventListener('click', function () {
+ closeSheet();
+ opts.onConfirm();
+ });
+ }
+ }
+
+ function nextSeq() {
+ state.seq += 1;
+ return state.seq;
+ }
+
+ function renderStrip5(cells) {
+ // cells: [{label, value, tone?}] — 5 cells for 跟踪/复盘汇总条;窄屏自适应
+ return '' +
+ cells.map(function (cell) {
+ const tone = cell.tone ? ' m-strip-' + cell.tone : '';
+ return '
' +
+ '' + escapeHtml(cell.label) + '' +
+ '' + (cell.value || '--') + '' +
+ '
';
+ }).join('') + '
';
+ }
+
+ /* ============================================================== 智能选股 + 策略跟踪 ============================================================== */
+
+ const SCREENER_VIEW_LABELS = {
+ latest: "最新候选",
+ active: "持续有效",
+ history: "入选历史"
+ };
+
+ function screenerViewKey(payload, view) {
+ if (!payload) return [];
+ if (view === "active") return payload.active_signals || [];
+ if (view === "history") return payload.candidate_history || [];
+ const latest = payload.latest_results || {};
+ const strategyLibrary = screenerCurrentLibrary(payload);
+ const mode = strategyLibrary || "smart";
+ const run = latest[mode] || latest.smart || latest.curated || null;
+ if (!run) return [];
+ return run.candidates || [];
+ }
+
+ 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 screenerCurrentRun(payload) {
+ if (!payload) return null;
+ if (state.screener.view === "active") return null;
+ if (state.screener.view === "history") return null;
+ const latest = payload.latest_results || {};
+ const mode = screenerCurrentLibrary(payload);
+ return latest[mode] || latest.smart || latest.curated || null;
+ }
+
+ function screenerCurrentRunId() {
+ const payload = state.screener.data;
+ if (!payload) return 0;
+ const run = screenerCurrentRun(payload);
+ return run ? Number(run.run_id || 0) : 0;
+ }
+
+ function screenerFollowedCodes() {
+ const tracking = state.tracking.data;
+ if (!tracking || !tracking.batches) return {};
+ const map = {};
+ tracking.batches.forEach(function (batch) {
+ (batch.items || []).forEach(function (item) {
+ if (item && item.code) map[String(item.code)] = item;
+ });
+ });
+ return map;
+ }
+
+ function findScreenerStrategy(payload, strategyId) {
+ const list = (payload && payload.strategies) || [];
+ if (!strategyId) return null;
+ for (let i = 0; i < list.length; i += 1) {
+ if (String(list[i].id) === String(strategyId)) return list[i];
+ }
+ return null;
+ }
+
+ function findScreenerStrategyByName(payload, name) {
+ const list = (payload && payload.strategies) || [];
+ if (!name) return null;
+ for (let i = 0; i < list.length; i += 1) {
+ if (list[i].name === name) return list[i];
+ }
+ return null;
+ }
+
+ function pickInitialScreenerStrategy(payload) {
+ const list = (payload && payload.strategies) || [];
+ // 默认选 smart 阶段策略
+ const stage = list.find(function (s) {
+ const lib = s.formula && s.formula.meta && s.formula.meta.library;
+ return lib === "smart";
+ });
+ if (stage) return stage;
+ if (list.length) return list[0];
+ return null;
+ }
+
+ function setupScreenerPage(key) {
+ document.getElementById("m-view").classList.add("m-view-feature");
+ state.screener = {
+ data: null,
+ date: todayString(),
+ view: "latest",
+ strategyId: "",
+ strategyName: "",
+ loading: false,
+ };
+ updateHeader("智能选股", displayCompactDate(state.screener.date));
+ document.getElementById("m-actions").innerHTML =
+ '';
+ document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(8)));
+ }
+
+ function loadScreener() {
+ state.screener.loading = true;
+ const seq = nextSeq();
+ const date = state.screener.date || todayString();
+ const url = "/api/screener/setup?trade_date=" + encodeURIComponent(date);
+ global.MobileAPI.request(url).then(function (payload) {
+ if (seq !== state.seq) return;
+ state.screener.data = payload;
+ state.screener.date = payload.requested_trade_date || payload.trade_date || date;
+ // 自动选择默认策略(首次进入时)
+ if (!state.screener.strategyId) {
+ const def = pickInitialScreenerStrategy(payload);
+ if (def) {
+ state.screener.strategyId = def.id;
+ state.screener.strategyName = def.name;
+ }
+ }
+ updateHeader("智能选股", displayCompactDate(state.screener.date));
+ renderScreener();
+ }).catch(function (error) {
+ if (seq !== state.seq) return;
+ renderError(error && error.message ? error.message : "选股数据加载失败");
+ }).then(function () {
+ state.screener.loading = false;
+ });
+ }
+
+ function renderScreener() {
+ const scroll = document.getElementById("m-scroll");
+ if (!scroll) return;
+ const payload = state.screener.data;
+ if (!payload) { scroll.innerHTML = skeletonHtml(6); return; }
+ const regime = payload.regime || {};
+ const stageLabel = regime.label || regime.id || "--";
+ const confidence = regime.confidence != null ? Math.round(Number(regime.confidence)) : null;
+ const reason = regime.reason || regime.description || "";
+ const updatedAt = formatScreenerUpdatedAt(regime.updated_at);
+ const factors = (payload.factor_data && payload.factor_data.date_count) || 0;
+
+ const stageCard =
+ '' +
+ '
' +
+ '当前阶段' +
+ '' + escapeHtml(updatedAt) + '' +
+ '
' +
+ '
' +
+ '' + escapeHtml(stageLabel) + '' +
+ (confidence != null ? '置信度 ' + confidence + '%' : '') +
+ '
' +
+ (reason ? '
' + escapeHtml(reason) + '
' : '') +
+ '
';
+
+ // 视图切换 pill 行
+ const tabs = Object.keys(SCREENER_VIEW_LABELS).map(function (key) {
+ const active = state.screener.view === key;
+ return '';
+ }).join("");
+ const viewTabs = '' + tabs + '
';
+
+ // 当前策略胶囊行
+ const strategyName = state.screener.strategyName || "选择策略";
+ const statusText = screenerStrategyStatusText(payload);
+ const strategyRow =
+ '';
+
+ // 表格容器
+ const tableWrap = '';
+ const foot = '';
+
+ scroll.innerHTML = stageCard + viewTabs + strategyRow + tableWrap + foot;
+ renderScreenerTable();
+ }
+
+ function screenerStrategyStatusText(payload) {
+ if (!state.screener.strategyId) return "选择策略";
+ const strategy = findScreenerStrategy(payload, state.screener.strategyId);
+ if (strategy && strategy.published_run) {
+ const pub = strategy.published_run;
+ const detail = pub.detail || "";
+ if (pub.status === "ready") return detail;
+ if (pub.status === "missing_data") return "数据不足";
+ if (pub.status === "not_run") return "等待盘后";
+ }
+ if (strategy && strategy.missing_data && strategy.missing_data.length) return "数据不足";
+ if (state.screener.view === "active") return "查看持续有效";
+ if (state.screener.view === "history") return "查看入选历史";
+ const rows = screenerViewKey(payload, state.screener.view);
+ if (rows.length) return rows.length + " 只候选";
+ return "暂无信号";
+ }
+
+ function formatScreenerUpdatedAt(value) {
+ if (!value) return "盘后已更新";
+ const text = String(value);
+ if (text.length >= 5 && text.indexOf(":") > 0) {
+ return "盘后 " + text + " 已更新";
+ }
+ if (text.length >= 10) return displayCompactDate(text) + " 已更新";
+ return "盘后已更新";
+ }
+
+ function renderScreenerTable() {
+ const wrap = document.getElementById("m-screener-table");
+ if (!wrap) return;
+ const payload = state.screener.data;
+ if (!payload) { wrap.innerHTML = ""; return; }
+ const rows = screenerViewKey(payload, state.screener.view);
+ if (!rows.length) {
+ wrap.innerHTML = '' +
+ '
' + icon("inbox", 26) + '' +
+ '
当前策略当日暂无信号
' +
+ '
可点上方策略行切换其他策略' +
+ '
';
+ return;
+ }
+ // 跟踪状态映射(加入跟踪后置灰)
+ const followed = screenerFollowedCodes();
+ rows.forEach(function (r) { r.tracked = followed[String(r.code)] ? true : false; });
+
+ const cols = {
+ frozenColumns: [
+ { key: "stock", label: "股票", type: "stock", width: 96 }
+ ],
+ primaryColumns: [
+ { key: "score_display", label: "综合分", type: "score" },
+ { key: "pct_chg", label: "涨幅%", type: "change" },
+ { key: "sector", label: "板块", type: "text" }
+ ],
+ scrollColumns: [
+ { key: "historical_probability", label: "历史胜率%", type: "rate" },
+ { key: "return_5d", label: "5日%", type: "change" },
+ { key: "volume_ratio_5d", label: "量比", type: "rate" },
+ { key: "sector_strength", label: "板块强度", type: "rate" }
+ ]
+ };
+ state.sortTable = { cols: cols, reapply: renderScreenerTable };
+ const head = orderedColumns(cols).map(function (col, i) {
+ const isFrozen = i < cols.frozenColumns.length;
+ const sortable = sortableColumn(col);
+ const active = sortable && state.sort.key === col.key && Boolean(state.sort.dir);
+ const indicator = sortable ? sortIndicatorHtml(active) : "";
+ const extraCls = sortable ? " m-sortable" + (active ? " is-sorted" : "") : "";
+ const extraAttrs = sortable ? ' data-sort-key="' + escapeHtml(col.key) + '" aria-sort="' + (active ? (state.sort.dir === "asc" ? "ascending" : "descending") : "none") + '"' : '';
+ return '' +
+ '' + escapeHtml(col.label) + indicator + ' | ';
+ }).join("");
+
+ const sorted = sortedRows(rows, cols);
+ const body = sorted.map(function (row, rIndex) {
+ const cells = orderedColumns(cols).map(function (col, i) {
+ const isFrozen = i < cols.frozenColumns.length;
+ const width = columnWidth(col);
+ const cls = "m-td " + colAlign(col) + (isFrozen ? " m-frozen" : "");
+ const style = "min-width:" + width + "px" + (isFrozen ? ";width:" + width + "px;left:0" : "");
+ let html = cellHtml(col, row, rIndex);
+ if (col.key === "score_display") {
+ const n = Number(row.score_display);
+ if (Number.isFinite(n) && n >= 80) {
+ html = '' + formatNumber(n, 1) + '';
+ }
+ }
+ return '' + html + ' | ';
+ }).join("");
+ const code = /^\d{6}$/.test(String(row.code || "")) ? ' data-code="' + escapeHtml(row.code) + '"' : "";
+ return '' + cells + '
';
+ }).join("");
+
+ wrap.innerHTML = '';
+ }
+
+ function openScreenerStrategyDrawer() {
+ const payload = state.screener.data;
+ if (!payload) return;
+ const list = (payload.strategies || []).map(function (s) {
+ const meta = (s.formula && s.formula.meta) || {};
+ return {
+ name: s.name,
+ library: meta.library || "",
+ category: meta.category || "",
+ id: s.id,
+ regimes: s.regimes || [],
+ published_run: s.published_run || null,
+ missing_data: s.missing_data || []
+ };
+ });
+ if (!list.length) {
+ list.push({ id: "smart", name: "智能阶段", library: "smart", category: "周期策略", published_run: null, missing_data: [] });
+ }
+ openSheet(
+ '选择策略
' +
+ '' +
+ '',
+ { 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 screenerStrategyStatusTextFor(s, payload) {
+ if (s.published_run) {
+ const pub = s.published_run;
+ if (pub.status === "ready") return pub.detail || "数据完整";
+ if (pub.status === "missing_data") return "数据不足";
+ if (pub.status === "not_run") return "等待盘后";
+ }
+ if (s.missing_data && s.missing_data.length) return "数据不足";
+ return "暂无信号";
+ }
+
+ function screenerDrawerContent(list, filter, cat) {
+ const kw = (filter || "").trim().toLowerCase();
+ const activeCat = cat || "all";
+ const cats = [{ key: "all", label: "全部" }, { key: "基本面", label: "基本面" }, { key: "趋势", label: "趋势" }, { key: "短线", label: "短线" }, { key: "动量", label: "动量" }, { key: "量化", label: "量化" }, { key: "事件", label: "事件" }, { key: "资金", label: "资金" }];
+ const catPills = cats.map(function (c) {
+ const active = activeCat === c.key;
+ return '';
+ }).join("");
+ const filtered = list.filter(function (s) {
+ if (kw && String(s.name).toLowerCase().indexOf(kw) < 0) return false;
+ if (activeCat !== "all") {
+ const cat = s.category || s.library || "";
+ if (String(cat) !== activeCat) return false;
+ }
+ return true;
+ });
+ const rows = filtered.map(function (s) {
+ const isCurrent = String(s.id) === String(state.screener.strategyId);
+ const status = screenerStrategyStatusTextFor(s, state.screener.data);
+ const statusCls = status === "数据不足" ? 'm-strategy-status--warn' : (status === "暂无信号" || status === "等待盘后" ? 'm-strategy-status--muted' : '');
+ return '';
+ }).join("") || '';
+ return '' + icon("search", 16) + '
' +
+ '' + catPills + '
' +
+ '' + rows + '
';
+ }
+
+ function setupTrackingPage(key) {
+ document.getElementById("m-view").classList.add("m-view-feature");
+ state.tracking = { data: null, loading: false, refreshing: false };
+ updateHeader("策略跟踪", "");
+ document.getElementById("m-actions").innerHTML =
+ '';
+ document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(6)));
+ }
+
+ function loadTracking() {
+ state.tracking.loading = true;
+ const seq = nextSeq();
+ global.MobileAPI.request("/api/screener/tracking?limit=12").then(function (payload) {
+ if (seq !== state.seq) return;
+ state.tracking.data = payload;
+ renderTracking();
+ }).catch(function (error) {
+ if (seq !== state.seq) return;
+ renderError(error && error.message ? error.message : "跟踪数据加载失败");
+ }).then(function () {
+ state.tracking.loading = false;
+ });
+ }
+
+ function renderTracking() {
+ const scroll = document.getElementById("m-scroll");
+ if (!scroll) return;
+ const data = state.tracking.data || {};
+ const summary = data.summary || {};
+ const batches = data.batches || [];
+ const items = [];
+ batches.forEach(function (b) {
+ (b.items || []).forEach(function (it) { items.push(Object.assign({ batch: b }, it)); });
+ });
+ items.sort(function (a, b) {
+ return String(b.batch.selection_date || "").localeCompare(String(a.batch.selection_date || ""));
+ });
+
+ if (!items.length) {
+ scroll.innerHTML = '' +
+ '
' + icon("target", 26) + '' +
+ '
暂无跟踪记录
' +
+ '
在智能选股页将候选加入跟踪,5 个交易日内会在这里自动汇总表现' +
+ '
' +
+ '
';
+ return;
+ }
+
+ const stripHtml = renderStrip5([
+ { label: "跟踪标的", value: String(summary.total != null ? summary.total : items.length) },
+ { label: "已观察", value: String(summary.observed != null ? summary.observed : 0) },
+ { label: "T+1胜率", value: summary.t1_win_rate != null ? summary.t1_win_rate + "%" : "--" },
+ { label: "T+5胜率", value: summary.t5_win_rate != null ? summary.t5_win_rate + "%" : "--" },
+ { label: "T+5平均", value: summary.average_t5 != null ? (summary.average_t5 > 0 ? "+" : "") + summary.average_t5 + "%" : "--", tone: summary.average_t5 > 0 ? "up" : (summary.average_t5 < 0 ? "down" : "") }
+ ]);
+
+ const cols = {
+ frozenColumns: [
+ { key: "stock", label: "股票", type: "stock", width: 96 }
+ ],
+ primaryColumns: [
+ { key: "status_text", label: "状态", type: "text" },
+ { key: "max_gain", label: "最大涨幅", type: "change" }
+ ],
+ scrollColumns: [
+ { key: "selection_date", label: "入选日", type: "text" },
+ { key: "strategy_name", label: "策略", type: "text", wide: true },
+ { key: "entry_price", label: "入场价", type: "price" },
+ { key: "t1_close", label: "T+1收", type: "change" },
+ { key: "t3_close", label: "T+3", type: "change" },
+ { key: "t5_close", label: "T+5", type: "change" },
+ { key: "max_drawdown", label: "最大回撤", type: "change" }
+ ]
+ };
+ const rows = items.map(function (it) {
+ return {
+ code: it.code,
+ name: it.name,
+ sector: it.sector,
+ status: it.status,
+ status_text: it.status || "--",
+ max_gain: it.max_gain,
+ selection_date: displayCompactDate(it.batch.selection_date || ""),
+ strategy_name: it.batch.strategy_name || "--",
+ entry_price: it.entry_price,
+ t1_close: it.t1_close,
+ t3_close: it.t3_close,
+ t5_close: it.t5_close,
+ max_drawdown: it.max_drawdown,
+ track_id: it.id
+ };
+ });
+
+ // 表头
+ const head = orderedColumns(cols).map(function (col, i) {
+ const isFrozen = i < cols.frozenColumns.length;
+ const sortable = sortableColumn(col) && col.key !== "status_text";
+ const active = sortable && state.sort.key === col.key && Boolean(state.sort.dir);
+ const indicator = sortable ? sortIndicatorHtml(active) : "";
+ const extraCls = sortable ? " m-sortable" + (active ? " is-sorted" : "") : "";
+ const extraAttrs = sortable ? ' data-sort-key="' + escapeHtml(col.key) + '" aria-sort="' + (active ? (state.sort.dir === "asc" ? "ascending" : "descending") : "none") + '"' : '';
+ return '' +
+ '' + escapeHtml(col.label) + indicator + ' | ';
+ }).join("");
+
+ state.sortTable = { cols: cols, reapply: renderTracking };
+ const sorted = sortedRows(rows, cols);
+ const body = sorted.map(function (row, rIndex) {
+ const cells = orderedColumns(cols).map(function (col, i) {
+ const isFrozen = i < cols.frozenColumns.length;
+ const width = columnWidth(col);
+ const cls = "m-td " + colAlign(col) + (isFrozen ? " m-frozen" : "");
+ const style = "min-width:" + width + "px" + (isFrozen ? ";width:" + width + "px;left:0" : "");
+ let html;
+ if (col.key === "status_text") {
+ html = trackingStatusBadge(row.status);
+ } else if (col.key === "max_gain") {
+ const v = row.max_gain;
+ if (v == null) html = '--';
+ else html = '' + (Number(v) > 0 ? "+" : "") + formatNumber(Number(v), 2) + '';
+ } else {
+ html = cellHtml(col, row, rIndex);
+ }
+ return '' + html + ' | ';
+ }).join("");
+ const code = /^\d{6}$/.test(String(row.code || "")) ? ' data-code="' + escapeHtml(row.code) + '" data-track-id="' + escapeHtml(String(row.track_id || "")) + '"' : '';
+ return '' + cells + '
';
+ }).join("");
+
+ const tableHtml = '';
+
+ scroll.innerHTML = stripHtml + tableHtml;
+ }
+
+ function trackingStatusBadge(status) {
+ const text = status || "--";
+ if (text.indexOf("已完成") === 0) return '' + escapeHtml(text) + '';
+ if (text.indexOf("等待") === 0) return '' + escapeHtml(text) + '';
+ if (text.indexOf("跟踪中") === 0) return '' + escapeHtml(text) + '';
+ return '' + escapeHtml(text) + '';
+ }
+
+ /* ---------------------------------------------------------------- screener/tracking detail sheets (composed on top of existing openDetailSheet) */
+
+ function appendDetailGroup(sheet, title, rows) {
+ return '' + escapeHtml(title) + '
' +
+ rows.map(function (r) {
+ return '' + escapeHtml(r.label) + '' +
+ '' + r.value + '
';
+ }).join("") + '';
+ }
+
+ function openScreenerDetailSheet(code) {
+ openDetailSheet(code);
+ // 等详情体渲染完成后追加「选股信息」分组 + 「加入跟踪」按钮
+ const attach = function () {
+ const body = document.getElementById("m-detail-sheet-body");
+ if (!body) return;
+ // 找第一个空骨架或图表加载完成
+ if (body.querySelector(".m-skeleton")) {
+ return global.setTimeout(attach, 80);
+ }
+ const rows = screenerViewKey(state.screener.data, state.screener.view);
+ const row = rows.find(function (r) { return String(r.code) === String(code); });
+ if (!row) return;
+ const runId = screenerCurrentRunId();
+ const followed = screenerFollowedCodes()[String(code)];
+ const isTracked = Boolean(followed);
+ const 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 || "--") }
+ ]) +
+ (row.reason ? appendDetailGroup("主要贡献", [{ label: "", value: '' + escapeHtml(row.reason) + '' }]) : '') +
+ '' +
+ (isTracked
+ ? ''
+ : '') +
+ '
';
+ const foot = document.createElement("div");
+ foot.className = "m-detail-foot";
+ foot.innerHTML = html;
+ body.appendChild(foot);
+ const btn = body.querySelector("[data-screener-track]");
+ if (btn) {
+ btn.addEventListener("click", function () {
+ addScreenerTrackingFromSheet(btn.dataset.runId, btn.dataset.code);
+ });
+ }
+ };
+ attach();
+ }
+
+ function addScreenerTrackingFromSheet(runId, code) {
+ if (!runId || runId === "0" || !code) {
+ showToast("当前结果不支持加入跟踪");
+ return;
+ }
+ global.MobileAPI.request("/api/screener/tracking", "POST", { run_id: Number(runId), code: code }).then(function () {
+ showToast("已加入跟踪");
+ // 刷新 tracking 缓存
+ return global.MobileAPI.request("/api/screener/tracking?limit=12");
+ }).then(function (payload) {
+ if (payload) state.tracking.data = payload;
+ // 重渲染选股表中跟踪态
+ renderScreenerTable();
+ // 更新当前 sheet 中的按钮
+ const body = document.getElementById("m-detail-sheet-body");
+ if (body) {
+ const actions = body.querySelector(".m-screener-detail-actions");
+ if (actions) {
+ actions.innerHTML = '';
+ }
+ }
+ }).catch(function (err) {
+ showToast((err && err.message) || "加入跟踪失败");
+ });
+ }
+
+ function openTrackingDetailSheet(code, trackId) {
+ openDetailSheet(code);
+ const attach = function () {
+ const body = document.getElementById("m-detail-sheet-body");
+ if (!body) return;
+ if (body.querySelector(".m-skeleton")) {
+ return global.setTimeout(attach, 80);
+ }
+ const data = state.tracking.data || {};
+ let item = null;
+ let batch = null;
+ (data.batches || []).forEach(function (b) {
+ (b.items || []).forEach(function (it) {
+ if (String(it.id) === String(trackId) || String(it.code) === String(code)) {
+ item = it;
+ batch = b;
+ }
+ });
+ });
+ if (!item) return;
+ const html =
+ appendDetailGroup("跟踪信息", [
+ { label: "入选日", value: escapeHtml(displayCompactDate(batch.selection_date || "")) },
+ { label: "策略", value: escapeHtml(batch.strategy_name || "--") },
+ { label: "入场价", value: item.entry_price != null ? formatNumber(Number(item.entry_price), 2) : "--" },
+ { label: "T+1开", value: item.t1_open != null ? (Number(item.t1_open) > 0 ? "+" : "") + formatNumber(Number(item.t1_open), 2) + "%" : "--" },
+ { label: "T+1收", value: item.t1_close != null ? (Number(item.t1_close) > 0 ? "+" : "") + formatNumber(Number(item.t1_close), 2) + "%" : "--" },
+ { label: "T+3收", value: item.t3_close != null ? (Number(item.t3_close) > 0 ? "+" : "") + formatNumber(Number(item.t3_close), 2) + "%" : "--" },
+ { label: "T+5收", value: item.t5_close != null ? (Number(item.t5_close) > 0 ? "+" : "") + formatNumber(Number(item.t5_close), 2) + "%" : "--" },
+ { label: "最大涨幅", value: item.max_gain != null ? (Number(item.max_gain) > 0 ? "+" : "") + formatNumber(Number(item.max_gain), 2) + "%" : "--", cls: "up" },
+ { label: "最大回撤", value: item.max_drawdown != null ? formatNumber(Number(item.max_drawdown), 2) + "%" : "--", cls: "down" }
+ ]) +
+ '' +
+ '' +
+ '
';
+ const foot = document.createElement("div");
+ foot.className = "m-detail-foot";
+ foot.innerHTML = html;
+ body.appendChild(foot);
+ const btn = body.querySelector("[data-tracking-remove]");
+ if (btn) {
+ btn.addEventListener("click", function () {
+ openConfirmSheet("移除跟踪", "移除后该股将不再自动更新表现,但历史成交已记录保留。", {
+ danger: true,
+ confirmLabel: "移除",
+ onConfirm: function () { removeTracking(btn.dataset.trackId); }
+ });
+ });
+ }
+ };
+ attach();
+ }
+
+ function removeTracking(trackId) {
+ if (!trackId) return;
+ global.MobileAPI.request("/api/screener/tracking/" + encodeURIComponent(trackId), "DELETE").then(function () {
+ showToast("已移除跟踪");
+ closeSheet();
+ return global.MobileAPI.request("/api/screener/tracking?limit=12");
+ }).then(function (payload) {
+ if (payload) {
+ state.tracking.data = payload;
+ renderTracking();
+ }
+ }).catch(function (err) {
+ showToast((err && err.message) || "移除失败");
+ });
+ }
+
+ function refreshTracking() {
+ if (state.tracking.refreshing) return;
+ state.tracking.refreshing = true;
+ global.MobileAPI.request("/api/screener/tracking/refresh", "POST", {}).then(function (payload) {
+ showToast(payload && payload.notice ? payload.notice : "已更新");
+ if (payload && payload.tracking) {
+ state.tracking.data = payload.tracking;
+ renderTracking();
+ } else {
+ return global.MobileAPI.request("/api/screener/tracking?limit=12");
+ }
+ }).then(function (payload) {
+ if (payload) {
+ state.tracking.data = payload;
+ renderTracking();
+ }
+ }).catch(function (err) {
+ showToast((err && err.message) || "刷新失败");
+ }).then(function () {
+ state.tracking.refreshing = false;
+ const icon = document.querySelector("[data-tracking-refresh] svg");
+ if (icon) icon.classList.remove("is-spinning");
+ });
+ }
+
+ /* ============================================================== 复盘助手 + 问师 聊天工作台 ============================================================== */
+
+ const ASSISTANT_PRESET_QUESTIONS = [
+ "市场位置",
+ "市场主线",
+ "交易复盘",
+ "明日清单"
+ ];
+
+ function setupChatPage(key) {
+ document.getElementById("m-view").classList.add("m-view-feature");
+ state.chat = {
+ page: key,
+ messages: [],
+ mentorId: "",
+ mentorName: "",
+ mentorTagline: "",
+ mentorGrade: "",
+ mentorFocus: [],
+ mentorSetup: null,
+ followUps: [],
+ streaming: false,
+ streamController: null,
+ streamBuffer: "",
+ streamBubble: null,
+ loading: false,
+ historyLoaded: false,
+ aborter: null
+ };
+ const isAssistant = key === "assistant/chat";
+ // 聊天页是底栏直达:不要 back 按钮;m-actions 由本函数自己控制
+ global.MobileRouter.updateHeader({ title: isAssistant ? "复盘助手" : "问师", back: false, actions: "" });
+ document.getElementById("m-view").innerHTML = buildChatShell(key);
+ const actions = [];
+ if (!isAssistant) {
+ actions.push('');
+ }
+ actions.push('');
+ document.getElementById("m-actions").innerHTML = actions.join("");
+ bindChatShell(key);
+ }
+
+ function buildChatShell(key) {
+ const isAssistant = key === "assistant/chat";
+ return '' +
+ '
' + chatSkeletonHtml() + '
' +
+ (isAssistant ? '' : '
') +
+ '
' +
+ '
' +
+ '
';
+ }
+
+ function chatSkeletonHtml() {
+ return '' +
+ '
' +
+ '
' +
+ '
' +
+ '
';
+ }
+
+ function bindChatShell(key) {
+ const isAssistant = key === "assistant/chat";
+ const input = document.getElementById("m-chat-input");
+ if (input) {
+ // 避免重复绑定:每次 setupChatPage 都新建 input 元素
+ input.addEventListener("input", function () {
+ autoSizeChatInput();
+ syncChatSendEnabled();
+ });
+ input.addEventListener("keydown", function (event) {
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
+ event.preventDefault();
+ if (!state.chat.streaming) submitChatMessage();
+ }
+ });
+ }
+ syncChatSendEnabled();
+ autoSizeChatInput();
+ }
+
+ function autoSizeChatInput() {
+ const input = document.getElementById("m-chat-input");
+ if (!input) return;
+ input.style.height = "auto";
+ const max = 116; // ~4 lines
+ const next = Math.min(max, Math.max(44, input.scrollHeight));
+ input.style.height = next + "px";
+ }
+
+ function syncChatSendEnabled() {
+ const input = document.getElementById("m-chat-input");
+ const btn = document.querySelector("[data-chat-send]");
+ if (!btn) return;
+ if (state.chat.streaming) {
+ btn.classList.add("m-chat-send--stop");
+ btn.setAttribute("aria-label", "停止");
+ btn.innerHTML = icon("square", 14);
+ btn.disabled = false;
+ return;
+ }
+ btn.classList.remove("m-chat-send--stop");
+ btn.setAttribute("aria-label", "发送");
+ btn.innerHTML = icon("send", 20);
+ const text = input ? input.value.trim() : "";
+ btn.disabled = !text;
+ }
+
+ function loadChat() {
+ if (state.chat.page === "assistant/chat") {
+ loadAssistantHistory();
+ } else {
+ loadMentorSetup();
+ }
+ }
+
+ function loadAssistantHistory() {
+ state.chat.loading = true;
+ const seq = nextSeq();
+ global.MobileAPI.request("/api/assistant/messages").then(function (payload) {
+ if (seq !== state.seq) return;
+ const items = (payload && payload.items) || [];
+ state.chat.messages = items.map(function (it) {
+ return { role: it.role, content: String(it.content || ""), created_at: it.created_at || "" };
+ });
+ state.chat.historyLoaded = true;
+ renderChatStream();
+ renderChatPresets();
+ }).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;
+ // 没有选定导师时,从偏好或首位开始
+ if (!state.chat.mentorId) {
+ const mentors = (payload && payload.mentors) || [];
+ if (mentors.length) {
+ selectMentor(mentors[0].id, { silent: true });
+ }
+ } 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();
+ renderChatPresets();
+ }).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();
+ renderChatPresets();
+ }).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();
+ renderChatPresets();
+ loadMentorHistory();
+ }
+
+ function clearAssistantMessages() {
+ global.MobileAPI.request("/api/assistant/messages", "DELETE").then(function () {
+ state.chat.messages = [];
+ state.chat.followUps = [];
+ renderChatStream();
+ renderChatPresets();
+ 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();
+ renderChatPresets();
+ 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", "我是你的复盘助手,可以问我市场位置、主线、交易复盘、明日清单。") +
+ '从下面挑一个开始,或直接输入你的问题
';
+ } else if (state.chat.mentorId) {
+ // 显示导师 tagline 原文(spec §1.5)
+ const tagline = state.chat.mentorTagline || "今天想问点什么?";
+ html = chatBubbleHtml("ai", tagline) +
+ '基于今日市场数据回答;引用数据时已带日期
';
+ } else {
+ html = '';
+ }
+ } else {
+ html = messages.map(function (msg, idx) {
+ return chatBubbleHtmlWithTimestamp(msg, idx, messages);
+ }).join("");
+ }
+ stream.innerHTML = html;
+ scrollChatToBottom();
+ renderChatFollowUps();
+ }
+
+ function renderChatStreamError(message) {
+ const stream = document.getElementById("m-chat-stream");
+ if (!stream) return;
+ stream.innerHTML = '' + escapeHtml(message) + '
' +
+ '
';
+ const btn = stream.querySelector("[data-chat-retry]");
+ if (btn) btn.addEventListener("click", loadChat);
+ }
+
+ function chatBubbleHtml(role, content) {
+ const isUser = role === "user";
+ return '' +
+ '
' + (isUser ? escapeHtml(content) : renderChatMarkdown(content)) + '
' +
+ '
';
+ }
+
+ function chatBubbleHtmlWithTimestamp(msg, idx, all) {
+ const isUser = msg.role === "user";
+ let stamp = "";
+ if (msg.created_at && idx > 0) {
+ const prev = all[idx - 1];
+ if (prev && prev.created_at && shouldInsertTimestamp(prev.created_at, msg.created_at)) {
+ stamp = '' + escapeHtml(chatTimestampLabel(msg.created_at)) + '
';
+ }
+ }
+ return stamp + chatBubbleHtml(isUser ? "user" : "ai", msg.content);
+ }
+
+ function shouldInsertTimestamp(prevIso, currIso) {
+ try {
+ const a = new Date(prevIso).getTime();
+ const b = new Date(currIso).getTime();
+ return (b - a) > 5 * 60 * 1000;
+ } catch (_error) { return false; }
+ }
+
+ function chatTimestampLabel(iso) {
+ try {
+ const d = new Date(iso);
+ const now = new Date();
+ const sameDay = d.toDateString() === now.toDateString();
+ if (sameDay) return "今天 " + String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
+ const yest = new Date(now.getTime() - 24 * 3600 * 1000);
+ if (d.toDateString() === yest.toDateString()) return "昨天 " + String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
+ return displayCompactDate(iso) + " " + String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
+ } catch (_error) { return displayCompactDate(iso) || ""; }
+ }
+
+ // 极简 markdown:加粗 / 列表 / 引用 / 标题(与电脑端限制保持一致;不渲染图片/表格/代码)
+ function renderChatMarkdown(text) {
+ if (!text) return "";
+ const lines = String(text).split("\n");
+ const out = [];
+ let inList = false;
+ let inQuote = false;
+ for (let i = 0; i < lines.length; i += 1) {
+ const raw = lines[i];
+ const line = escapeHtml(raw);
+ if (/^\s*[-*]\s+/.test(raw)) {
+ if (!inList) { out.push(""); inList = true; }
+ if (inQuote) { out.push(""); inQuote = false; }
+ out.push("- " + line.replace(/^\s*[-*]\s+/, "").replace(/\*\*([^*]+)\*\*/g, "$1") + "
");
+ continue;
+ }
+ if (inList) { out.push("
"); inList = false; }
+ if (/^>\s?/.test(raw)) {
+ if (!inQuote) { out.push(""); inQuote = true; }
+ out.push(line.replace(/^>\s?/, "").replace(/\*\*([^*]+)\*\*/g, "$1"));
+ continue;
+ }
+ if (inQuote) { out.push("
"); inQuote = false; }
+ if (/^###\s+/.test(raw)) {
+ out.push("" + line.replace(/^###\s+/, "").replace(/\*\*([^*]+)\*\*/g, "$1") + "
");
+ continue;
+ }
+ if (!line.trim()) { out.push(""); continue; }
+ out.push("" + line.replace(/\*\*([^*]+)\*\*/g, "$1") + "
");
+ }
+ if (inList) out.push("");
+ if (inQuote) out.push("");
+ return out.join("");
+ }
+
+ function renderChatMentorBar() {
+ const bar = document.getElementById("m-chat-mentor-bar");
+ if (!bar) return;
+ if (state.chat.page === "assistant/chat") { bar.innerHTML = ""; return; }
+ if (!state.chat.mentorId) { bar.innerHTML = ""; return; }
+ const grade = state.chat.mentorGrade || "";
+ const gradeBadge = grade ? '' + escapeHtml(grade) + '' : "";
+ bar.innerHTML = '';
+ }
+
+ function renderChatPresets() {
+ const wrap = document.getElementById("m-chat-presets");
+ if (!wrap) return;
+ const isAssistant = state.chat.page === "assistant/chat";
+ const hasHistory = (state.chat.messages || []).length > 0;
+ if (isAssistant) {
+ // 复盘助手:仅在空会话时显示引导问题
+ if (hasHistory || state.chat.streaming) { wrap.innerHTML = ""; return; }
+ wrap.innerHTML = '' +
+ ASSISTANT_PRESET_QUESTIONS.map(function (q) {
+ return '';
+ }).join("") + '
';
+ } else {
+ // 问师:无引导问题
+ wrap.innerHTML = "";
+ }
+ }
+
+ function renderChatFollowUps() {
+ const stream = document.getElementById("m-chat-stream");
+ if (!stream) return;
+ const old = stream.querySelector("[data-chat-followup-row]");
+ if (old) old.remove();
+ if (state.chat.streaming || !state.chat.followUps || !state.chat.followUps.length) return;
+ const html = '' +
+ state.chat.followUps.slice(0, 3).map(function (q) {
+ return '';
+ }).join("") + '
';
+ stream.insertAdjacentHTML("beforeend", html);
+ scrollChatToBottom();
+ }
+
+ function scrollChatToBottom() {
+ const stream = document.getElementById("m-chat-stream");
+ if (!stream) return;
+ global.requestAnimationFrame(function () {
+ stream.scrollTop = stream.scrollHeight;
+ });
+ }
+
+ function submitChatMessage() {
+ const input = document.getElementById("m-chat-input");
+ if (!input) return;
+ const text = input.value.trim();
+ if (!text) return;
+ sendChatMessage(text);
+ }
+
+ function sendChatMessage(text) {
+ if (state.chat.streaming) return;
+ if (state.chat.page === "tools/mentor" && !state.chat.mentorId) {
+ showToast("请先选择导师");
+ return;
+ }
+ const userMsg = { role: "user", content: text, created_at: new Date().toISOString() };
+ state.chat.messages = state.chat.messages.concat([userMsg]);
+ state.chat.followUps = [];
+ const input = document.getElementById("m-chat-input");
+ if (input) { input.value = ""; autoSizeChatInput(); }
+ renderChatStream();
+ renderChatPresets();
+ startChatStream(text);
+ }
+
+ function startChatStream(question) {
+ if (state.chat.streaming) return;
+ state.chat.streaming = true;
+ state.chat.streamBuffer = "";
+ state.chat.streamController = new AbortController();
+ syncChatSendEnabled();
+ // 插入等待气泡占位
+ const stream = document.getElementById("m-chat-stream");
+ if (stream) {
+ stream.insertAdjacentHTML("beforeend",
+ '');
+ scrollChatToBottom();
+ }
+ renderChatFollowUps();
+
+ const url = state.chat.page === "assistant/chat" ? "/api/assistant/chat" : "/api/mentors/chat";
+ const body = state.chat.page === "assistant/chat"
+ ? { question: question, trade_date: todayString() }
+ : { mentor_id: state.chat.mentorId, question: question, trade_date: todayString() };
+
+ const onEvent = function (event) {
+ if (!event || !event.type) return;
+ if (event.type === "delta" && typeof event.content === "string") {
+ state.chat.streamBuffer += event.content;
+ const bubble = document.querySelector("[data-stream-bubble]");
+ if (bubble) {
+ bubble.innerHTML = renderChatMarkdown(state.chat.streamBuffer) + '';
+ scrollChatToBottom();
+ }
+ } else if (event.type === "meta") {
+ if (Array.isArray(event.follow_ups) && event.follow_ups.length) {
+ state.chat.followUps = event.follow_ups.slice(0, 3);
+ }
+ renderChatFollowUps();
+ }
+ };
+
+ global.MobileAPI.streamNdjson(url, {
+ method: "POST",
+ body: body,
+ signal: state.chat.streamController.signal,
+ onEvent: onEvent,
+ errorMessage: state.chat.page === "assistant/chat" ? "智能解读失败" : "问师回复失败"
+ }).then(function () {
+ finalizeChatStream();
+ }).catch(function (err) {
+ const aborted = err && (err.name === "AbortError" || err.message === "请求已取消");
+ handleChatStreamError(err, aborted);
+ });
+ }
+
+ function finalizeChatStream() {
+ if (!state.chat.streaming) return;
+ const aiText = state.chat.streamBuffer;
+ if (aiText) {
+ const aiMsg = { role: "assistant", content: aiText, created_at: new Date().toISOString() };
+ state.chat.messages = state.chat.messages.concat([aiMsg]);
+ }
+ state.chat.streaming = false;
+ state.chat.streamController = null;
+ state.chat.streamBuffer = "";
+ state.chat.streamBubble = null;
+ syncChatSendEnabled();
+ renderChatStream();
+ renderChatPresets();
+ }
+
+ 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();
+ renderChatPresets();
+ return;
+ }
+ state.chat.streaming = false;
+ state.chat.streamController = null;
+ state.chat.streamBuffer = "";
+ syncChatSendEnabled();
+ // 渲染错误气泡 + 重试
+ const stream = document.getElementById("m-chat-stream");
+ if (stream) {
+ const placeholder = stream.querySelector("[data-stream-msg]");
+ if (placeholder) placeholder.remove();
+ stream.insertAdjacentHTML("beforeend",
+ '' +
+ '
' +
+ '' + escapeHtml((err && err.message) || "网络异常,请重试") + '' +
+ '' +
+ '
');
+ const btn = stream.querySelector("[data-chat-retry]");
+ if (btn) btn.addEventListener("click", function () {
+ // 找到上条用户消息并重试
+ const last = [...state.chat.messages].reverse().find(function (m) { return m.role === "user"; });
+ if (last) {
+ state.chat.messages = state.chat.messages.slice(0, state.chat.messages.length - 1);
+ renderChatStream();
+ sendChatMessage(last.content);
+ }
+ });
+ scrollChatToBottom();
+ }
+ }
+
+ function stopChatStream() {
+ if (!state.chat.streaming || !state.chat.streamController) return;
+ try { state.chat.streamController.abort(); } catch (_error) { /* noop */ }
+ }
+
+ function openMentorDrawer() {
+ const payload = state.chat.mentorSetup;
+ if (!payload) {
+ showToast("导师目录暂未加载");
+ return;
+ }
+ const mentors = payload.mentors || [];
+ const listHtml = mentors.map(function (m) {
+ const grade = (m.evidence && m.evidence.grade) || "";
+ const isCurrent = m.id === state.chat.mentorId;
+ return '';
+ }).join("") || '';
+ openSheet(
+ '选择导师
' +
+ '' +
+ '' +
+ '
' + icon("search", 16) + '
' +
+ '
' + listHtml + '
' +
+ '
',
+ { detail: false }
+ );
+ const sheetBody = document.querySelector(".m-sheet-body");
+ if (!sheetBody) return;
+ const input = sheetBody.querySelector("[data-mentor-drawer-search]");
+ const list = sheetBody.querySelector(".m-mentor-drawer-list");
+ if (input && list) {
+ input.addEventListener("input", function () {
+ const kw = input.value.trim().toLowerCase();
+ const rows = list.querySelectorAll(".m-mentor-row");
+ rows.forEach(function (row) {
+ const name = (row.querySelector(".m-mentor-row-name") || {}).textContent || "";
+ const tag = (row.querySelector(".m-mentor-row-tag") || {}).textContent || "";
+ const hit = !kw || name.toLowerCase().indexOf(kw) >= 0 || tag.toLowerCase().indexOf(kw) >= 0;
+ row.style.display = hit ? "" : "none";
+ });
+ });
+ }
+ // 导师行点击在 sheet 内绑定(m-view 上的全局 handler 抓不到 sheet 内点击)
+ if (list) {
+ list.addEventListener("click", function (event) {
+ const row = event.target.closest("[data-mentor-row]");
+ if (!row) return;
+ const id = row.dataset.mentorRow;
+ if (id) selectMentor(id);
+ });
+ }
+ }
+
/* ----- complex page registry ----- */
function setupComplexPage(key) {
@@ -1542,6 +2928,10 @@
"market/auction": setupComplexPage,
"market/themes": setupComplexPage,
"market/dragon": setupComplexPage,
+ "tools/screener": setupScreenerPage,
+ "tools/tracking": setupTrackingPage,
+ "tools/mentor": setupChatPage,
+ "assistant/chat": setupChatPage,
};
const COMPLEX_LOADERS = {
@@ -1551,6 +2941,10 @@
"market/auction": loadAuction,
"market/themes": loadThemes,
"market/dragon": loadDragon,
+ "tools/screener": loadScreener,
+ "tools/tracking": loadTracking,
+ "tools/mentor": loadChat,
+ "assistant/chat": loadChat,
};
function isComplexPage(key) {
@@ -1655,14 +3049,24 @@
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(state.requestedDate).getFullYear(),
- month: parseLocalDate(state.requestedDate).getMonth(),
+ year: parseLocalDate(baseDate).getFullYear(),
+ month: parseLocalDate(baseDate).getMonth(),
};
openSheet(
'' +
@@ -2178,7 +3582,86 @@
const row = event.target.closest("[data-code]");
if (row) {
const interactive = event.target.closest("button, a, input, select, textarea");
- if (!interactive) openDetailSheet(row.dataset.code);
+ if (!interactive) {
+ if (state.key === "tools/tracking" && row.dataset.trackId) {
+ openTrackingDetailSheet(row.dataset.code, row.dataset.trackId);
+ } else if (state.key === "tools/screener") {
+ openScreenerDetailSheet(row.dataset.code);
+ } else {
+ openDetailSheet(row.dataset.code);
+ }
+ }
+ }
+
+ // 智能选股:视图切换
+ const screenerView = event.target.closest("[data-screener-view]");
+ if (screenerView) {
+ const view = screenerView.dataset.screenerView;
+ if (view && view !== state.screener.view) {
+ state.screener.view = view;
+ state.sort = { key: "", dir: null };
+ renderScreener();
+ }
+ return;
+ }
+ // 智能选股:策略胶囊行(开抽屉)
+ const screenerOpen = event.target.closest("[data-screener-open-drawer]");
+ if (screenerOpen) { openScreenerStrategyDrawer(); return; }
+ // 智能选股:日期按钮
+ const screenerDate = event.target.closest("[data-screener-date]");
+ if (screenerDate) { openDateSheet(); return; }
+ // 策略跟踪:刷新按钮
+ const trackingRefresh = event.target.closest("[data-tracking-refresh]");
+ if (trackingRefresh) {
+ const icon = trackingRefresh.querySelector("svg");
+ if (icon) icon.classList.add("is-spinning");
+ refreshTracking();
+ return;
+ }
+
+ // 聊天工作台(复盘助手 / 问师):标题区操作位
+ const chatClear = event.target.closest("[data-chat-clear]");
+ if (chatClear) {
+ if (state.chat.page === "assistant/chat") {
+ openConfirmSheet("清空对话", "将删除当前账号的全部复盘助手对话历史,无法恢复。", {
+ danger: true,
+ confirmLabel: "清空",
+ onConfirm: clearAssistantMessages
+ });
+ } else if (state.chat.page === "tools/mentor") {
+ if (!state.chat.mentorId) { showToast("请先选择导师"); return; }
+ openConfirmSheet("清空当日对话", "将清空该导师今天的对话记录,无法恢复。", {
+ danger: true,
+ confirmLabel: "清空",
+ onConfirm: function () { clearMentorMessages(state.chat.mentorId, todayString()); }
+ });
+ }
+ return;
+ }
+ const chatMentors = event.target.closest("[data-chat-mentors]");
+ if (chatMentors) { openMentorDrawer(); return; }
+ const chatSend = event.target.closest("[data-chat-send]");
+ if (chatSend) {
+ if (state.chat.streaming) stopChatStream();
+ else submitChatMessage();
+ return;
+ }
+ const chatPreset = event.target.closest("[data-chat-preset]");
+ if (chatPreset) { sendChatMessage(chatPreset.dataset.chatPreset); return; }
+ const chatFollowup = event.target.closest("[data-chat-followup]");
+ if (chatFollowup) { sendChatMessage(chatFollowup.dataset.chatFollowup); return; }
+ const chatRemoveMentor = event.target.closest("[data-chat-remove-mentor]");
+ if (chatRemoveMentor) {
+ state.chat.mentorId = "";
+ state.chat.mentorName = "";
+ state.chat.mentorTagline = "";
+ state.chat.mentorGrade = "";
+ state.chat.followUps = [];
+ state.chat.messages = [];
+ renderChatStream();
+ renderChatMentorBar();
+ renderChatPresets();
+ return;
}
});
}
diff --git a/app/frontend/m/js/router.js b/app/frontend/m/js/router.js
index c838386..e000191 100644
--- a/app/frontend/m/js/router.js
+++ b/app/frontend/m/js/router.js
@@ -233,6 +233,10 @@
}
function renderAssistant() {
+ if (global.MobilePages && global.MobilePages.has("assistant/chat")) {
+ global.MobilePages.render("assistant/chat");
+ return;
+ }
updateHeader({ title: "复盘助手", back: false });
document.getElementById("m-view").innerHTML = placeholderHtml("复盘助手", "聊天工作台将在后续批次实现。");
}