feat: add account-scoped in-app reminder center
This commit is contained in:
@@ -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")
|
||||
+124
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user