From 32d82e59378ae16997b82a0281697d0f471053da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=BD=E5=B7=A5=E5=91=98?= Date: Mon, 24 Aug 2026 22:58:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=BD=E5=B7=A5=EF=BC=9A=E6=89=8B=E6=9C=BA?= =?UTF-8?q?=E7=AB=AF=E7=AC=AC=E5=9B=9B=E6=89=B9=20P3a=EF=BC=88=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E9=80=89=E8=82=A1/=E7=AD=96=E7=95=A5=E8=B7=9F?= =?UTF-8?q?=E8=B8=AA/=E9=97=AE=E5=B8=88/=E5=A4=8D=E7=9B=98=E5=8A=A9?= =?UTF-8?q?=E6=89=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- frontend/m/css/features.css | 565 ++++++++++++- frontend/m/js/api.js | 8 +- frontend/m/js/pages.js | 1489 ++++++++++++++++++++++++++++++++++- frontend/m/js/router.js | 4 + 4 files changed, 2061 insertions(+), 5 deletions(-) diff --git a/frontend/m/css/features.css b/frontend/m/css/features.css index 943bc86..9c979d0 100644 --- a/frontend/m/css/features.css +++ b/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/frontend/m/js/api.js b/frontend/m/js/api.js index 07237e2..1c5e2a8 100644 --- a/frontend/m/js/api.js +++ b/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/frontend/m/js/pages.js b/frontend/m/js/pages.js index e8b8b71..102b380 100644 --- a/frontend/m/js/pages.js +++ b/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 = '' + head + '' + body + '
'; + } + + 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 '' + + '
' + 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 = '
' + head + '' + body + '
'; + + 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( + '

选择导师

' + + '
' + + '
' + + '' + + '
' + 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/frontend/m/js/router.js b/frontend/m/js/router.js index c838386..e000191 100644 --- a/frontend/m/js/router.js +++ b/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("复盘助手", "聊天工作台将在后续批次实现。"); }