From 291a61f6acf94f5f7327752f483c056ce0944653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=BD=E5=B7=A5=E5=91=98?= <4846a27a-ba87-49c9-939d-7275f63efcc7@agents.multica.local> Date: Tue, 25 Aug 2026 01:54:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=BD=E5=B7=A5(HEL-105):=20=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E7=AB=AF=E7=AC=AC=E5=9B=9B=E6=89=B9=20P3b=20=E6=88=91?= =?UTF-8?q?=E7=9A=84=E5=A4=8D=E7=9B=98=E4=BA=94=E9=A1=B5=E2=80=94=E2=80=94?= =?UTF-8?q?=E8=87=AA=E9=80=89=E8=82=A1/=E4=BA=A4=E6=98=93=E6=97=A5?= =?UTF-8?q?=E5=BF=97/=E6=AF=8F=E6=97=A5=E5=A4=8D=E7=9B=98/=E4=B8=AA?= =?UTF-8?q?=E8=82=A1=E7=AC=94=E8=AE=B0/=E6=8F=90=E9=86=92=E4=B8=AD?= =?UTF-8?q?=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 review 五页路由与渲染(COMPLEX_PAGES/LOADERS 注册 setupReviewPage/loadReview) - 自选股:颜色圆点+关注分列表、无排序例外、点行双图详情、移出自选本地同步 - 交易日志:五格汇总条、动作徽标、盈亏红绿、详情 Sheet 编辑/删除、全屏表单(13 字段) - 每日复盘:查看态三卡+历史复盘分组+日期切换、全屏编辑表单(三 textarea) - 个股笔记:按股票分组列表、详情抽屉、新增笔记页(股票搜索选择器) - 提醒中心:类型徽标/未读/到期规则、全部已读、新增提醒抽屉、关联股票详情 - 表单控件族(text/textarea/number/date/胶囊组/股票搜索)、字数统计、必填校验、提交条贴键盘 - 图标补充 check/check-check/star/scroll-text/calendar-check/sticky-note/bell - 全屏表单返回拦截(capture 阶段)与放弃编辑确认;零新依赖、仅动 m/ 前端 Co-authored-by: multica-agent --- app/frontend/m/css/features.css | 375 ++++++++- app/frontend/m/js/pages.js | 1278 ++++++++++++++++++++++++++++++- 2 files changed, 1651 insertions(+), 2 deletions(-) diff --git a/app/frontend/m/css/features.css b/app/frontend/m/css/features.css index 9d2c30a..455d1a2 100644 --- a/app/frontend/m/css/features.css +++ b/app/frontend/m/css/features.css @@ -2751,7 +2751,380 @@ .m-mentor-pill:active, .m-chat-send:active, .m-screener-drawer-row:active, - .m-mentor-row:active { + .m-mentor-row:active, + .m-seg-opt:active, + .m-note-card:active, + .m-alert-row:active, + .m-daily-history-row:active, + .m-stock-picker-row:active { transform: none; } } + +/* ============== 我的复盘(P3b) ============== */ +.m-num-strong { font-weight: 600; } +.m-cell-text--empty { color: var(--text-tertiary); } + +/* 自选股:颜色圆点标记 */ +.m-stock--watch { flex-direction: row; align-items: center; gap: 8px; } +.m-stock-copy { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; min-width: 0; } +.m-stock-copy .m-stock-name { max-width: 100%; } +.m-watch-dot { flex: 0 0 auto; width: 4px; height: 14px; border-radius: 999px; } +.m-watch-dot--red { background: var(--market-up); } +.m-watch-dot--blue { background: var(--action); } +.m-watch-dot--green { background: var(--market-down); } +.m-watch-dot--amber { background: var(--warning); } + +/* 交易动作徽标 */ +.m-trade-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; +} +.m-trade-badge--up { background: var(--market-up-soft); color: var(--market-up); } +.m-trade-badge--down { background: var(--market-down-soft); color: var(--market-down); } +.m-trade-badge--flat { background: var(--surface-muted); color: var(--text-secondary); } + +/* 详情 Sheet 内的通用正文块 */ +.m-detail-text { + padding: 10px 12px; + background: var(--surface); + border-radius: 8px; + font-size: 14px; + line-height: 1.6; + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-word; +} + +/* 交易详情 */ +.m-trade-detail { padding: 0 12px; } +.m-trade-detail-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 9px 12px; + border-bottom: 1px solid var(--border-subtle); + font-size: 13.5px; + gap: 12px; +} +.m-trade-detail-label { color: var(--text-secondary); flex: 0 0 auto; } +.m-trade-detail-value { color: var(--text-primary); text-align: right; font-variant-numeric: tabular-nums; word-break: break-word; } +.m-trade-detail-value.up { color: var(--market-up); } +.m-trade-detail-value.down { color: var(--market-down); } + +/* 每日复盘 */ +.m-daily-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 2px 8px; +} +.m-daily-date { font-size: 13px; color: var(--text-secondary); } +.m-daily-edit { + appearance: none; + border: none; + background: transparent; + color: var(--action); + font-size: 13px; + font-weight: 600; + cursor: pointer; + padding: 6px; +} +.m-daily-edit:active { opacity: 0.7; } +.m-daily-card { + margin-bottom: 12px; + padding: 12px; + border-radius: 8px; + background: var(--surface); +} +.m-daily-card-title { font-size: 15px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; } +.m-daily-card-body { margin: 0; font-size: 14px; line-height: 1.6; color: var(--text-primary); white-space: pre-wrap; word-break: break-word; } +.m-daily-delete { + margin: 8px 2px 16px; + background: transparent; + border: none; + color: var(--market-up); + font-size: 13px; + cursor: pointer; + padding: 8px 0; + height: auto; +} +.m-daily-delete:active { opacity: 0.7; } +.m-daily-history { margin: 4px 0 16px; } +.m-daily-history-list { display: flex; flex-direction: column; } +.m-daily-history-row { + appearance: none; + border: none; + background: transparent; + border-bottom: 1px solid var(--border); + padding: 0 2px; + min-height: 52px; + display: flex; + align-items: center; + gap: 12px; + text-align: left; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} +.m-daily-history-row:active { background: var(--surface-hover); } +.m-daily-history-row.is-active { background: var(--surface-selected); } +.m-daily-history-date { flex: 0 0 auto; font-size: 14px; font-weight: 500; color: var(--text-primary); font-variant-numeric: tabular-nums; } +.m-daily-history-line { flex: 1 1 auto; min-width: 0; font-size: 12px; color: var(--text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* 个股笔记 */ +.m-note-group { margin-bottom: 16px; } +.m-note-group-head { + display: flex; + align-items: baseline; + gap: 8px; + height: 40px; + padding: 0 2px; +} +.m-note-group-name { font-size: 14px; font-weight: 600; color: var(--text-primary); } +.m-note-group-code { font-size: 11.5px; color: var(--text-tertiary); font-variant-numeric: tabular-nums; } +.m-note-group-count { margin-left: auto; font-size: 12px; color: var(--text-tertiary); } +.m-note-group-list { display: flex; flex-direction: column; gap: 8px; } +.m-note-card { + appearance: none; + border: none; + text-align: left; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + padding: 12px; + border-radius: 8px; + background: var(--surface); + display: flex; + flex-direction: column; + gap: 6px; +} +.m-note-card:active { background: var(--surface-hover); transform: scale(0.99); } +.m-note-card-date { font-size: 12px; color: var(--text-tertiary); font-variant-numeric: tabular-nums; } +.m-note-card-body { + font-size: 14px; + line-height: 1.5; + color: var(--text-primary); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.m-note-card-plan { + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.m-note-detail-block { margin-bottom: 16px; } + +/* 提醒中心 */ +.m-alert-tabs { margin-bottom: 12px; } +.m-alert-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--market-up); + margin-left: 4px; + vertical-align: middle; +} +.m-alert-list { display: flex; flex-direction: column; } +.m-alert-row { + appearance: none; + border: none; + background: transparent; + border-bottom: 1px solid var(--border); + padding: 12px 4px; + min-height: 64px; + display: flex; + align-items: center; + gap: 10px; + text-align: left; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + width: 100%; +} +.m-alert-row:active { background: var(--surface-hover); } +.m-alert-row-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 4px; } +.m-alert-row-top { display: flex; align-items: center; gap: 8px; } +.m-alert-row-date { font-size: 12px; color: var(--text-tertiary); font-variant-numeric: tabular-nums; } +.m-alert-row-title { font-size: 14px; color: var(--text-primary); display: flex; align-items: center; gap: 6px; } +.m-alert-row.is-unread .m-alert-row-title { font-weight: 600; } +.m-alert-row.is-read .m-alert-row-title { color: var(--text-secondary); font-weight: 400; } +.m-alert-unread-dot { flex: 0 0 auto; width: 6px; height: 6px; border-radius: 50%; background: var(--action); } +.m-alert-row-summary { + font-size: 12px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.m-alert-row-chevron { flex: 0 0 auto; color: var(--text-tertiary); display: inline-flex; } +.m-alert-kind { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 4px; + font-size: 11.5px; + font-weight: 500; + white-space: nowrap; +} +.m-alert-kind--action { background: var(--action-soft); color: var(--action); } +.m-alert-kind--warn { background: var(--warning-soft); color: var(--warning); } +.m-alert-kind--down { background: var(--market-down-soft); color: var(--market-down); } +.m-alert-detail { padding: 0 4px; } +.m-alert-detail-title { margin: 12px 0 4px; font-size: 16px; font-weight: 600; color: var(--text-primary); } +.m-alert-detail-meta { font-size: 12px; color: var(--text-tertiary); margin-bottom: 12px; } +.m-alert-stock { + appearance: none; + border: 1px solid var(--border-strong); + background: var(--surface); + color: var(--action); + border-radius: 999px; + padding: 6px 14px; + font-size: 13px; + cursor: pointer; + margin: 8px 0; + -webkit-tap-highlight-color: transparent; +} +.m-alert-stock:active { background: var(--action-soft); } + +/* 表单控件族 */ +.m-form-field textarea { + min-height: 88px; + padding: 10px 12px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--surface); + color: var(--text-primary); + font-size: 14px; + line-height: 1.5; + font-family: inherit; + resize: none; +} +.m-form-field textarea:focus { outline: none; border-color: var(--action); } +.m-form-field input[type="date"] { padding-right: 36px; } +.m-form-field .m-field-req { + font-style: normal; + color: var(--market-up); + margin-left: 2px; +} +.m-field-textarea { display: flex; flex-direction: column; gap: 2px; } +.m-form-field .m-field-counter { font-size: 11.5px; color: var(--text-tertiary); text-align: right; } +.m-form-field .m-field-error { font-size: 12px; color: var(--market-up); } +.m-form-field.is-invalid input, +.m-form-field.is-invalid textarea { border-color: var(--market-up); } +.m-input-unit { position: relative; display: flex; align-items: center; } +.m-input-unit input { width: 100%; padding-right: 40px; } +.m-form-field .m-input-unit-suffix { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + font-size: 12px; + color: var(--text-tertiary); + pointer-events: none; +} +.m-input-date { position: relative; display: flex; align-items: center; } +.m-input-date input { width: 100%; } +.m-input-date input::-webkit-calendar-picker-indicator { opacity: 0; position: absolute; right: 0; width: 100%; height: 100%; cursor: pointer; } +.m-form-field .m-input-date-icon { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--text-tertiary); + display: inline-flex; + pointer-events: none; +} +.m-seg { display: flex; flex-wrap: wrap; gap: 8px; } +.m-seg-opt { + height: 36px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid var(--border-strong); + background: transparent; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} +.m-seg-opt:active { transform: scale(0.97); } +.m-seg-opt.active { + background: var(--action-soft); + border-color: var(--action); + color: var(--action); + font-weight: 600; +} +.m-stock-picker { position: relative; } +.m-stock-picker-results { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: var(--surface); + border-radius: 8px; + box-shadow: var(--elevation-float); + max-height: 240px; + overflow-y: auto; + z-index: 10; +} +.m-stock-picker-row { + appearance: none; + border: none; + background: transparent; + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + height: 44px; + cursor: pointer; + width: 100%; + text-align: left; + -webkit-tap-highlight-color: transparent; +} +.m-stock-picker-row:active { background: var(--surface-hover); } +.m-stock-picker-row-name { font-size: 14px; font-weight: 600; color: var(--text-primary); } +.m-stock-picker-row-sub { font-size: 11.5px; color: var(--text-tertiary); flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.m-stock-picker-row-code { font-size: 12px; color: var(--text-secondary); font-variant-numeric: tabular-nums; } +.m-stock-picker-empty { padding: 12px; font-size: 13px; color: var(--text-tertiary); text-align: center; } +.m-stock-chip { + display: flex; + align-items: center; + gap: 8px; + height: 44px; + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--surface); +} +.m-stock-chip-name { font-size: 14px; font-weight: 600; color: var(--text-primary); } +.m-stock-chip-code { font-size: 12px; color: var(--text-tertiary); font-variant-numeric: tabular-nums; } +.m-stock-chip-clear { + margin-left: auto; + color: var(--text-tertiary); + background: none; + border: none; + cursor: pointer; + display: inline-flex; + padding: 4px; + -webkit-tap-highlight-color: transparent; +} +.m-stock-chip-clear:active { color: var(--text-primary); } + +/* 全屏表单页 */ +.m-form-body { padding: 12px 12px 16px; } +.m-form-bar { + flex: 0 0 auto; + display: flex; + gap: 8px; + padding: 8px 12px calc(8px + var(--mobile-safe-bottom)); + background: var(--surface); + border-top: 1px solid var(--border); + transform: translateY(calc(-1 * var(--m-keyboard-inset, 0px))); +} diff --git a/app/frontend/m/js/pages.js b/app/frontend/m/js/pages.js index ece9f5e..251a665 100644 --- a/app/frontend/m/js/pages.js +++ b/app/frontend/m/js/pages.js @@ -83,6 +83,13 @@ send: '', square: '', "alert-triangle": '', + check: '', + "check-check": '', + star: '', + "scroll-text": '', + "calendar-check": '', + "sticky-note": '', + bell: '', }; function icon(name, size) { @@ -168,6 +175,17 @@ loading: false, refreshing: false, }, + review: { + subKey: "", + watchlist: null, + trades: null, + notes: null, + dailyItems: null, + alerts: null, + alertStatus: "all", + form: null, + dirty: false, + }, }; let sheetToken = 0; @@ -3044,6 +3062,11 @@ "tools/tracking": setupTrackingPage, "tools/mentor": setupChatPage, "assistant/chat": setupChatPage, + "review/watchlist": setupReviewPage, + "review/trades": setupReviewPage, + "review/daily": setupReviewPage, + "review/notes": setupReviewPage, + "review/alerts": setupReviewPage, }; const COMPLEX_LOADERS = { @@ -3057,6 +3080,11 @@ "tools/tracking": loadTracking, "tools/mentor": loadChat, "assistant/chat": loadChat, + "review/watchlist": loadReview, + "review/trades": loadReview, + "review/daily": loadReview, + "review/notes": loadReview, + "review/alerts": loadReview, }; function isComplexPage(key) { @@ -3351,6 +3379,13 @@ detail.watchlist = adding; updateStarButton(); showToast(adding ? "已加入自选" : "已移出自选"); + // 自选股列表页在背后:同步移除该行,避免关 Sheet 后仍显示已移除标的 + if (!adding && state.review && state.review.watchlist) { + state.review.watchlist.items = (state.review.watchlist.items || []).filter(function (it) { + return String(it.code) !== String(code); + }); + renderReviewWatchlist(); + } }).catch(function () { updateStarButton(); showToast("操作失败,请重试"); @@ -3571,11 +3606,1192 @@ ""; } + /* ============================================================== 我的复盘(P3b) ============================================================== */ + + const TRADE_ACTION_OPTIONS = [ + { key: "buy", label: "买入", tone: "up" }, + { key: "add", label: "加仓", tone: "up" }, + { key: "sell", label: "卖出", tone: "down" }, + { key: "trim", label: "减仓", tone: "down" }, + { key: "watch", label: "观察", tone: "flat" } + ]; + const TRADE_EMOTION_OPTIONS = [ + { key: "calm", label: "平静" }, + { key: "confident", label: "笃定" }, + { key: "hesitant", label: "犹豫" }, + { key: "anxious", label: "焦虑" }, + { key: "impulsive", label: "冲动" } + ]; + + function reviewSubKey(key) { + const parts = String(key || "").split("/"); + return parts[1] || ""; + } + + function reviewTitle(key) { + const map = { + watchlist: "自选股", + trades: "交易日志", + daily: "每日复盘", + notes: "个股笔记", + alerts: "提醒中心" + }; + return map[reviewSubKey(key)] || "我的复盘"; + } + + function headerActionButton(dataAttr, iconName, ariaLabel) { + return '"; + } + + function setupReviewPage(key) { + const subKey = reviewSubKey(key); + state.key = key; + state.requestedDate = todayString(); + state.sort = { key: "", dir: null }; + state.sortTable = { cols: null, reapply: null }; + state.detail = null; + state.review.subKey = subKey; + state.review.form = null; + state.review.dirty = false; + state.review.watchlist = null; + state.review.trades = null; + state.review.notes = null; + state.review.dailyItems = null; + state.review.alerts = null; + state.review.alertStatus = "all"; + + document.getElementById("m-view").classList.add("m-view-feature"); + const title = reviewTitle(key); + let actions = ""; + if (subKey === "trades") { + actions = headerActionButton("review-add-trade", "plus", "新增交易"); + } else if (subKey === "notes") { + actions = headerActionButton("review-add-note", "plus", "新增笔记"); + } else if (subKey === "alerts") { + actions = + headerActionButton("review-add-alert", "plus", "新增提醒") + + '"; + } + if (subKey === "daily") { + updateHeader(title, state.requestedDate); + } else { + global.MobileRouter.updateHeader({ title: title, back: true, actions: actions }); + } + document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(6))); + } + + function loadReview() { + const subKey = state.review.subKey; + if (subKey === "watchlist") loadReviewWatchlist(); + else if (subKey === "trades") loadReviewTrades(); + else if (subKey === "daily") loadReviewDaily(); + else if (subKey === "notes") loadReviewNotes(); + else if (subKey === "alerts") loadReviewAlerts(); + } + + function reviewState(kind) { + return '

' + escapeHtml(kind) + "加载失败

" + + '
'; + } + + /* ----- 自选股 ----- */ + + function loadReviewWatchlist() { + const seq = nextSeq(); + global.MobileAPI.request("/api/watchlist?trade_date=" + encodeURIComponent(todayString())).then(function (payload) { + if (seq !== state.seq) return; + state.review.watchlist = payload || { items: [] }; + renderReviewWatchlist(); + }).catch(function (error) { + if (seq !== state.seq) return; + const scroll = document.getElementById("m-scroll"); + if (scroll) scroll.innerHTML = reviewState("自选股"); + }); + } + + function watchDotColor(color) { + return { red: true, blue: true, green: true, amber: true }[color] ? color : "red"; + } + + function watchStockCell(row) { + const color = watchDotColor(row.color); + return '' + + '' + + '' + + '' + escapeHtml(row.name || "--") + "" + + '' + escapeHtml(row.code || "") + "" + + ""; + } + + function renderReviewWatchlist() { + const scroll = document.getElementById("m-scroll"); + if (!scroll) return; + const data = state.review.watchlist || { items: [] }; + const rows = data.items || []; + if (!rows.length) { + scroll.innerHTML = '
' + + '' + icon("star", 26) + "" + + "

还没有自选股

" + + "在任意股票详情点星形加入" + + "
"; + return; + } + const cols = { + frozenColumns: [{ key: "stock", label: "股票", type: "stock", width: 112 }], + primaryColumns: [ + { key: "change", label: "当日涨幅", type: "change" }, + { key: "attention_score", label: "关注分", type: "score" } + ], + scrollColumns: [ + { key: "sector", label: "板块", type: "text" }, + { key: "return_5d", label: "5日涨幅", type: "change" }, + { key: "remark", label: "跟踪备注", type: "text" } + ] + }; + state.sortTable = { cols: cols, reapply: renderReviewWatchlist }; + const sorted = sortedRows(rows, cols); + scroll.innerHTML = '
' + buildReviewTable(cols, sorted, { + noSort: true, + cellRenderer: function (col, row) { + if (col.key === "stock") return watchStockCell(row); + if (col.key === "attention_score") { + if (row.attention_score == null || row.attention_score === "") return ''; + return '' + formatNumber(number(row.attention_score), 1) + ""; + } + if (col.key === "remark") { + const text = String(row.remark == null ? "" : row.remark); + if (!text) return '--'; + return '' + escapeHtml(text) + ""; + } + return null; + } + }) + "
"; + } + + /* ----- 交易日志 ----- */ + + function loadReviewTrades() { + const seq = nextSeq(); + global.MobileAPI.request("/api/trades").then(function (payload) { + if (seq !== state.seq) return; + state.review.trades = payload || { items: [], summary: {} }; + renderReviewTrades(); + }).catch(function (error) { + if (seq !== state.seq) return; + const scroll = document.getElementById("m-scroll"); + if (scroll) scroll.innerHTML = reviewState("交易日志"); + }); + } + + function tradeActionBadge(action) { + const opt = TRADE_ACTION_OPTIONS.find(function (o) { return o.key === action; }); + const label = opt ? opt.label : (action || "--"); + const tone = opt ? opt.tone : "flat"; + return '' + escapeHtml(label) + ""; + } + + function renderReviewTrades() { + const scroll = document.getElementById("m-scroll"); + if (!scroll) return; + const data = state.review.trades || { items: [], summary: {} }; + const items = data.items || []; + const summary = data.summary || {}; + if (!items.length) { + scroll.innerHTML = '
' + + '' + icon("scroll-text", 26) + "" + + "

还没有交易记录

" + + "记下每一次买卖,回头复盘更有据" + + '' + + "
"; + return; + } + const pnlAmount = summary.pnl_amount; + const avgPos = summary.average_position; + const strip = renderStrip5([ + { label: "记录", value: String(summary.total != null ? summary.total : items.length) }, + { label: "已实现", value: String(summary.realized != null ? summary.realized : 0) }, + { label: "胜率", value: summary.win_rate != null ? summary.win_rate + "%" : "--" }, + { label: "累计盈亏", value: pnlAmount == null ? "--" : (pnlAmount > 0 ? "+" : "") + formatNumber(pnlAmount, 2), tone: pnlAmount == null ? "" : (pnlAmount > 0 ? "up" : (pnlAmount < 0 ? "down" : "")) }, + { label: "平均仓位", value: avgPos == null ? "--" : avgPos + "%" } + ]); + + const cols = { + frozenColumns: [{ key: "stock", label: "股票", type: "stock", width: 96 }], + primaryColumns: [ + { key: "action_label", label: "动作", type: "text" }, + { key: "pnl_pct", label: "盈亏%", type: "change" } + ], + scrollColumns: [ + { key: "trade_date", label: "日期", type: "text" }, + { key: "position_pct", label: "仓位%", type: "rate" }, + { key: "pnl_amount", label: "盈亏额", type: "change" }, + { key: "emotion_label", label: "情绪", type: "text" } + ] + }; + const rows = items.map(function (it) { + return { + code: it.code, + name: it.name, + action: it.action, + action_label: it.action_label, + pnl_pct: it.pnl_pct, + trade_date: displayCompactDate(it.trade_date), + position_pct: it.position_pct, + pnl_amount: it.pnl_amount, + emotion_label: it.emotion_label, + id: it.id, + _raw: it + }; + }); + state.sortTable = { cols: cols, reapply: renderReviewTrades }; + const sorted = sortedRows(rows, cols); + const table = buildReviewTable(cols, sorted, { + cellRenderer: function (col, row) { + if (col.key === "action_label") return tradeActionBadge(row.action); + if (col.key === "pnl_pct") { + if (row.pnl_pct == null || row.pnl_pct === "") return '--'; + return '' + (number(row.pnl_pct) > 0 ? "+" : "") + formatNumber(number(row.pnl_pct), 2) + ""; + } + if (col.key === "pnl_amount") { + if (row.pnl_amount == null || row.pnl_amount === "") return '--'; + return '' + (number(row.pnl_amount) > 0 ? "+" : "") + formatNumber(number(row.pnl_amount), 2) + ""; + } + if (col.key === "position_pct") { + if (row.position_pct == null || row.position_pct === "") return '--'; + return '' + formatNumber(number(row.position_pct), 1) + ""; + } + return null; + }, + rowAttrs: function (row) { + return ' data-trade-id="' + escapeHtml(String(row.id || "")) + '"'; + } + }); + scroll.innerHTML = strip + '
' + table + "
"; + } + + function openTradeDetail(tradeId) { + const data = state.review.trades || { items: [] }; + const item = (data.items || []).find(function (it) { return String(it.id) === String(tradeId); }); + if (!item) return; + openSheet( + '

交易详情

' + + '
" + + '
' + tradeDetailBody(item) + "
", + { detail: false } + ); + } + + function tradeDetailBody(item) { + const rows = [ + ["股票", (item.name || "--") + " " + (item.code || "")], + ["日期", displayCompactDate(item.trade_date)], + ["动作", item.action_label || "--"], + ["价格", item.price != null ? formatNumber(number(item.price), 2) : "--"], + ["数量", item.quantity != null ? intOrDash(item.quantity) : "--"], + ["仓位", item.position_pct != null ? formatNumber(number(item.position_pct), 1) + "%" : "--"], + ["盈亏额", item.pnl_amount == null ? "--" : (number(item.pnl_amount) > 0 ? "+" : "") + formatNumber(number(item.pnl_amount), 2)], + ["盈亏%", item.pnl_pct == null ? "--" : (number(item.pnl_pct) > 0 ? "+" : "") + formatNumber(number(item.pnl_pct), 2) + "%"], + ["情绪", item.emotion_label || "--"], + ["标签", (item.tags && item.tags.length) ? item.tags.join("、") : "--"] + ]; + const toneRow = item.pnl_amount == null ? "" : (number(item.pnl_amount) > 0 ? "up" : number(item.pnl_amount) < 0 ? "down" : ""); + const grid = rows.map(function (pair) { + const isPnl = pair[0] === "盈亏额"; + return '
' + escapeHtml(pair[0]) + "" + + '' + escapeHtml(pair[1]) + "
"; + }).join(""); + const block = function (title, text) { + return '
' + escapeHtml(title) + "
" + + '
' + escapeHtml(text || "尚未填写") + "
"; + }; + return '
' + grid + "
" + + '
' + + block("交易逻辑", item.thesis) + + block("执行复核", item.execution) + + '
' + + '' + + '' + + "
"; + } + + function deleteTrade(tradeId) { + openConfirmSheet("删除交易记录", "删除后无法恢复,确定删除这条交易记录吗?", { + danger: true, + confirmLabel: "删除", + onConfirm: function () { + global.MobileAPI.request("/api/trades/" + encodeURIComponent(tradeId), "DELETE").then(function (payload) { + state.review.trades = { items: payload.items || [], summary: payload.summary || {} }; + closeSheet(); + renderReviewTrades(); + showToast("交易记录已删除"); + }).catch(function (error) { + showToast(error && error.message ? error.message : "删除失败"); + }); + } + }); + } + + /* ----- 每日复盘 ----- */ + + function loadReviewDaily() { + const seq = nextSeq(); + global.MobileAPI.request("/api/notes?scope=daily").then(function (payload) { + if (seq !== state.seq) return; + state.review.dailyItems = (payload && payload.items) || []; + updateHeader("每日复盘", state.requestedDate); + renderReviewDaily(); + }).catch(function (error) { + if (seq !== state.seq) return; + const scroll = document.getElementById("m-scroll"); + if (scroll) scroll.innerHTML = reviewState("每日复盘"); + }); + } + + function dailyNoteFor(dateStr) { + const items = state.review.dailyItems || []; + const compact = String(dateStr || "").replaceAll("-", ""); + return items.find(function (it) { return String(it.trade_date || "").replaceAll("-", "") === compact; }) || null; + } + + function renderReviewDaily() { + const scroll = document.getElementById("m-scroll"); + if (!scroll) return; + const dateStr = state.requestedDate || todayString(); + const note = dailyNoteFor(dateStr); + const items = state.review.dailyItems || []; + + const dateLine = '
' + + '' + escapeHtml(displayCompactDate(dateStr)) + "" + + '" + + "
"; + + if (!note) { + const history = dailyHistoryBlock(items, dateStr); + scroll.innerHTML = '
' + + '' + icon("calendar-check", 26) + "" + + "

今日还未复盘

" + + '花几分钟回顾今天的盘面与操作' + + '' + + "
" + history; + return; + } + + const card = function (title, text) { + return '
' + + '
' + escapeHtml(title) + "
" + + '

' + escapeHtml(text || "尚未填写") + "

" + + "
"; + }; + + const history = dailyHistoryBlock(items, dateStr); + scroll.innerHTML = dateLine + + card("今日盘面", note.summary) + + card("做对做错", note.content) + + card("明日计划", note.plan) + + '' + + history; + } + + function dailyHistoryBlock(items, currentDate) { + const sorted = (items || []).slice().sort(function (a, b) { + return String(b.trade_date || "").localeCompare(String(a.trade_date || "")); + }); + if (!sorted.length) return ""; + const rows = sorted.map(function (it) { + const active = String(it.trade_date || "").replaceAll("-", "") === String(currentDate || "").replaceAll("-", ""); + const oneLine = String(it.summary || "").trim() || "(无盘面摘要)"; + return '"; + }).join(""); + return '
' + + '
历史复盘
' + + '
' + rows + "
" + + "
"; + } + + function deleteDailyNote(noteId) { + openConfirmSheet("删除此日复盘", "删除后无法恢复,确定删除这一天的复盘吗?", { + danger: true, + confirmLabel: "删除", + onConfirm: function () { + global.MobileAPI.request("/api/notes/" + encodeURIComponent(noteId), "DELETE").then(function () { + state.review.dailyItems = (state.review.dailyItems || []).filter(function (it) { return String(it.id) !== String(noteId); }); + closeSheet(); + renderReviewDaily(); + showToast("复盘已删除"); + }).catch(function (error) { + showToast(error && error.message ? error.message : "删除失败"); + }); + } + }); + } + + /* ----- 个股笔记 ----- */ + + function loadReviewNotes() { + const seq = nextSeq(); + global.MobileAPI.request("/api/notes?scope=stock").then(function (payload) { + if (seq !== state.seq) return; + state.review.notes = (payload && payload.items) || []; + renderReviewNotes(); + }).catch(function (error) { + if (seq !== state.seq) return; + const scroll = document.getElementById("m-scroll"); + if (scroll) scroll.innerHTML = reviewState("个股笔记"); + }); + } + + function groupNotesByStock(items) { + const groups = {}; + (items || []).forEach(function (it) { + const key = it.code || "_"; + if (!groups[key]) groups[key] = { code: it.code, name: it.stock_name || it.code || "--", notes: [] }; + groups[key].notes.push(it); + }); + return Object.keys(groups).map(function (k) { return groups[k]; }); + } + + function renderReviewNotes() { + const scroll = document.getElementById("m-scroll"); + if (!scroll) return; + const items = state.review.notes || []; + if (!items.length) { + scroll.innerHTML = '
' + + '' + icon("sticky-note", 26) + "" + + "

还没有个股笔记

" + + "把对个股的观察与计划记下来" + + '' + + "
"; + return; + } + const groups = groupNotesByStock(items); + scroll.innerHTML = groups.map(function (g) { + const cards = g.notes.map(function (it) { + const plan = it.plan ? '计划:' + escapeHtml(it.plan) + "" : ""; + return '"; + }).join(""); + return '
' + + '
' + + '' + escapeHtml(g.name) + "" + + '' + escapeHtml(g.code || "") + "" + + '' + g.notes.length + " 条" + + "
" + + '
' + cards + "
" + + "
"; + }).join(""); + } + + function openNoteDetail(noteId) { + const item = (state.review.notes || []).find(function (it) { return String(it.id) === String(noteId); }); + if (!item) return; + openSheet( + '

' + escapeHtml((item.stock_name || item.code || "笔记") + " · " + displayCompactDate(item.trade_date)) + "

" + + '
" + + '
' + + '
笔记
' + escapeHtml(item.content || "尚未填写") + "
" + + '
计划
' + escapeHtml(item.plan || "尚未填写") + "
" + + '
' + + '' + + "
", + { detail: false } + ); + } + + function deleteNote(noteId) { + openConfirmSheet("删除笔记", "删除后无法恢复,确定删除这条笔记吗?", { + danger: true, + confirmLabel: "删除", + onConfirm: function () { + global.MobileAPI.request("/api/notes/" + encodeURIComponent(noteId), "DELETE").then(function () { + state.review.notes = (state.review.notes || []).filter(function (it) { return String(it.id) !== String(noteId); }); + closeSheet(); + renderReviewNotes(); + showToast("笔记已删除"); + }).catch(function (error) { + showToast(error && error.message ? error.message : "删除失败"); + }); + } + }); + } + + /* ----- 提醒中心 ----- */ + + function loadReviewAlerts() { + const seq = nextSeq(); + const status = state.review.alertStatus; + global.MobileAPI.request("/api/alerts?status=" + status + "&as_of=" + encodeURIComponent(todayString())).then(function (payload) { + if (seq !== state.seq) return; + state.review.alerts = payload || { items: [], unread_count: 0 }; + renderReviewAlerts(); + }).catch(function (error) { + if (seq !== state.seq) return; + const scroll = document.getElementById("m-scroll"); + if (scroll) scroll.innerHTML = reviewState("提醒中心"); + }); + } + + function alertKindLabel(kind) { + if (kind === "manual") return "自定提醒"; + if (kind === "strategy_t5") return "跟踪完成"; + return "策略反馈"; + } + + function alertKindBadge(kind) { + const tone = kind === "manual" ? "action" : kind === "strategy_t5" ? "down" : "warn"; + return '' + escapeHtml(alertKindLabel(kind)) + ""; + } + + function renderReviewAlerts() { + const scroll = document.getElementById("m-scroll"); + if (!scroll) return; + const data = state.review.alerts || { items: [], unread_count: 0 }; + const items = data.items || []; + const unreadCount = number(data.unread_count); + + const readAllBtn = document.querySelector("[data-review-read-all]"); + if (readAllBtn) readAllBtn.hidden = unreadCount <= 0; + + const filterTabs = ["all", "unread"].map(function (f) { + const active = state.review.alertStatus === f; + const label = f === "all" ? "全部" : "未读"; + const dot = f === "unread" && unreadCount > 0 ? '' : ""; + return '"; + }).join(""); + const tabs = '
' + filterTabs + "
"; + + if (!items.length) { + const emptyText = state.review.alertStatus === "unread" ? "没有未读提醒" : "暂无提醒"; + scroll.innerHTML = tabs + '
' + + '' + icon("bell", 26) + "" + + "

" + escapeHtml(emptyText) + "

" + + (state.review.alertStatus === "all" ? '重要节点给自己留个备忘' : "") + + "
"; + return; + } + + const rows = items.map(function (it) { + const upcoming = !it.due; + const unread = !it.is_read; + const dot = unread && it.due ? '' : ""; + const dateText = displayCompactDate(it.available_date); + return '"; + }).join(""); + + scroll.innerHTML = tabs + '
' + rows + "
"; + } + + function openAlertDetail(alertId) { + const item = (state.review.alerts && state.review.alerts.items || []).find(function (it) { return String(it.id) === String(alertId); }); + if (!item) return; + const upcoming = !item.due; + const canRead = !item.is_read && !upcoming; + openSheet( + '

提醒详情

' + + '
" + + '
' + + '
' + + alertKindBadge(item.kind) + + '

' + escapeHtml(item.title) + "

" + + '
' + escapeHtml(displayCompactDate(item.available_date)) + (upcoming ? "(未到期)" : "") + "
" + + (item.content ? '
' + escapeHtml(item.content) + "
" : "") + + (item.code ? '" : "") + + "
" + + '
' + + (canRead ? '' : "") + + '' + + "
", + { detail: false } + ); + } + + function markAlertRead(alertId) { + global.MobileAPI.request("/api/alerts/" + encodeURIComponent(alertId) + "/read", "POST", {}).then(function (payload) { + state.review.alerts = payload || state.review.alerts; + closeSheet(); + renderReviewAlerts(); + showToast("已标为已读"); + }).catch(function (error) { + showToast(error && error.message ? error.message : "操作失败"); + }); + } + + function deleteAlert(alertId) { + openConfirmSheet("删除提醒", "删除后无法恢复,确定删除这条提醒吗?", { + danger: true, + confirmLabel: "删除", + onConfirm: function () { + global.MobileAPI.request("/api/alerts/" + encodeURIComponent(alertId), "DELETE").then(function (payload) { + state.review.alerts = payload || state.review.alerts; + closeSheet(); + renderReviewAlerts(); + showToast("提醒已删除"); + }).catch(function (error) { + showToast(error && error.message ? error.message : "删除失败"); + }); + } + }); + } + + function markAllAlertsRead() { + global.MobileAPI.request("/api/alerts/read-all", "POST", { as_of: todayString() }).then(function (payload) { + state.review.alerts = payload || state.review.alerts; + renderReviewAlerts(); + showToast("全部已读"); + }).catch(function (error) { + showToast(error && error.message ? error.message : "操作失败"); + }); + } + + /* ----- 全屏表单页 ----- */ + + function openForm(kind, opts) { + state.review.form = { kind: kind, opts: opts || {}, action: "buy", emotion: "calm", stock: null }; + state.review.dirty = false; + const title = kind === "trade" ? ((opts && opts.trade) ? "编辑交易" : "新增交易") : kind === "daily" ? "写复盘" : "新增笔记"; + global.MobileRouter.updateHeader({ title: title, back: true, actions: "" }); + document.getElementById("m-view").classList.add("m-view-feature"); + document.getElementById("m-view").innerHTML = renderForm(); + bindForm(); + } + + function closeForm() { + if (state.review.dirty) { + openConfirmSheet("放弃本次编辑?", "返回后已填写的内容将不会保存。", { + confirmLabel: "放弃", + onConfirm: function () { exitForm(); } + }); + return; + } + exitForm(); + } + + function exitForm() { + const key = state.key; + const wasDaily = state.review.subKey === "daily"; + const keepDate = state.requestedDate; + state.review.form = null; + state.review.dirty = false; + setupReviewPage(key); + if (wasDaily) state.requestedDate = keepDate; + loadReview(); + } + + function formFieldHtml(label, controlHtml, required, hintHtml) { + return '"; + } + + function textInputHtml(id, value, attrs) { + return '"; + } + + function numberInputHtml(id, value, attrs) { + return '"; + } + + function textareaHtml(id, value, maxlength, rows) { + return '
' + + '" + + '0/' + maxlength + "" + + "
"; + } + + function dateInputHtml(id, value) { + return '
' + + '' + + '' + icon("calendar", 16) + "" + + "
"; + } + + function segHtml(group, options, selected) { + return '
' + + options.map(function (opt) { + const active = opt.key === selected; + return '"; + }).join("") + + "
"; + } + + function stockPickerHtml(id, stock) { + const chip = stock + ? '' + + '' + escapeHtml(stock.name) + "" + + '' + escapeHtml(stock.code) + "" + + '" + + "" + : ""; + return '
' + + (chip || '') + + '' + + "
"; + } + + function renderForm() { + const form = state.review.form; + if (form.kind === "trade") { + return renderTradeForm(form); + } + if (form.kind === "daily") { + return renderDailyForm(form); + } + return renderNoteForm(form); + } + + function renderTradeForm(form) { + const t = form.opts.trade || {}; + const tags = (t.tags || []).join(", "); + const fields = [ + formFieldHtml("日期", dateInputHtml("m-trade-date", displayCompactDate(t.trade_date)), true), + formFieldHtml("代码", textInputHtml("m-trade-code", t.code, 'inputmode="numeric" maxlength="6" placeholder="6 位代码"'), true), + formFieldHtml("名称", textInputHtml("m-trade-name", t.name, 'placeholder="股票名称"'), true), + formFieldHtml("动作", segHtml("action", TRADE_ACTION_OPTIONS, form.action || t.action || "buy"), true), + formFieldHtml("价格", numberInputHtml("m-trade-price", t.price, 'placeholder="成交价格"'), true), + formFieldHtml("数量", numberInputHtml("m-trade-quantity", t.quantity, 'inputmode="numeric" placeholder="成交数量"'), false), + formFieldHtml("仓位%", '
' + numberInputHtml("m-trade-position", t.position_pct, 'placeholder="0"') + '%
', false), + formFieldHtml("盈亏额", numberInputHtml("m-trade-pnl-amount", t.pnl_amount, 'placeholder="可留空"'), false), + formFieldHtml("盈亏%", '
' + numberInputHtml("m-trade-pnl-pct", t.pnl_pct, 'placeholder="可留空"') + '%
', false), + formFieldHtml("情绪", segHtml("emotion", TRADE_EMOTION_OPTIONS, form.emotion || t.emotion || "calm"), true), + formFieldHtml("标签", textInputHtml("m-trade-tags", tags, 'placeholder="逗号分隔,如 打板、龙头"'), false), + formFieldHtml("交易逻辑", textareaHtml("m-trade-thesis", t.thesis, 2000, 4), false), + formFieldHtml("执行复核", textareaHtml("m-trade-execution", t.execution, 2000, 4), false) + ]; + return '
' + + '
' + + '
' + fields.join("") + "
" + + "
" + + '
' + + "
"; + } + + function renderDailyForm() { + const note = state.review.form.opts.note || {}; + const fields = [ + formFieldHtml("日期", dateInputHtml("m-daily-date", state.requestedDate || todayString()), true), + formFieldHtml("今日盘面", textareaHtml("m-daily-summary", note.summary, 500, 3), false), + formFieldHtml("做对做错", textareaHtml("m-daily-content", note.content, 5000, 8), false), + formFieldHtml("明日计划", textareaHtml("m-daily-plan", note.plan, 2000, 5), false) + ]; + return '
' + + '
' + + '
' + fields.join("") + "
" + + "
" + + '
' + + "
"; + } + + function renderNoteForm() { + const fields = [ + formFieldHtml("股票", stockPickerHtml("m-note-stock", state.review.form.stock), true), + formFieldHtml("笔记内容", textareaHtml("m-note-content", "", 5000, 6), false), + formFieldHtml("计划", textareaHtml("m-note-plan", "", 2000, 4), false) + ]; + return '
' + + '
' + + '
' + fields.join("") + "
" + + "
" + + '
' + + "
"; + } + + function markDirty() { + if (state.review.form) state.review.dirty = true; + } + + function bindForm() { + const scroll = document.getElementById("m-scroll"); + if (!scroll) return; + + scroll.addEventListener("input", function (event) { + if (event.target.matches("input, textarea")) markDirty(); + if (event.target.dataset && event.target.dataset.maxlength) { + const counter = document.querySelector('[data-counter-for="' + event.target.id + '"]'); + if (counter) counter.textContent = event.target.value.length + "/" + event.target.dataset.maxlength; + } + }); + + scroll.querySelectorAll("textarea[data-maxlength]").forEach(function (ta) { + const counter = document.querySelector('[data-counter-for="' + ta.id + '"]'); + if (counter) counter.textContent = ta.value.length + "/" + ta.dataset.maxlength; + }); + + scroll.addEventListener("click", function (event) { + const seg = event.target.closest("[data-seg-key]"); + if (seg) { + const group = seg.parentElement && seg.parentElement.dataset.segGroup; + seg.parentElement.querySelectorAll("[data-seg-key]").forEach(function (b) { + b.classList.toggle("active", b === seg); + b.setAttribute("aria-checked", String(b === seg)); + }); + state.review.form[group] = seg.dataset.segKey; + markDirty(); + return; + } + }); + + const picker = scroll.querySelector("[data-stock-picker]"); + if (picker) bindStockPicker(picker, state.review.form); + } + + function bindStockPicker(picker, target) { + const t = target || { stock: null }; + let timer = null; + let requestSeq = 0; + + function hideResults() { + const results = picker.querySelector("[data-stock-results]"); + if (results) { results.hidden = true; results.innerHTML = ""; } + } + + function renderPickerContent() { + picker.innerHTML = stockPickerHtml("", t.stock); + } + + function runStockSearch(q) { + const seq = ++requestSeq; + global.MobileAPI.request("/api/search?q=" + encodeURIComponent(q) + "&trade_date=" + encodeURIComponent(todayString())).then(function (payload) { + if (seq !== requestSeq) return; + const stocks = (payload && payload.groups && payload.groups.stocks) || []; + const results = picker.querySelector("[data-stock-results]"); + if (!results) return; + results.hidden = false; + results.innerHTML = stocks.length + ? stocks.map(function (s) { + return '"; + }).join("") + : '
没有找到匹配的股票
'; + }).catch(function () { + if (seq !== requestSeq) return; + const results = picker.querySelector("[data-stock-results]"); + if (!results) return; + results.hidden = false; + results.innerHTML = '
搜索失败
'; + }); + } + + picker.addEventListener("input", function (event) { + if (event.target.tagName !== "INPUT") return; + markDirty(); + clearTimeout(timer); + const q = event.target.value.trim(); + if (!q) { hideResults(); return; } + timer = setTimeout(function () { runStockSearch(q); }, 180); + }); + + picker.addEventListener("focusin", function (event) { + if (event.target.tagName !== "INPUT") return; + const q = event.target.value.trim(); + if (q) runStockSearch(q); + }); + + picker.addEventListener("click", function (event) { + const row = event.target.closest("[data-stock-pick]"); + if (row) { + t.stock = { code: row.dataset.stockPick, name: row.dataset.stockName, sector: row.dataset.stockSector }; + hideResults(); + renderPickerContent(); + markDirty(); + return; + } + const clear = event.target.closest("[data-stock-clear]"); + if (clear) { + t.stock = null; + renderPickerContent(); + markDirty(); + return; + } + }); + + document.addEventListener("click", function onDoc(event) { + if (!picker.contains(event.target)) hideResults(); + }); + } + + function fieldError(id, message) { + const input = document.getElementById(id); + if (!input) return; + const label = input.closest(".m-form-field"); + if (!label) return; + label.classList.add("is-invalid"); + let err = label.querySelector(".m-field-error"); + if (!err) { + err = document.createElement("span"); + err.className = "m-field-error"; + label.appendChild(err); + } + err.textContent = message; + } + + function clearFieldError(id) { + const input = document.getElementById(id); + if (!input) return; + const label = input.closest(".m-form-field"); + if (!label) return; + label.classList.remove("is-invalid"); + const err = label.querySelector(".m-field-error"); + if (err) err.remove(); + } + + function numberValue(id) { + const el = document.getElementById(id); + const raw = el ? el.value.trim() : ""; + return raw === "" ? "" : number(raw); + } + + function submitForm() { + const form = state.review.form; + if (!form) return; + if (form.kind === "trade") submitTradeForm(); + else if (form.kind === "daily") submitDailyForm(); + else submitNoteForm(); + } + + function setSubmitting(btn, submitting) { + if (!btn) return; + btn.disabled = submitting; + if (submitting) { + btn.innerHTML = '保存中…'; + } else { + btn.innerHTML = "保存"; + } + } + + function submitTradeForm() { + const form = state.review.form; + const trade = form.opts.trade || {}; + const tradeDate = document.getElementById("m-trade-date").value; + const code = document.getElementById("m-trade-code").value.trim(); + const name = document.getElementById("m-trade-name").value.trim(); + const price = numberValue("m-trade-price"); + const position = numberValue("m-trade-position"); + const pnlAmount = numberValue("m-trade-pnl-amount"); + const pnlPct = numberValue("m-trade-pnl-pct"); + const tags = document.getElementById("m-trade-tags").value.trim(); + + clearFieldError("m-trade-code"); + clearFieldError("m-trade-name"); + clearFieldError("m-trade-price"); + let invalid = false; + if (!code) { fieldError("m-trade-code", "请输入股票代码"); invalid = true; } + if (!name) { fieldError("m-trade-name", "请输入股票名称"); invalid = true; } + if (price === "") { fieldError("m-trade-price", "请输入成交价格"); invalid = true; } + if (invalid) return; + + const payload = { + trade_date: tradeDate || todayString(), + code: code, + name: name, + action: form.action, + price: price, + quantity: number(document.getElementById("m-trade-quantity").value || 0), + position_pct: position === "" ? 0 : position, + pnl_amount: pnlAmount === "" ? null : pnlAmount, + pnl_pct: pnlPct === "" ? null : pnlPct, + emotion: form.emotion, + tags: tags ? tags.split(/[,,]/).map(function (s) { return s.trim(); }).filter(Boolean) : [], + thesis: document.getElementById("m-trade-thesis").value.trim(), + execution: document.getElementById("m-trade-execution").value.trim() + }; + if (trade.id) payload.id = trade.id; + + const btn = document.querySelector("[data-review-submit]"); + setSubmitting(btn, true); + global.MobileAPI.request("/api/trades", "POST", payload).then(function () { + state.review.dirty = false; + exitForm(); + showToast("交易已保存"); + }).catch(function (error) { + setSubmitting(btn, false); + showToast(error && error.message ? error.message : "保存失败"); + }); + } + + function submitDailyForm() { + const form = state.review.form; + const note = form.opts.note || {}; + const summary = document.getElementById("m-daily-summary").value.trim(); + const content = document.getElementById("m-daily-content").value.trim(); + const plan = document.getElementById("m-daily-plan").value.trim(); + const tradeDate = document.getElementById("m-daily-date").value || todayString(); + if (!summary && !content && !plan) { + showToast("复盘内容不能全部为空"); + return; + } + const payload = { + trade_date: tradeDate, + summary: summary, + content: content, + plan: plan + }; + if (note.id) payload.id = note.id; + + const btn = document.querySelector("[data-review-submit]"); + setSubmitting(btn, true); + global.MobileAPI.request("/api/notes", "POST", payload).then(function () { + state.review.dirty = false; + state.requestedDate = tradeDate; + exitForm(); + showToast("已保存"); + }).catch(function (error) { + setSubmitting(btn, false); + showToast(error && error.message ? error.message : "保存失败"); + }); + } + + function submitNoteForm() { + const form = state.review.form; + const stock = form.stock; + if (!stock || !stock.code) { + showToast("请先选择股票"); + return; + } + const payload = { + code: stock.code, + stock_name: stock.name, + trade_date: todayString(), + content: document.getElementById("m-note-content").value.trim(), + plan: document.getElementById("m-note-plan").value.trim() + }; + if (!payload.content && !payload.plan) { + showToast("笔记内容不能为空"); + return; + } + const btn = document.querySelector("[data-review-submit]"); + setSubmitting(btn, true); + global.MobileAPI.request("/api/notes", "POST", payload).then(function () { + state.review.dirty = false; + exitForm(); + showToast("笔记已保存"); + }).catch(function (error) { + setSubmitting(btn, false); + showToast(error && error.message ? error.message : "保存失败"); + }); + } + + /* ----- 提醒新增抽屉 ----- */ + + function openAlertAddDrawer() { + state.review.alertDraft = { stock: null }; + openSheet( + '

新增提醒

' + + '
" + + '
' + alertAddDrawerBody() + "
", + { detail: false } + ); + const body = document.getElementById("m-alert-add-body"); + const picker = body.querySelector("[data-stock-picker]"); + if (picker) bindStockPicker(picker, state.review.alertDraft); + } + + function alertAddDrawerBody() { + return '' + + formFieldHtml("提醒日期", dateInputHtml("m-alert-date", todayString()), true) + + formFieldHtml("关联股票", stockPickerHtml("m-alert-stock", state.review.alertDraft ? state.review.alertDraft.stock : null), false) + + formFieldHtml("内容", textareaHtml("m-alert-content", "", 500, 3), false) + + '
'; + } + + function submitAlertAdd() { + const title = document.getElementById("m-alert-title").value.trim(); + if (!title) { showToast("请输入提醒标题"); return; } + const stock = state.review.alertDraft && state.review.alertDraft.stock; + const payload = { + title: title, + remind_date: document.getElementById("m-alert-date").value || todayString(), + code: stock ? stock.code : "", + content: document.getElementById("m-alert-content").value.trim() + }; + global.MobileAPI.request("/api/alerts", "POST", payload).then(function (res) { + state.review.alerts = res || state.review.alerts; + closeSheet(); + renderReviewAlerts(); + showToast("已添加提醒"); + }).catch(function (error) { + showToast(error && error.message ? error.message : "保存失败"); + }); + } + + function buildReviewTable(cols, rows, opts) { + const o = opts || {}; + const frozen = cols.frozenColumns || []; + const primary = cols.primaryColumns || []; + const scroll = cols.scrollColumns || []; + const ordered = frozen.concat(primary, scroll); + let left = 0; + const frozenLeft = frozen.map(function (col) { const off = left; left += columnWidth(col); return off; }); + + function cellOpen(col, index, isHead, extraCls, extraAttrs) { + const isFrozen = index < frozen.length; + const width = columnWidth(col); + const clamp = (col.type === "text" || col.type === "concepts") ? ";max-width:" + width + "px" : ""; + const style = "min-width:" + width + "px" + clamp + (isFrozen ? ";width:" + width + "px;left:" + frozenLeft[index] + "px" : ""); + const cls = (isHead ? "m-th" : "m-td") + " " + colAlign(col) + (isFrozen ? " m-frozen" : "") + (extraCls ? " " + extraCls : ""); + return '<' + (isHead ? "th" : "td") + ' class="' + cls + '" style="' + style + '"' + (extraAttrs || "") + '>'; + } + + const head = ordered.map(function (col, i) { + const sortable = !o.noSort && sortableColumn(col); + let extraCls = ""; + let extraAttrs = ""; + let indicator = ""; + if (sortable) { + const active = state.sort.key === col.key && Boolean(state.sort.dir); + extraCls = " m-sortable" + (active ? " is-sorted" : ""); + extraAttrs = ' data-sort-key="' + escapeHtml(col.key) + '" aria-sort="' + (active ? (state.sort.dir === "asc" ? "ascending" : "descending") : "none") + '"'; + indicator = sortIndicatorHtml(active); + } + return cellOpen(col, i, true, extraCls, extraAttrs) + '' + escapeHtml(col.label) + indicator + ""; + }).join(""); + + const body = rows.map(function (row, rIndex) { + const cells = ordered.map(function (col, i) { + const custom = o.cellRenderer ? o.cellRenderer(col, row, rIndex) : null; + const html = custom != null ? custom : cellHtml(col, row, rIndex); + return cellOpen(col, i, false) + html + ""; + }).join(""); + const linkable = !o.noLink && /^\d{6}$/.test(String(row.code || "")); + const code = linkable ? ' data-code="' + escapeHtml(row.code) + '"' : ""; + const extra = o.rowAttrs ? o.rowAttrs(row) : ""; + return "" + cells + ""; + }).join(""); + + return '' + head + "" + body + "
"; + } + /* ---------------------------------------------------------------- events */ function bindEvents() { window.addEventListener("hashchange", closeSheet); + // 全屏表单页的返回:优先回到列表/查看态,而非离开路由(capture 阶段先于路由层返回) + document.getElementById("m-back").addEventListener("click", function (event) { + if (state.review && state.review.form) { + event.preventDefault(); + event.stopPropagation(); + closeForm(); + } + }, true); + document.addEventListener("click", function (event) { const dateBtn = event.target.closest("[data-action=date]"); if (dateBtn) { openDateSheet(); return; } @@ -3691,11 +4907,71 @@ return; } + // 我的复盘:新增/编辑/删除/筛选/提交等操作 + const reviewAddTrade = event.target.closest("[data-review-add-trade]"); + if (reviewAddTrade) { openForm("trade"); return; } + const reviewAddNote = event.target.closest("[data-review-add-note]"); + if (reviewAddNote) { openForm("note"); return; } + const reviewAddAlert = event.target.closest("[data-review-add-alert]"); + if (reviewAddAlert) { openAlertAddDrawer(); return; } + const reviewReadAll = event.target.closest("[data-review-read-all]"); + if (reviewReadAll) { markAllAlertsRead(); return; } + const reviewEditTrade = event.target.closest("[data-review-edit-trade]"); + if (reviewEditTrade) { + const id = reviewEditTrade.dataset.tradeId; + const data = state.review.trades || { items: [] }; + const item = (data.items || []).find(function (it) { return String(it.id) === String(id); }); + closeSheet(); + if (item) openForm("trade", { trade: item }); + return; + } + const reviewDeleteTrade = event.target.closest("[data-review-delete-trade]"); + if (reviewDeleteTrade) { deleteTrade(reviewDeleteTrade.dataset.tradeId); return; } + const reviewEditDaily = event.target.closest("[data-review-edit-daily]"); + if (reviewEditDaily) { + const note = dailyNoteFor(state.requestedDate || todayString()); + openForm("daily", { note: note }); + return; + } + const reviewDeleteDaily = event.target.closest("[data-review-delete-daily]"); + if (reviewDeleteDaily) { deleteDailyNote(reviewDeleteDaily.dataset.noteId); return; } + const dailyDateRow = event.target.closest("[data-daily-date]"); + if (dailyDateRow) { + state.requestedDate = dailyDateRow.dataset.dailyDate; + updateHeader("每日复盘", state.requestedDate); + renderReviewDaily(); + return; + } + const reviewDeleteNote = event.target.closest("[data-review-delete-note]"); + if (reviewDeleteNote) { deleteNote(reviewDeleteNote.dataset.noteId); return; } + const noteCard = event.target.closest("[data-note-id]"); + if (noteCard) { openNoteDetail(noteCard.dataset.noteId); return; } + const alertRow = event.target.closest("[data-alert-id]"); + if (alertRow) { openAlertDetail(alertRow.dataset.alertId); return; } + const alertRead = event.target.closest("[data-review-alert-read]"); + if (alertRead) { markAlertRead(alertRead.dataset.alertId); return; } + const alertDelete = event.target.closest("[data-review-alert-delete]"); + if (alertDelete) { deleteAlert(alertDelete.dataset.alertId); return; } + const alertFilter = event.target.closest("[data-review-alert-filter]"); + if (alertFilter) { + state.review.alertStatus = alertFilter.dataset.reviewAlertFilter === "unread" ? "unread" : "all"; + loadReviewAlerts(); + return; + } + const reviewSubmit = event.target.closest("[data-review-submit]"); + if (reviewSubmit) { submitForm(); return; } + const alertSave = event.target.closest("[data-alert-save]"); + if (alertSave) { submitAlertAdd(); return; } + const alertStock = event.target.closest("[data-alert-stock]"); + if (alertStock) { openDetailSheet(alertStock.dataset.code); return; } + const row = event.target.closest("[data-code]"); if (row) { const interactive = event.target.closest("button, a, input, select, textarea"); if (!interactive) { - if (state.key === "tools/tracking" && row.dataset.trackId) { + if (state.key === "review/trades" && row.dataset.tradeId) { + openTradeDetail(row.dataset.tradeId); + } else if (state.key === "tools/tracking" && row.dataset.trackId) { openTrackingDetailSheet(row.dataset.code, row.dataset.trackId); } else if (state.key === "tools/screener") { openScreenerDetailSheet(row.dataset.code);