feat: add account-scoped in-app reminder center

This commit is contained in:
leefer
2026-07-23 00:06:33 +08:00
parent 5ae2791dd3
commit a2a5db8f6a
8 changed files with 608 additions and 0 deletions
+137
View File
@@ -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);
+30
View File
@@ -62,6 +62,7 @@
<button id="nextDate" class="icon-button" type="button" title="后一个交易日" aria-label="后一个交易日"><i data-lucide="chevron-right"></i></button>
</div>
<button id="globalSearchButton" class="icon-button global-search-button" type="button" title="全局搜索(Ctrl+K" aria-label="全局搜索"><i data-lucide="search"></i></button>
<button id="alertButton" class="icon-button alert-button" type="button" title="提醒中心" aria-label="提醒中心"><i data-lucide="bell"></i><span id="alertBadge" class="alert-badge" hidden>0</span></button>
<button id="headerMenuButton" class="icon-button header-menu-button" type="button" title="打开命令菜单" aria-label="打开命令菜单" aria-expanded="false" aria-controls="headerCommandGroup"><i data-lucide="ellipsis"></i></button>
<div id="headerCommandGroup" class="header-command-group">
<button id="refreshButton" class="button command-button" type="button"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
@@ -1022,6 +1023,7 @@
</div>
<div class="dialog-header-actions">
<button id="stockHeavenButton" class="button stock-heaven-button" type="button"><i data-lucide="sparkles"></i><span>观势</span></button>
<button id="stockReminderButton" class="button" type="button"><i data-lucide="bell-plus"></i><span>设提醒</span></button>
<button id="watchStockButton" class="button" type="button">加入自选</button>
<button id="closeStockDialog" class="icon-button" type="button" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
</div>
@@ -1078,6 +1080,34 @@
</section>
</dialog>
<dialog id="alertsDialog" class="settings-dialog alerts-dialog" aria-labelledby="alertsDialogTitle">
<div class="dialog-header">
<div><span class="dialog-eyebrow">当前账号</span><h2 id="alertsDialogTitle">提醒中心</h2></div>
<button id="closeAlertsDialog" class="icon-button" type="button" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
</div>
<div class="alerts-toolbar">
<div class="segmented" role="group" aria-label="提醒筛选">
<button class="segment active" type="button" data-alert-filter="all">全部</button>
<button class="segment" type="button" data-alert-filter="unread">未读</button>
</div>
<button id="markAllAlertsRead" class="button" type="button">全部已读</button>
</div>
<form id="alertForm" class="settings-section alert-form">
<div class="settings-section-heading"><h3>新建提醒</h3><span>仅站内</span></div>
<div class="alert-form-grid">
<label class="form-field"><span>标题</span><input id="alertTitle" maxlength="80" required placeholder="例如:复核开盘承接"></label>
<label class="form-field"><span>提醒日期</span><input id="alertDate" type="date" required></label>
<label class="form-field"><span>股票代码(可选)</span><input id="alertCode" maxlength="12" inputmode="numeric" placeholder="002141"></label>
</div>
<label class="form-field"><span>提醒内容(可选)</span><textarea id="alertContent" maxlength="500" placeholder="记录触发条件和应对动作"></textarea></label>
<div class="dialog-actions"><button class="button primary" type="submit">保存提醒</button></div>
</form>
<section class="settings-section alert-list-section">
<div class="settings-section-heading"><h3>提醒记录</h3><span id="alertListCount">0 条</span></div>
<div id="alertList" class="alert-list"></div>
</section>
</dialog>
<dialog id="settingsDialog" class="settings-dialog">
<div class="dialog-header">
<div>
+65
View File
@@ -11407,3 +11407,68 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin
.tracking-summary > div:nth-child(2n) { border-right: 0; }
.strategy-tracking-panel .section-toolbar { align-items: flex-start; }
}
.alert-button { position: relative; }
.alert-button.has-alerts {
border-color: #d6b45d;
background: #fff9e9;
color: #8a6100;
}
.alert-badge {
position: absolute;
top: -5px;
right: -5px;
min-width: 18px;
height: 18px;
padding: 0 4px;
border: 2px solid var(--surface);
border-radius: 9px;
background: var(--market-up);
color: #fff;
font-size: 9px;
font-weight: 750;
line-height: 14px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.alerts-dialog { width: min(760px, calc(100vw - 32px)); }
.alerts-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 18px; border-bottom: 1px solid var(--border); }
.alert-form { border-bottom: 1px solid var(--border); }
.alert-form-grid { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(150px, 0.7fr) minmax(130px, 0.6fr); gap: 12px; }
.alert-list-section { padding-bottom: 18px; }
.alert-list { display: grid; border-top: 1px solid var(--border); }
.alert-item {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
min-height: 82px;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.alert-item.is-unread { box-shadow: inset 3px 0 var(--action); }
.alert-item.is-read { opacity: 0.68; }
.alert-item.is-upcoming { background: var(--surface-muted); opacity: 1; }
.alert-item-icon { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 6px; background: var(--action-soft); color: var(--action); }
.alert-item-icon .lucide { width: 18px; height: 18px; }
.alert-item-copy { min-width: 0; }
.alert-item-copy > div { display: flex; gap: 10px; color: var(--text-secondary); font-size: 10px; }
.alert-item-copy strong { display: block; margin-top: 5px; font-size: 13px; }
.alert-item-copy p { margin: 5px 0 0; color: var(--text-secondary); font-size: 12px; line-height: 1.55; }
.alert-stock-link { margin-top: 6px; border: 0; background: transparent; color: var(--action); cursor: pointer; font-size: 11px; }
.alert-item-actions { display: flex; gap: 6px; }
.alert-item-actions .icon-button { width: 34px; height: 34px; }
@media (max-width: 720px) {
.alerts-dialog { width: calc(100vw - 16px); margin: 8px auto; }
.alert-form-grid { grid-template-columns: 1fr; }
.alert-item { grid-template-columns: 32px minmax(0, 1fr); padding-inline: 13px; }
.alert-item-icon { width: 32px; height: 32px; }
.alert-item-actions { grid-column: 2; }
}