feat: add account-scoped in-app reminder center
This commit is contained in:
+137
@@ -44,6 +44,9 @@ const state = {
|
||||
selectedStrategy: null,
|
||||
screenerResult: null,
|
||||
screenerTracking: null,
|
||||
alerts: [],
|
||||
alertFilter: "all",
|
||||
alertUnreadCount: 0,
|
||||
screenerMobileView: "strategy",
|
||||
sentimentHistory: null,
|
||||
sentimentRange: 20,
|
||||
@@ -86,6 +89,7 @@ const elements = {
|
||||
loading: document.querySelector("#loadingOverlay"),
|
||||
toast: document.querySelector("#toast"),
|
||||
stockDialog: document.querySelector("#stockDialog"),
|
||||
alertsDialog: document.querySelector("#alertsDialog"),
|
||||
globalSearchDialog: document.querySelector("#globalSearchDialog"),
|
||||
globalSearchInput: document.querySelector("#globalSearchInput"),
|
||||
globalSearchResults: document.querySelector("#globalSearchResults"),
|
||||
@@ -216,6 +220,7 @@ async function initialize() {
|
||||
document.querySelector("#qiObservationDate").value = elements.tradeDate.value;
|
||||
document.querySelector("#qiObservationDate").max = todayString();
|
||||
document.querySelector("#accountBirthDate").max = todayString();
|
||||
document.querySelector("#alertDate").value = todayString();
|
||||
bindEvents();
|
||||
try {
|
||||
const session = await apiRequest("/api/auth/me");
|
||||
@@ -249,6 +254,7 @@ async function startAuthenticatedApp() {
|
||||
}
|
||||
}
|
||||
loadDashboard();
|
||||
loadAlerts();
|
||||
if (new URLSearchParams(window.location.search).get("settings") === "1") {
|
||||
setTimeout(openSettings, 0);
|
||||
}
|
||||
@@ -373,6 +379,14 @@ function bindEvents() {
|
||||
toggleHeaderCommandMenu();
|
||||
});
|
||||
document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch);
|
||||
document.querySelector("#alertButton").addEventListener("click", openAlerts);
|
||||
document.querySelector("#closeAlertsDialog").addEventListener("click", () => elements.alertsDialog.close());
|
||||
document.querySelector("#alertForm").addEventListener("submit", saveAlert);
|
||||
document.querySelector("#markAllAlertsRead").addEventListener("click", markAllAlertsRead);
|
||||
document.querySelector("#alertList").addEventListener("click", handleAlertAction);
|
||||
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
|
||||
button.addEventListener("click", () => selectAlertFilter(button.dataset.alertFilter));
|
||||
});
|
||||
document.querySelector("#closeGlobalSearch").addEventListener("click", closeGlobalSearch);
|
||||
document.querySelector("#closeEntityDetail").addEventListener("click", () => elements.entityDetailDialog.close());
|
||||
elements.globalSearchDialog.addEventListener("click", (event) => {
|
||||
@@ -482,6 +496,7 @@ function bindEvents() {
|
||||
document.querySelector("#stockNoteForm").addEventListener("submit", saveStockNote);
|
||||
document.querySelector("#watchStockButton").addEventListener("click", toggleActiveWatchlist);
|
||||
document.querySelector("#stockHeavenButton").addEventListener("click", openActiveStockInHeaven);
|
||||
document.querySelector("#stockReminderButton").addEventListener("click", openStockReminder);
|
||||
document.querySelector("#reasonForm").addEventListener("submit", saveReasonOverride);
|
||||
document.querySelector("#backfillButton").addEventListener("click", backfillData);
|
||||
document.querySelector("#factorSyncButton").addEventListener("click", syncFactorData);
|
||||
@@ -4234,6 +4249,128 @@ function repositionStockPreview() {
|
||||
elements.stockPreview.style.top = `${Math.round(top)}px`;
|
||||
}
|
||||
|
||||
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 && !elements.alertsDialog.open) elements.alertsDialog.showModal();
|
||||
} catch (error) {
|
||||
if (openDialog) showToast(error.message || "提醒加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function openAlerts() {
|
||||
toggleHeaderCommandMenu(false);
|
||||
toggleAccountDropdown(false);
|
||||
document.querySelector("#alertDate").value ||= todayString();
|
||||
elements.alertsDialog.showModal();
|
||||
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("") || '<div class="empty-state">暂无提醒</div>';
|
||||
bindStockRows(container);
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
function openGlobalSearch() {
|
||||
if (!state.user) return;
|
||||
toggleHeaderCommandMenu(false);
|
||||
|
||||
Reference in New Issue
Block a user