window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], { enter: ["loadReview"], }); /* PRESERVATION-SOURCE-BEGIN app.js:3029-3470 */ async function loadReviewWorkspace() { try { const [watchlistPayload, notesPayload, tradesPayload] = await Promise.all([ apiRequest(`/api/watchlist?trade_date=${encodeURIComponent(elements.tradeDate.value)}`), apiRequest("/api/notes?scope=daily"), apiRequest("/api/trades"), ]); state.watchlist = watchlistPayload.items || []; state.notes = notesPayload.items || []; state.tradeEntries = tradesPayload.items || []; state.tradeSummary = tradesPayload.summary || {}; setText("reviewDataDate", displayCompactDate(elements.tradeDate.value)); renderWatchlist(); renderNotesHistory(state.notes, document.querySelector("#notesHistory"), false); setText("notesCount", `${state.notes.length} 条`); renderTradeLog(); populateJournalForm(); } catch (error) { showToast(error.message || "我的复盘加载失败"); } } function renderWatchlist() { setText("watchlistCount", `${state.watchlist.length} 只`); const body = document.querySelector("#watchlistTableBody"); body.innerHTML = state.watchlist.map((item) => ` ${escapeHtml(item.name)}${escapeHtml(item.code)} ${escapeHtml(item.sector || "其他")} ${formatWatchMetric(item.change)} ${formatWatchMetric(item.return_5d)} ${item.attention_score == null ? "" : formatNumber(item.attention_score, 1)} ${escapeHtml(item.remark || "尚未填写")} `).join(""); document.querySelector("#watchlistEmpty").hidden = state.watchlist.length > 0; body.querySelectorAll("[data-watch-remark]").forEach((button) => { button.addEventListener("click", () => { const item = state.watchlist.find((row) => row.code === button.dataset.watchRemark); openWatchlistDialog(item); }); }); body.querySelectorAll("[data-watch-delete]").forEach((button) => { button.addEventListener("click", () => removeWatchlist(button.dataset.watchDelete)); }); bindStockRows(body); } function formatWatchMetric(value) { if (value == null || !Number.isFinite(Number(value))) return ""; return signed(value); } function openWatchlistDialog(item = null) { clearTimeout(watchlistSearchTimer); state.watchlistSelection = item ? { code: item.code, name: item.name, sector: item.sector || "其他", color: item.color || "red", } : null; state.watchlistSearchResults = []; setText("watchlistDialogTitle", item ? "编辑跟踪备注" : "添加自选"); document.querySelector("#watchlistRemark").value = item?.remark || ""; document.querySelector("#watchlistSearchInput").value = ""; document.querySelector("#watchlistSearchResults").innerHTML = ""; syncWatchlistSelection(Boolean(item)); openModalDialog(elements.watchlistDialog); requestAnimationFrame(() => (item ? document.querySelector("#watchlistRemark") : document.querySelector("#watchlistSearchInput")).focus()); } function closeWatchlistDialog() { clearTimeout(watchlistSearchTimer); if (elements.watchlistDialog.open) elements.watchlistDialog.close(); } function clearWatchlistSelection() { state.watchlistSelection = null; syncWatchlistSelection(false); document.querySelector("#watchlistSearchInput").focus(); } function syncWatchlistSelection(editing = false) { const item = state.watchlistSelection; document.querySelector("#watchlistSearchField").hidden = Boolean(item); document.querySelector("#watchlistSelection").hidden = !item; document.querySelector("#changeWatchlistSelection").hidden = editing; document.querySelector("#saveWatchlist").disabled = !item; if (!item) return; setText("watchlistSelectionName", item.name || "--"); setText("watchlistSelectionCode", item.code || "--"); setText("watchlistSelectionSector", item.sector || "其他"); refreshIcons(); } function scheduleWatchlistSearch() { clearTimeout(watchlistSearchTimer); const query = document.querySelector("#watchlistSearchInput").value.trim(); if (!query) { document.querySelector("#watchlistSearchResults").innerHTML = ""; return; } document.querySelector("#watchlistSearchResults").innerHTML = '
正在查找股票
'; watchlistSearchTimer = setTimeout(() => runWatchlistSearch(query), 160); } async function runWatchlistSearch(query) { const sequence = ++state.watchlistSearchRequestSequence; try { const params = new URLSearchParams({ q: query, trade_date: elements.tradeDate.value }); const payload = await apiRequest(`/api/search?${params}`); if (sequence !== state.watchlistSearchRequestSequence) return; state.watchlistSearchResults = payload.groups?.stocks || []; document.querySelector("#watchlistSearchResults").innerHTML = state.watchlistSearchResults.map((item, index) => ` `).join("") || '
没有找到匹配的股票
'; } catch (error) { document.querySelector("#watchlistSearchResults").innerHTML = `
${escapeHtml(error.message || "搜索失败")}
`; } } function handleWatchlistSearchResult(event) { const button = event.target.closest("[data-watchlist-result]"); if (!button) return; const item = state.watchlistSearchResults[number(button.dataset.watchlistResult)]; if (!item) return; state.watchlistSelection = { code: item.code, name: item.name, sector: item.industry || "其他", color: "red", }; syncWatchlistSelection(false); } async function saveWatchlistFromDialog(event) { event.preventDefault(); const item = state.watchlistSelection; if (!item) return; const button = document.querySelector("#saveWatchlist"); button.disabled = true; try { await apiRequest("/api/watchlist", "POST", { code: item.code, name: item.name, sector: item.sector || "其他", color: item.color || "red", remark: document.querySelector("#watchlistRemark").value.trim(), }); closeWatchlistDialog(); await loadReviewWorkspace(); showToast(state.watchlist.some((row) => row.code === item.code) ? "自选跟踪已保存" : "已加入自选"); } catch (error) { showToast(error.message || "自选保存失败"); button.disabled = false; } } async function toggleActiveWatchlist() { const stock = state.activeStock; if (!stock?.code) return; const isWatched = Boolean(state.stockDetail?.stock?.watchlist || state.watchlist.some((item) => item.code === stock.code)); try { if (isWatched) { await apiRequest(`/api/watchlist/${stock.code}`, "DELETE"); state.watchlist = state.watchlist.filter((item) => item.code !== stock.code); if (state.stockDetail?.stock) state.stockDetail.stock.watchlist = null; showToast("已移出自选"); } else { const payload = await apiRequest("/api/watchlist", "POST", { code: stock.code, name: stock.name || "--", sector: stock.sector || "其他", color: "red", }); state.watchlist = payload.items || state.watchlist; if (state.stockDetail?.stock) state.stockDetail.stock.watchlist = state.watchlist.find((item) => item.code === stock.code); showToast("已加入自选"); } updateWatchButton(); renderWatchlist(); } catch (error) { showToast(error.message); } } function updateWatchButton() { const code = state.activeStock?.code; const watched = Boolean(state.stockDetail?.stock?.watchlist || state.watchlist.some((item) => item.code === code)); setText("watchStockButton", watched ? "移出自选" : "加入自选"); } async function removeWatchlist(code) { try { await apiRequest(`/api/watchlist/${code}`, "DELETE"); state.watchlist = state.watchlist.filter((item) => item.code !== code); renderWatchlist(); showToast("已移出自选"); } catch (error) { showToast(error.message); } } async function saveJournal(event) { event.preventDefault(); try { await apiRequest("/api/notes", "POST", { trade_date: document.querySelector("#journalDate").value, id: state.editingDailyNoteId || undefined, summary: document.querySelector("#journalSummary").value, content: document.querySelector("#journalContent").value, plan: document.querySelector("#journalPlan").value, }); await loadReviewWorkspace(); showToast("每日复盘已保存"); } catch (error) { showToast(error.message); } } function populateJournalForm() { const selectedDate = document.querySelector("#journalDate").value.replaceAll("-", ""); const note = state.notes.find((item) => String(item.trade_date).replaceAll("-", "") === selectedDate); state.editingDailyNoteId = number(note?.id); document.querySelector("#journalSummary").value = note?.summary || ""; document.querySelector("#journalContent").value = note?.content || ""; document.querySelector("#journalPlan").value = note?.plan || ""; } function openTradeLogDialog() { resetTradeLogForm(); openModalDialog(elements.tradeLogDialog); requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus()); } function closeTradeLogDialog() { if (elements.tradeLogDialog.open) elements.tradeLogDialog.close(); else resetTradeLogForm(); } async function saveTradeLog(event) { event.preventDefault(); const button = document.querySelector("#saveTradeLog"); button.disabled = true; try { const payload = await apiRequest("/api/trades", "POST", { id: state.editingTradeId || undefined, trade_date: document.querySelector("#tradeLogDate").value, code: document.querySelector("#tradeLogCode").value.trim(), name: document.querySelector("#tradeLogName").value.trim(), action: document.querySelector("#tradeLogAction").value, price: document.querySelector("#tradeLogPrice").value, quantity: document.querySelector("#tradeLogQuantity").value, position_pct: document.querySelector("#tradeLogPosition").value, pnl_amount: document.querySelector("#tradeLogPnlAmount").value, pnl_pct: document.querySelector("#tradeLogPnlPct").value, emotion: document.querySelector("#tradeLogEmotion").value, tags: document.querySelector("#tradeLogTags").value, thesis: document.querySelector("#tradeLogThesis").value, execution: document.querySelector("#tradeLogExecution").value, }); state.tradeEntries = payload.items || []; state.tradeSummary = payload.summary || {}; renderTradeLog(); closeTradeLogDialog(); showToast("交易记录已保存"); } catch (error) { showToast(error.message || "交易记录保存失败"); } finally { button.disabled = false; } } function resetTradeLogForm() { state.editingTradeId = 0; document.querySelector("#tradeLogForm").reset(); document.querySelector("#tradeLogDate").value = elements.tradeDate.value || todayString(); document.querySelector("#tradeLogQuantity").value = "0"; document.querySelector("#tradeLogPosition").value = "0"; setText("tradeLogDialogTitle", "交易日志"); setText("saveTradeLog", "保存交易"); } function editTradeLog(id) { const item = state.tradeEntries.find((entry) => number(entry.id) === id); if (!item) return; state.editingTradeId = id; document.querySelector("#tradeLogDate").value = displayCompactDate(item.trade_date); document.querySelector("#tradeLogCode").value = item.code; document.querySelector("#tradeLogName").value = item.name; document.querySelector("#tradeLogAction").value = item.action; document.querySelector("#tradeLogPrice").value = item.price; document.querySelector("#tradeLogQuantity").value = item.quantity; document.querySelector("#tradeLogPosition").value = item.position_pct; document.querySelector("#tradeLogPnlAmount").value = item.pnl_amount ?? ""; document.querySelector("#tradeLogPnlPct").value = item.pnl_pct ?? ""; document.querySelector("#tradeLogEmotion").value = item.emotion; document.querySelector("#tradeLogTags").value = (item.tags || []).join(", "); document.querySelector("#tradeLogThesis").value = item.thesis || ""; document.querySelector("#tradeLogExecution").value = item.execution || ""; setText("tradeLogDialogTitle", "编辑交易日志"); setText("saveTradeLog", "保存修改"); openModalDialog(elements.tradeLogDialog); requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus()); } async function handleTradeLogAction(event) { const button = event.target.closest("[data-trade-action]"); if (!button) return; const id = number(button.dataset.tradeId); if (button.dataset.tradeAction === "edit") { editTradeLog(id); return; } if (!window.confirm("确定删除这条交易记录吗?")) return; try { const payload = await apiRequest(`/api/trades/${id}`, "DELETE"); state.tradeEntries = payload.items || []; state.tradeSummary = payload.summary || {}; if (state.editingTradeId === id) resetTradeLogForm(); renderTradeLog(); showToast("交易记录已删除"); } catch (error) { showToast(error.message || "交易记录删除失败"); } } function renderTradeLog() { const summary = state.tradeSummary || {}; setText("tradeLogCount", `${state.tradeEntries.length} 条`); document.querySelector("#tradeLogSummary").innerHTML = [ ["记录", `${number(summary.total)} 条`], ["已实现", `${number(summary.realized)} 条`], ["胜率", summary.win_rate == null ? "--" : `${formatNumber(summary.win_rate, 1)}%`], ["累计盈亏", summary.pnl_amount == null ? "--" : `${number(summary.pnl_amount) > 0 ? "+" : ""}${formatNumber(summary.pnl_amount, 2)}`], ["平均仓位", summary.average_position == null ? "--" : `${formatNumber(summary.average_position, 1)}%`], ].map(([label, value]) => `
${label}${value}
`).join(""); document.querySelector("#tradeLogEmpty").hidden = state.tradeEntries.length > 0; document.querySelector("#tradeLogTableBody").innerHTML = state.tradeEntries.map((item) => ` ${displayCompactDate(item.trade_date)} ${escapeHtml(item.name)}${escapeHtml(item.code)} ${escapeHtml(item.action_label)} ${item.position_pct == null ? "" : formatNumber(item.position_pct, 1)} ${item.pnl_pct == null ? "" : signed(item.pnl_pct)} ${item.pnl_amount == null ? "" : signed(item.pnl_amount)} ${escapeHtml(item.emotion_label)}
${(item.tags || []).map((tag) => `${escapeHtml(tag)}`).join("")}
${escapeHtml(item.thesis || "")}${escapeHtml(item.execution || "尚未填写执行复核")}
`).join(""); bindStockRows(document.querySelector("#tradeLogTableBody")); } async function saveStockNote(event) { event.preventDefault(); if (!state.activeStock?.code) return; try { await apiRequest("/api/notes", "POST", { code: state.activeStock.code, stock_name: state.activeStock.name || "--", trade_date: elements.tradeDate.value, content: document.querySelector("#stockNoteContent").value, plan: document.querySelector("#stockNotePlan").value, }); document.querySelector("#stockNoteContent").value = ""; document.querySelector("#stockNotePlan").value = ""; const payload = await apiRequest(`/api/notes?scope=stock&code=${encodeURIComponent(state.activeStock.code)}`); state.stockDetail.notes = payload.items || []; renderStockNotes(state.stockDetail.notes); showToast("个股笔记已保存"); } catch (error) { showToast(error.message); } } async function saveReasonOverride(event) { event.preventDefault(); if (!state.activeStock?.code) return; const reason = document.querySelector("#reasonInput").value.trim(); try { await apiRequest("/api/reasons", "POST", { trade_date: elements.tradeDate.value, code: state.activeStock.code, reason, }); state.activeStock.reason = reason; for (const key of ["limits", "broken", "down_limits"]) { const row = state.dashboard?.[key]?.find((item) => item.code === state.activeStock.code); if (row) row.reason = reason; } setText("detailReason", reason); renderDashboard(); showToast("事件逻辑已修订"); } catch (error) { showToast(error.message); } } function renderMoneyflow(flow) { for (const [id, value] of [["flowNet", flow.net_million], ["flowLarge", flow.large_million], ["flowMedium", flow.medium_million], ["flowSmall", flow.small_million]]) { const element = document.getElementById(id); element.textContent = formatMoneyMillion(value); element.className = changeClass(value); } } function renderStockNotes(notes) { renderNotesHistory(notes, document.querySelector("#stockNotes"), true); } function renderNotesHistory(notes, container, compact) { container.innerHTML = notes.map((note) => `
${note.stock_name ? `${escapeHtml(note.stock_name)}` : ""}
${!compact ? `
盘面

${escapeHtml(note.summary || "--")}

` : ""}
复盘

${escapeHtml(note.content || "--")}

计划

${escapeHtml(note.plan || "--")}

`).join("") || emptyStateHtml("暂无复盘记录"); container.querySelectorAll("[data-note-delete]").forEach((button) => { button.addEventListener("click", () => deleteNote(number(button.dataset.noteDelete), compact)); }); } async function deleteNote(noteId, compact) { try { await apiRequest(`/api/notes/${noteId}`, "DELETE"); if (compact && state.activeStock) { state.stockDetail.notes = state.stockDetail.notes.filter((note) => number(note.id) !== noteId); renderStockNotes(state.stockDetail.notes); } else { await loadReviewWorkspace(); } showToast("笔记已删除"); } catch (error) { showToast(error.message); } } /* PRESERVATION-SOURCE-END app.js:3029-3470 */ /* PRESERVATION-SOURCE-BEGIN app.js:7720-7968 */ async function loadAlerts(openDialog = false) { try { const query = new URLSearchParams({ status: state.alertFilter, as_of: todayString() }); const payload = await apiRequest(`/api/alerts?${query}`); state.alerts = payload.items || []; state.alertUnreadCount = number(payload.unread_count); renderAlerts(); if (openDialog) openModalDialog(elements.alertsDialog); } catch (error) { if (openDialog) showToast(error.message || "提醒加载失败"); } } function openAlerts() { toggleHeaderCommandMenu(false); toggleAccountDropdown(false); document.querySelector("#alertDate").value ||= todayString(); openModalDialog(elements.alertsDialog); loadAlerts(); } function openStockReminder() { const stock = state.activeStock || {}; document.querySelector("#alertTitle").value = `${stock.name || stock.code || "个股"}观察提醒`; document.querySelector("#alertCode").value = stock.code || ""; document.querySelector("#alertDate").value = todayString(); if (elements.stockDialog.open) elements.stockDialog.close(); openAlerts(); document.querySelector("#alertContent").focus(); } function selectAlertFilter(filter) { state.alertFilter = filter === "unread" ? "unread" : "all"; document.querySelectorAll("[data-alert-filter]").forEach((button) => { button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter); }); loadAlerts(); } async function saveAlert(event) { event.preventDefault(); const button = event.currentTarget.querySelector("button[type='submit']"); button.disabled = true; try { const payload = await apiRequest("/api/alerts", "POST", { title: document.querySelector("#alertTitle").value.trim(), remind_date: document.querySelector("#alertDate").value, code: document.querySelector("#alertCode").value.trim(), content: document.querySelector("#alertContent").value.trim(), }); event.currentTarget.reset(); document.querySelector("#alertDate").value = todayString(); state.alertFilter = "all"; state.alerts = payload.items || []; state.alertUnreadCount = number(payload.unread_count); renderAlerts(); showToast("提醒已保存"); } catch (error) { showToast(error.message || "提醒保存失败"); } finally { button.disabled = false; } } async function markAllAlertsRead() { try { await apiRequest("/api/alerts/read-all", "POST", { as_of: todayString() }); await loadAlerts(); } catch (error) { showToast(error.message || "提醒状态更新失败"); } } async function handleAlertAction(event) { const button = event.target.closest("[data-alert-action]"); if (!button) return; const id = number(button.dataset.alertId); if (!id) return; try { if (button.dataset.alertAction === "delete") { await apiRequest(`/api/alerts/${id}`, "DELETE"); } else { await apiRequest(`/api/alerts/${id}/read`, "POST", {}); } await loadAlerts(); } catch (error) { showToast(error.message || "提醒操作失败"); } } function renderAlerts() { const badge = document.querySelector("#alertBadge"); badge.hidden = state.alertUnreadCount <= 0; badge.textContent = state.alertUnreadCount > 99 ? "99+" : String(state.alertUnreadCount); document.querySelector("#alertButton").classList.toggle("has-alerts", state.alertUnreadCount > 0); setText("alertListCount", `${state.alerts.length} 条`); document.querySelectorAll("[data-alert-filter]").forEach((button) => { button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter); }); document.querySelector("#markAllAlertsRead").disabled = state.alertUnreadCount <= 0; const container = document.querySelector("#alertList"); container.innerHTML = state.alerts.map((item) => { const upcoming = !item.due; const kindLabel = item.kind === "manual" ? "自定提醒" : item.kind === "strategy_t5" ? "跟踪完成" : "策略反馈"; return `
${escapeHtml(kindLabel)}
${escapeHtml(item.title)} ${item.content ? `

${escapeHtml(item.content)}

` : ""} ${item.code ? `` : ""}
${!item.is_read && !upcoming ? `` : ""}
`; }).join("") || emptyStateHtml("暂无提醒"); bindStockRows(container); refreshIcons(); } async function openReviewAssistant() { toggleHeaderCommandMenu(false); toggleAccountDropdown(false); openModalDialog(elements.assistantDialog); updateAssistantControls(); if (!hasMemberAccess()) { document.querySelector("#closeAssistantDialog").focus(); return; } try { const payload = await apiRequest("/api/assistant/messages"); state.assistantMessages = payload.items || []; renderAssistantMessages(); } catch (error) { showToast(error.message || "对话记录加载失败"); } document.querySelector("#assistantQuestion").focus(); } function useAssistantPrompt(prompt) { const input = document.querySelector("#assistantQuestion"); input.value = prompt; input.focus(); } async function sendAssistantQuestion(event) { event.preventDefault(); if (state.assistantLoading) return; const input = document.querySelector("#assistantQuestion"); const question = input.value.trim(); if (!question) return; input.value = ""; state.assistantMessages.push({ role: "user", content: question, context_date: elements.tradeDate.value.replaceAll("-", "") }); state.assistantMessages.push({ role: "assistant", content: "", streaming: true, context_date: elements.tradeDate.value.replaceAll("-", "") }); state.assistantLoading = true; state.assistantController = new AbortController(); updateAssistantControls(); renderAssistantMessages(); try { await streamAssistantRequest(question, state.assistantController.signal, (chunk) => { const message = state.assistantMessages.at(-1); if (message?.role === "assistant") message.content += chunk; scheduleAssistantRender(); }); const message = state.assistantMessages.at(-1); if (message) message.streaming = false; setStatus("复盘助手回答完成"); } catch (error) { const message = state.assistantMessages.at(-1); if (message?.role === "assistant") { message.streaming = false; message.error = true; if (!message.content) message.content = error.name === "AbortError" ? "已停止生成。" : error.message || "回答失败,请稍后重试。"; } if (error.name !== "AbortError") showToast(error.message || "复盘助手回答失败"); } finally { state.assistantLoading = false; state.assistantController = null; updateAssistantControls(); renderAssistantMessages(); input.focus(); } } async function streamAssistantRequest(question, signal, onDelta) { await window.XiaobaiAPI.streamNdjson("/api/assistant/chat", { method: "POST", body: { question, trade_date: elements.tradeDate.value }, signal, errorMessage: "复盘助手暂不可用", onEvent: (event) => { if (event.type === "delta") onDelta(String(event.content || "")); }, }); } function stopAssistantResponse() { state.assistantController?.abort(); } async function clearAssistantConversation() { if (state.assistantLoading || !state.assistantMessages.length) return; if (!window.confirm("确定清空复盘助手的对话记录吗?")) return; try { await apiRequest("/api/assistant/messages", "DELETE"); state.assistantMessages = []; renderAssistantMessages(); } catch (error) { showToast(error.message || "对话记录清空失败"); } } function scheduleAssistantRender() { if (assistantRenderFrame) return; assistantRenderFrame = requestAnimationFrame(() => { assistantRenderFrame = 0; renderAssistantMessages(); }); } function renderAssistantMessages() { const container = document.querySelector("#assistantMessages"); container.innerHTML = state.assistantMessages.map((message) => `
${message.role === "user" ? "我" : "复盘助手"}${message.context_date ? `` : ""}
${message.role === "assistant" ? (message.content ? formatMentorAnswer(message.content) : '正在整理复盘数据') : escapeHtml(message.content)}
${message.streaming ? '' : ""}
`).join("") || emptyStateHtml("可以从市场、策略或自己的交易记录开始复盘"); updateAssistantControls(); requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; }); } function updateAssistantControls() { const unlocked = hasMemberAccess(); elements.assistantDialog.classList.toggle("member-locked", !unlocked); document.querySelector("#assistantMemberGate").hidden = unlocked; document.querySelector("#assistantMemberContent").setAttribute("aria-disabled", String(!unlocked)); document.querySelector("#assistantQuestion").disabled = !unlocked || state.assistantLoading; document.querySelector("#sendAssistant").disabled = !unlocked || state.assistantLoading; document.querySelector("#stopAssistant").hidden = !unlocked || !state.assistantLoading; document.querySelector("#clearAssistantMessages").disabled = !unlocked || state.assistantLoading || !state.assistantMessages.length; document.querySelectorAll("[data-assistant-prompt]").forEach((button) => { button.disabled = !unlocked || state.assistantLoading; }); } /* PRESERVATION-SOURCE-END app.js:7720-7968 */