700 lines
30 KiB
JavaScript
700 lines
30 KiB
JavaScript
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) => `
|
|
<tr data-code="${escapeHtml(item.code)}"><td><span class="review-watch-mark ${escapeHtml(item.color)}" title="${escapeHtml(item.color)}">★</span></td>
|
|
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(item.name)}</strong><small class="stock-code scode">${escapeHtml(item.code)}</small></span></td>
|
|
<td>${escapeHtml(item.sector || "其他")}</td>
|
|
<td class="number num ${item.change == null ? "" : changeClass(item.change)}">${formatWatchMetric(item.change)}</td>
|
|
<td class="number num ${item.return_5d == null ? "" : changeClass(item.return_5d)}">${formatWatchMetric(item.return_5d)}</td>
|
|
<td class="number num"><strong class="watch-attention-score">${item.attention_score == null ? "" : formatNumber(item.attention_score, 1)}</strong></td>
|
|
<td><span class="watch-remark" title="${escapeHtml(item.remark || "尚未填写跟踪备注")}">${escapeHtml(item.remark || "尚未填写")}</span></td>
|
|
<td><span class="review-row-actions"><button class="table-action" type="button" data-watch-remark="${escapeHtml(item.code)}">备注</button>
|
|
<button class="table-action down" type="button" data-watch-delete="${escapeHtml(item.code)}" aria-label="移除 ${escapeHtml(item.name)}">移除</button></span></td></tr>
|
|
`).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 = '<div class="watchlist-search-status">正在查找股票</div>';
|
|
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) => `
|
|
<button type="button" data-watchlist-result="${index}"><span><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.industry || "其他")}</small></span><b>${escapeHtml(item.code)}</b></button>
|
|
`).join("") || '<div class="watchlist-search-status">没有找到匹配的股票</div>';
|
|
} catch (error) {
|
|
document.querySelector("#watchlistSearchResults").innerHTML = `<div class="watchlist-search-status">${escapeHtml(error.message || "搜索失败")}</div>`;
|
|
}
|
|
}
|
|
|
|
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]) => `<div><span>${label}</span><strong>${value}</strong></div>`).join("");
|
|
document.querySelector("#tradeLogEmpty").hidden = state.tradeEntries.length > 0;
|
|
document.querySelector("#tradeLogTableBody").innerHTML = state.tradeEntries.map((item) => `
|
|
<tr data-code="${escapeHtml(item.code)}">
|
|
<td>${displayCompactDate(item.trade_date)}</td>
|
|
<td><span class="stock-cell"><strong class="sname">${escapeHtml(item.name)}</strong><small class="stock-code scode">${escapeHtml(item.code)}</small></span></td>
|
|
<td><span class="trade-action trade-action-${escapeHtml(item.action)}">${escapeHtml(item.action_label)}</span></td>
|
|
<td class="number num">${item.position_pct == null ? "" : formatNumber(item.position_pct, 1)}</td>
|
|
<td class="number num ${item.pnl_pct == null ? "" : changeClass(item.pnl_pct)}">${item.pnl_pct == null ? "" : signed(item.pnl_pct)}</td>
|
|
<td class="number num ${item.pnl_amount == null ? "" : changeClass(item.pnl_amount)}">${item.pnl_amount == null ? "" : signed(item.pnl_amount)}</td>
|
|
<td><span class="trade-emotion">${escapeHtml(item.emotion_label)}</span><div class="trade-tags">${(item.tags || []).map((tag) => `<em>${escapeHtml(tag)}</em>`).join("")}</div></td>
|
|
<td class="trade-copy" title="交易逻辑:${escapeHtml(item.thesis || "")};执行复核:${escapeHtml(item.execution || "")}"><strong>${escapeHtml(item.thesis || "")}</strong><small>${escapeHtml(item.execution || "尚未填写执行复核")}</small></td>
|
|
<td><div class="trade-row-actions"><button class="table-action" type="button" data-trade-action="edit" data-trade-id="${number(item.id)}">编辑</button><button class="table-action down" type="button" data-trade-action="delete" data-trade-id="${number(item.id)}">删除</button></div></td>
|
|
</tr>
|
|
`).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) => `
|
|
<article class="note-row">
|
|
<div><time>${displayCompactDate(note.trade_date)}</time>${note.stock_name ? `<small>${escapeHtml(note.stock_name)}</small>` : ""}</div>
|
|
${!compact ? `<div class="note-block note-summary"><strong>盘面</strong><p>${escapeHtml(note.summary || "--")}</p></div>` : ""}
|
|
<div class="note-block"><strong>复盘</strong><p>${escapeHtml(note.content || "--")}</p></div>
|
|
<div class="note-block"><strong>计划</strong><p>${escapeHtml(note.plan || "--")}</p></div>
|
|
<button class="table-action down" type="button" data-note-delete="${number(note.id)}">删除</button>
|
|
</article>
|
|
`).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 `<article class="alert-item ${item.is_read ? "is-read" : "is-unread"} ${upcoming ? "is-upcoming" : ""}">
|
|
<div class="alert-item-icon"><i data-lucide="${upcoming ? "calendar-clock" : item.kind === "manual" ? "bell" : "chart-no-axes-combined"}"></i></div>
|
|
<div class="alert-item-copy">
|
|
<div><span>${escapeHtml(kindLabel)}</span><time>${displayCompactDate(item.available_date)}</time></div>
|
|
<strong>${escapeHtml(item.title)}</strong>
|
|
${item.content ? `<p>${escapeHtml(item.content)}</p>` : ""}
|
|
${item.code ? `<button class="stock-preview-trigger alert-stock-link" type="button" data-code="${escapeHtml(item.code)}">${escapeHtml(item.code)}</button>` : ""}
|
|
</div>
|
|
<div class="alert-item-actions">
|
|
${!item.is_read && !upcoming ? `<button class="icon-button" type="button" data-alert-action="read" data-alert-id="${number(item.id)}" title="标为已读" aria-label="标为已读"><i data-lucide="check"></i></button>` : ""}
|
|
<button class="icon-button" type="button" data-alert-action="delete" data-alert-id="${number(item.id)}" title="删除提醒" aria-label="删除提醒"><i data-lucide="trash-2"></i></button>
|
|
</div>
|
|
</article>`;
|
|
}).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) => `
|
|
<article class="assistant-message ${message.role} ${message.error ? "is-error" : ""}">
|
|
<div class="assistant-message-label">${message.role === "user" ? "我" : "复盘助手"}${message.context_date ? `<time>${displayCompactDate(message.context_date)}</time>` : ""}</div>
|
|
<div class="assistant-message-content">${message.role === "assistant" ? (message.content ? formatMentorAnswer(message.content) : '<span class="assistant-thinking">正在整理复盘数据</span>') : escapeHtml(message.content)}</div>
|
|
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
|
|
</article>
|
|
`).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 */
|