diff --git a/alert_service.py b/alert_service.py new file mode 100644 index 0000000..fc36b47 --- /dev/null +++ b/alert_service.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import secrets +from datetime import date, datetime +from typing import Any + +from app_config import validate_text +from database import ReviewDatabase + + +class AlertService: + def __init__(self, database: ReviewDatabase) -> None: + self.database = database + + def create_manual(self, user_id: int, payload: dict[str, Any]) -> int: + title = validate_text(payload.get("title"), "提醒标题", 80, required=True) + content = validate_text(payload.get("content"), "提醒内容", 500) + code = validate_text(payload.get("code"), "股票代码", 12) + available_date = self.calendar_date( + str(payload.get("remind_date") or date.today().isoformat()) + ) + return self.database.save_alert( + user_id=user_id, + kind="manual", + title=title, + content=content, + available_date=available_date, + code=code, + dedupe_key=f"manual:{secrets.token_hex(12)}", + ) + + def sync_strategy_tracking(self, user_id: int, tracking: dict[str, Any]) -> int: + synced = 0 + today = date.today().strftime("%Y%m%d") + for batch in tracking.get("batches") or []: + items = batch.get("items") or [] + summary = batch.get("summary") or {} + if not items: + continue + run_id = int(batch.get("run_id") or 0) + strategy_name = str(batch.get("strategy_name") or "选股策略") + observed = int(summary.get("observed") or 0) + completed = int(summary.get("completed") or 0) + if observed: + win_rate = summary.get("t1_win_rate") + suffix = f",当前红盘率 {win_rate:.1f}%" if win_rate is not None else "" + self.database.save_alert( + user_id, "strategy_t1", f"{strategy_name} 已有 T+1 反馈", + f"{observed}/{len(items)} 只标的已有首日表现{suffix}。", + today, "", f"strategy:{run_id}:t1", + ) + synced += 1 + if completed == len(items): + average = summary.get("average_t5") + suffix = f",平均收益 {average:+.2f}%" if average is not None else "" + self.database.save_alert( + user_id, "strategy_t5", f"{strategy_name} 五日跟踪完成", + f"本批 {len(items)} 只标的已完成 T+5 跟踪{suffix}。", + today, "", f"strategy:{run_id}:t5", + ) + synced += 1 + return synced + + def list_alerts( + self, user_id: int, status: str = "all", as_of: str = "" + ) -> dict[str, Any]: + if status not in {"all", "unread"}: + raise ValueError("提醒筛选不支持。") + compact_date = self.calendar_date(as_of or date.today().isoformat()) + items = self.database.list_alerts(user_id, compact_date, status == "unread") + for item in items: + item["due"] = str(item.get("available_date") or "") <= compact_date + return { + "items": items, + "unread_count": self.database.count_unread_alerts(user_id, compact_date), + "as_of": compact_date, + } + + @staticmethod + def calendar_date(value: str) -> str: + compact = value.replace("-", "").strip() + try: + parsed = datetime.strptime(compact, "%Y%m%d") + except ValueError as exc: + raise ValueError("提醒日期格式应为 YYYY-MM-DD。") from exc + return parsed.strftime("%Y%m%d") diff --git a/database.py b/database.py index e84a2fa..6975547 100644 --- a/database.py +++ b/database.py @@ -277,6 +277,26 @@ class ReviewDatabase: CREATE INDEX IF NOT EXISTS idx_strategy_tracks_user_run ON strategy_tracks(user_id, run_id DESC, id); + + CREATE TABLE IF NOT EXISTS alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + kind TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + available_date TEXT NOT NULL, + code TEXT NOT NULL DEFAULT '', + dedupe_key TEXT NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + read_at TEXT, + UNIQUE(user_id, dedupe_key), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_alerts_user_due + ON alerts(user_id, available_date, is_read, id DESC); """ ) user_columns = { @@ -1426,6 +1446,110 @@ class ReviewDatabase: for ts_code, selection_date in unique_targets } + def save_alert( + self, + user_id: int, + kind: str, + title: str, + content: str, + available_date: str, + code: str, + dedupe_key: str, + ) -> int: + now = datetime.now().astimezone().isoformat(timespec="seconds") + with self.connect() as connection: + connection.execute( + """ + INSERT INTO alerts + (user_id, kind, title, content, available_date, code, dedupe_key, + is_read, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + ON CONFLICT(user_id, dedupe_key) DO UPDATE SET + title=excluded.title, content=excluded.content, + available_date=excluded.available_date, updated_at=excluded.updated_at + """, + ( + int(user_id), kind, title, content, available_date, code, + dedupe_key, now, now, + ), + ) + row = connection.execute( + "SELECT id FROM alerts WHERE user_id = ? AND dedupe_key = ?", + (int(user_id), dedupe_key), + ).fetchone() + return int(row["id"]) + + def list_alerts( + self, user_id: int, as_of: str, unread_only: bool = False, limit: int = 100 + ) -> list[dict[str, Any]]: + with self.connect() as connection: + if unread_only: + rows = connection.execute( + """ + SELECT id, kind, title, content, available_date, code, is_read, + created_at, updated_at, read_at + FROM alerts + WHERE user_id = ? AND available_date <= ? AND is_read = 0 + ORDER BY available_date DESC, id DESC LIMIT ? + """, + (int(user_id), as_of, max(1, min(300, int(limit)))), + ).fetchall() + else: + rows = connection.execute( + """ + SELECT id, kind, title, content, available_date, code, is_read, + created_at, updated_at, read_at + FROM alerts WHERE user_id = ? + ORDER BY CASE WHEN available_date > ? THEN 0 ELSE 1 END, + is_read, available_date, id DESC LIMIT ? + """, + (int(user_id), as_of, max(1, min(300, int(limit)))), + ).fetchall() + return [{**dict(row), "is_read": bool(row["is_read"])} for row in rows] + + def count_unread_alerts(self, user_id: int, as_of: str) -> int: + with self.connect() as connection: + row = connection.execute( + """ + SELECT COUNT(*) AS total FROM alerts + WHERE user_id = ? AND available_date <= ? AND is_read = 0 + """, + (int(user_id), as_of), + ).fetchone() + return int(row["total"] if row else 0) + + def mark_alert_read(self, user_id: int, alert_id: int) -> bool: + now = datetime.now().astimezone().isoformat(timespec="seconds") + with self.connect() as connection: + cursor = connection.execute( + """ + UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ? + WHERE id = ? AND user_id = ? + """, + (now, now, int(alert_id), int(user_id)), + ) + return cursor.rowcount > 0 + + def mark_all_alerts_read(self, user_id: int, as_of: str) -> int: + now = datetime.now().astimezone().isoformat(timespec="seconds") + with self.connect() as connection: + cursor = connection.execute( + """ + UPDATE alerts SET is_read = 1, read_at = ?, updated_at = ? + WHERE user_id = ? AND available_date <= ? AND is_read = 0 + """, + (now, now, int(user_id), as_of), + ) + return int(cursor.rowcount) + + def delete_alert(self, user_id: int, alert_id: int) -> bool: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM alerts WHERE id = ? AND user_id = ?", + (int(alert_id), int(user_id)), + ) + return cursor.rowcount > 0 + def start_sync(self, trade_date: str, source: str) -> int: started_at = datetime.now().astimezone().isoformat(timespec="seconds") with self.connect() as connection: diff --git a/server.py b/server.py index a2359e2..50fd984 100644 --- a/server.py +++ b/server.py @@ -15,6 +15,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any from urllib.parse import parse_qs, unquote, urlparse +from alert_service import AlertService from api_access import required_role from app_config import ( DATA_DIR, @@ -129,6 +130,7 @@ class DashboardService: self._system_credentials = self._load_system_credentials(environment_credentials) self.screener = ScreenerEngine(self.database) self.strategy_tracking = StrategyTrackingService(self.database) + self.alert_service = AlertService(self.database) self.mentor_skills = MentorSkillRegistry(MENTOR_SKILLS_DIR) self.realtime_aggregator = WebRealtimeAggregator() self.screener.ensure_builtin_strategies() @@ -1104,6 +1106,30 @@ class DashboardService: "notice": notice, } + def alert_center(self, status: str = "all", as_of: str = "") -> dict[str, Any]: + tracking = self.strategy_tracking.list_tracking(self.current_user_id, 12) + self.alert_service.sync_strategy_tracking(self.current_user_id, tracking) + return self.alert_service.list_alerts( + self.current_user_id, status, as_of + ) + + def create_alert(self, payload: dict[str, Any]) -> dict[str, Any]: + alert_id = self.alert_service.create_manual(self.current_user_id, payload) + return {"id": alert_id, **self.alert_center()} + + def mark_alert_read(self, alert_id: int) -> dict[str, Any]: + self.database.mark_alert_read(self.current_user_id, alert_id) + return self.alert_center() + + def mark_all_alerts_read(self, as_of: str = "") -> dict[str, Any]: + compact_date = self.alert_service.calendar_date(as_of or date.today().isoformat()) + self.database.mark_all_alerts_read(self.current_user_id, compact_date) + return self.alert_center(as_of=compact_date) + + def delete_alert(self, alert_id: int) -> dict[str, Any]: + deleted = self.database.delete_alert(self.current_user_id, alert_id) + return {"deleted": deleted, **self.alert_center()} + def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]: if not self.configured: raise ValueError("请先配置 Tushare Token。") @@ -3368,6 +3394,18 @@ class RequestHandler(BaseHTTPRequestHandler): if parsed.path == "/api/account/status": self.send_json({"ok": True, **SERVICE.status()}) return + if parsed.path == "/api/alerts": + query = parse_qs(parsed.query) + try: + self.send_json( + SERVICE.alert_center( + query.get("status", ["all"])[0], + query.get("as_of", [date.today().isoformat()])[0], + ) + ) + except ValueError as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + return if parsed.path == "/api/dashboard": query = parse_qs(parsed.query) trade_date = query.get("trade_date", [date.today().isoformat()])[0] @@ -3574,6 +3612,21 @@ class RequestHandler(BaseHTTPRequestHandler): if parsed.path == "/api/account/password": self.change_password() return + alert_read_match = re.fullmatch(r"/api/alerts/(\d+)/read", parsed.path) + if alert_read_match: + self.send_json( + {"ok": True, **SERVICE.mark_alert_read(int(alert_read_match.group(1)))} + ) + return + if parsed.path == "/api/alerts/read-all": + body = self.read_json_body(True) + self.send_json( + {"ok": True, **SERVICE.mark_all_alerts_read(str(body.get("as_of") or ""))} + ) + return + if parsed.path == "/api/alerts": + self.save_alert() + return if parsed.path == "/api/admin/settings": self.save_system_settings() return @@ -3676,6 +3729,12 @@ class RequestHandler(BaseHTTPRequestHandler): ) self.send_json({"ok": True, "deleted": deleted}) return + alert_match = re.fullmatch(r"/api/alerts/(\d+)", parsed.path) + if alert_match: + self.send_json( + {"ok": True, **SERVICE.delete_alert(int(alert_match.group(1)))} + ) + return sector_phase_match = re.fullmatch(r"/api/heaven/sector-phases/(.+)", parsed.path) if sector_phase_match: name = unquote(sector_phase_match.group(1)).strip() @@ -3777,6 +3836,13 @@ class RequestHandler(BaseHTTPRequestHandler): except (ValueError, json.JSONDecodeError) as exc: self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + def save_alert(self) -> None: + try: + body = self.read_json_body() + self.send_json({"ok": True, **SERVICE.create_alert(body)}, HTTPStatus.CREATED) + except (ValueError, json.JSONDecodeError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + def session_token(self) -> str: cookie = SimpleCookie() try: diff --git a/static/app.js b/static/app.js index 8af24a2..0153efa 100644 --- a/static/app.js +++ b/static/app.js @@ -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 `
+
+
+
${escapeHtml(kindLabel)}
+ ${escapeHtml(item.title)} + ${item.content ? `

${escapeHtml(item.content)}

` : ""} + ${item.code ? `` : ""} +
+
+ ${!item.is_read && !upcoming ? `` : ""} + +
+
`; + }).join("") || '
暂无提醒
'; + bindStockRows(container); + refreshIcons(); +} + function openGlobalSearch() { if (!state.user) return; toggleHeaderCommandMenu(false); diff --git a/static/index.html b/static/index.html index 5b7fced..362f284 100644 --- a/static/index.html +++ b/static/index.html @@ -62,6 +62,7 @@ +
@@ -1022,6 +1023,7 @@
+
@@ -1078,6 +1080,34 @@ + +
+
当前账号

提醒中心

+ +
+
+
+ + +
+ +
+
+

新建提醒

仅站内
+
+ + + +
+ +
+
+
+

提醒记录

0 条
+
+
+
+
diff --git a/static/styles.css b/static/styles.css index 3510778..d2c5ff3 100644 --- a/static/styles.css +++ b/static/styles.css @@ -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; } +} diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index 6d1fbba..dffcd52 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -54,6 +54,7 @@ async function mockApplication(page, authSession = session()) { if (url.pathname === "/api/auth/me") payload = authSession; else if (url.pathname === "/api/dashboard") payload = dashboard; else if (url.pathname === "/api/watchlist" || url.pathname === "/api/notes") payload = { items: [] }; + else if (url.pathname === "/api/alerts") payload = { items: [], unread_count: 0 }; else if (url.pathname === "/api/search") payload = { groups: { stocks: [], sectors: [], themes: [], indices: [] } }; else if (url.pathname === "/api/dragon-tiger") { payload = { @@ -88,6 +89,9 @@ test("admin shell opens every primary workspace and global search", async ({ pag await expect(page.locator("#authGate")).toBeHidden(); await expect(page.locator("#settingsButton")).toBeVisible(); await expect(page.locator("#syncButton")).toBeVisible(); + await page.locator("#alertButton").click(); + await expect(page.locator("#alertsDialog")).toBeVisible(); + await page.locator("#closeAlertsDialog").click(); const views = [ "sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView", diff --git a/tests/test_alerts.py b/tests/test_alerts.py new file mode 100644 index 0000000..81bfe87 --- /dev/null +++ b/tests/test_alerts.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from alert_service import AlertService +from database import ReviewDatabase + + +class AlertServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.database = ReviewDatabase(Path(self.temp.name) / "review.db") + self.owner = self.database.create_user("alert_owner", "salt", "hash") + self.other = self.database.create_user("alert_other", "salt", "hash") + self.service = AlertService(self.database) + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_future_manual_alert_is_visible_but_not_unread_until_due(self): + alert_id = self.service.create_manual( + self.owner["id"], + { + "title": "复核承接", + "content": "开盘不及预期则退出观察", + "code": "002141", + "remind_date": "2026-07-25", + }, + ) + before = self.service.list_alerts(self.owner["id"], "all", "2026-07-22") + due = self.service.list_alerts(self.owner["id"], "unread", "2026-07-25") + + self.assertEqual(before["items"][0]["id"], alert_id) + self.assertFalse(before["items"][0]["due"]) + self.assertEqual(before["unread_count"], 0) + self.assertEqual(due["unread_count"], 1) + self.assertTrue(due["items"][0]["due"]) + + def test_alert_reads_and_mutations_are_scoped_to_owner(self): + alert_id = self.database.save_alert( + self.owner["id"], "manual", "甲的提醒", "", "20260722", "", "owner-only" + ) + self.assertEqual( + self.service.list_alerts(self.other["id"], "all", "2026-07-22")["items"], [] + ) + self.assertFalse(self.database.mark_alert_read(self.other["id"], alert_id)) + self.assertFalse(self.database.delete_alert(self.other["id"], alert_id)) + self.assertTrue(self.database.mark_alert_read(self.owner["id"], alert_id)) + self.assertEqual( + self.service.list_alerts(self.owner["id"], "all", "2026-07-22")["unread_count"], 0 + ) + self.assertTrue(self.database.delete_alert(self.owner["id"], alert_id)) + + def test_strategy_alerts_are_idempotent(self): + tracking = { + "batches": [ + { + "run_id": 9, + "strategy_name": "修复策略", + "items": [{"code": "002141"}, {"code": "600000"}], + "summary": { + "observed": 2, + "completed": 2, + "t1_win_rate": 50.0, + "average_t5": 3.25, + }, + } + ] + } + self.service.sync_strategy_tracking(self.owner["id"], tracking) + self.service.sync_strategy_tracking(self.owner["id"], tracking) + alerts = self.database.list_alerts( + self.owner["id"], "99991231", unread_only=False + ) + + self.assertEqual(len(alerts), 2) + self.assertEqual({item["kind"] for item in alerts}, {"strategy_t1", "strategy_t5"}) + + def test_mark_all_only_changes_due_alerts(self): + self.database.save_alert( + self.owner["id"], "manual", "今日", "", "20260722", "", "due" + ) + self.database.save_alert( + self.owner["id"], "manual", "未来", "", "20260723", "", "future" + ) + self.assertEqual( + self.database.mark_all_alerts_read(self.owner["id"], "20260722"), 1 + ) + future = self.service.list_alerts(self.owner["id"], "unread", "2026-07-23") + self.assertEqual([item["title"] for item in future["items"]], ["未来"]) + + +if __name__ == "__main__": + unittest.main()