diff --git a/app.py b/app.py index 53a487c..b0dd6d2 100644 --- a/app.py +++ b/app.py @@ -60,6 +60,11 @@ def page_admin(): return render_template("admin.html", service=SERVICE_NAME, is_mock=IS_MOCK) +@app.route("/settings") +def page_settings(): + return render_template("settings.html", service=SERVICE_NAME, is_mock=IS_MOCK) + + @app.route("/stock/") def page_stock(code): return render_template("stock_detail.html", code=code, @@ -542,6 +547,96 @@ def api_backtest_rebuild(): return jsonify({"ok": True, "msg": "全市场回测重建已启动"}) +# ------------------------------------------------------------------ 设置与舆情监控 +@app.route("/api/settings") +def api_settings(): + from settings import all_settings, mail_config, monitor_config, monitor_state + from config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL + s = all_settings() + # 返回带默认值的完整配置 + mail = mail_config() + mono = monitor_config() + return jsonify({ + "mail": mail, + "monitor": mono, + "llm": { + "base_url": s.get("llm_base_url", LLM_BASE_URL), + "api_key": s.get("llm_api_key", LLM_API_KEY), + "model": s.get("llm_model", LLM_MODEL), + }, + "state": monitor_state(), + }) + + +@app.route("/api/settings", methods=["POST"]) +def api_settings_save(): + from settings import save_all, set_setting + body = request.get_json(silent=True) or {} + mail = body.get("mail") or {} + llm = body.get("llm") or {} + mono = body.get("monitor") or {} + # 邮件 + for k in ("smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_mode", + "email_to", "sender_name", "email_enabled"): + if k in mail: + set_setting(k, mail[k]) + # LLM + for k in ("llm_base_url", "llm_api_key", "llm_model"): + if k in llm: + set_setting(k, str(llm[k]).strip()) + # 监控 + for k in ("monitor_enabled", "monitor_interval", "monitor_categories", + "monitor_sentiment", "monitor_importance", "monitor_keywords"): + if k in mono: + set_setting(k, mono[k]) + return jsonify({"ok": True, "msg": "设置已保存"}) + + +@app.route("/api/settings/test-email", methods=["POST"]) +def api_settings_test_email(): + from settings import mail_config + from engine.notifier import send_email + body = request.get_json(silent=True) or {} + try: + cfg = mail_config() + send_email( + "[智能荐股] 邮件配置测试", + "

✅ 邮件配置生效

如果你收到这封邮件,说明 SMTP 设置正确,舆情通知将正常送达。

" + "

发送时间:" + time.strftime("%Y-%m-%d %H:%M:%S") + "

", + cfg=cfg) + return jsonify({"ok": True, "msg": f"测试邮件已发送到 {cfg['email_to']}"}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + +@app.route("/api/settings/test-llm", methods=["POST"]) +def api_settings_test_llm(): + from settings import llm_config + from engine.analyst import llm_chat + try: + cfg = llm_config() + rep = llm_chat([{"role": "user", "content": "回复'连接正常'四个字"}], max_tokens=20) + return jsonify({"ok": True, "msg": f"连接成功({cfg['model']}):{rep[:60]}"}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + +@app.route("/api/monitor/scan", methods=["POST"]) +def api_monitor_scan(): + from engine.notifier import scan_news + try: + r = scan_news(force=True) + return jsonify({"ok": True, **r}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + +@app.route("/api/monitor/log") +def api_monitor_log(): + from engine.notifier import notification_log + return jsonify({"items": notification_log()}) + + # ------------------------------------------------------------------ 数据管理 @app.route("/api/admin/stats") def api_admin_stats(): @@ -598,5 +693,7 @@ def api_admin_healthcheck(): if __name__ == "__main__": init_db() + from engine.notifier import start_monitor + start_monitor() print(f"✅ {SERVICE_NAME} 启动: http://0.0.0.0:{SERVICE_PORT}") app.run(host=SERVICE_HOST, port=SERVICE_PORT, threaded=True) diff --git a/config.py b/config.py index 54d8f2e..4ef775d 100644 --- a/config.py +++ b/config.py @@ -44,3 +44,24 @@ SERVICE_PORT = 16095 SERVICE_HOST = "0.0.0.0" SERVICE_NAME = "智能荐股系统" IS_MOCK = True # 当前数据为模拟数据(后期接入真实数据后改为 False) + +# ---------------- 邮件通知(默认值,可在设置区修改) ---------------- +MAIL_DEFAULTS = { + "smtp_host": "mail.tphai.com", + "smtp_port": 587, + "smtp_user": "hz4th_coder@tphai.com", + "smtp_pass": "hz4th_coder@!", + "smtp_mode": "plain", # plain(无加密) / starttls / ssl + "email_to": "wlq@tphai.com", + "sender_name": "智能荐股系统", +} + +# ---------------- 舆情监控自动化(默认值,可在设置区修改) ---------------- +MONITOR_DEFAULTS = { + "monitor_enabled": "1", + "monitor_interval": "30", # 分钟 + "monitor_categories": "", # 空=全部;逗号分隔如:公司,业绩,机构观点 + "monitor_sentiment": "0.20", # 情感绝对值权重阈值 + "monitor_importance": "45", # 重要度阈值(0-100),>= 则通知 + "monitor_keywords": "回购,中标,减持,问询,停牌,上调,下调,超预期,不及预期,预警,重组,增持,定增,业绩,退市", +} diff --git a/database.py b/database.py index 65d49e0..c5f3105 100644 --- a/database.py +++ b/database.py @@ -122,6 +122,24 @@ CREATE TABLE IF NOT EXISTS strategy_backtests ( PRIMARY KEY (strategy, code) ); +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS notification_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + news_id INTEGER DEFAULT 0, + title TEXT DEFAULT '', + category TEXT DEFAULT '', + sentiment REAL DEFAULT 0, + importance REAL DEFAULT 0, + related TEXT DEFAULT '', + status TEXT DEFAULT 'sent', -- sent / failed + message TEXT DEFAULT '', + sent_at TEXT DEFAULT (datetime('now','localtime')) +); + CREATE TABLE IF NOT EXISTS market_index ( date TEXT PRIMARY KEY, sh REAL DEFAULT 0, -- 上证指数(点) @@ -188,8 +206,9 @@ def wipe_all(): """清空业务表(保留结构)+ 重置自增序列,用于重灌数据""" for t in ("stock_daily", "inst_ratings", "fund_holdings", "news", "institutions", "stocks", "watchlist", "analysis_cache", "analysis_history", - "market_index", "strategy_backtests"): + "market_index", "strategy_backtests", "notification_log"): with db() as conn: conn.execute(f'DELETE FROM "{t}"') with db() as conn: conn.execute("DELETE FROM sqlite_sequence") # 重置 AUTOINCREMENT,保证重灌后 ID 从 1 开始 + # settings 保留(用户配置不清空) diff --git a/engine/analyst.py b/engine/analyst.py index 9a75014..c8912d4 100644 --- a/engine/analyst.py +++ b/engine/analyst.py @@ -23,18 +23,21 @@ _jobs_lock = threading.Lock() # ------------------------------------------------------------------ LLM def llm_chat(messages, max_tokens=None, temperature=None, timeout=None): - """调用 DeepSeek(OpenAI 兼容)。返回最终 content(忽略推理过程)""" + """调用 DeepSeek(OpenAI 兼容)。返回最终 content(忽略推理过程) + 模型/Key/地址从设置区读取(settings 覆盖 config 默认)""" + from settings import llm_config + cfg = llm_config() resp = requests.post( - f"{LLM_BASE_URL}/chat/completions", - headers={"Authorization": f"Bearer {LLM_API_KEY}"}, + f"{cfg['base_url']}/chat/completions", + headers={"Authorization": f"Bearer {cfg['api_key']}"}, json={ - "model": LLM_MODEL, + "model": cfg["model"], "messages": messages, - "max_tokens": max_tokens or LLM_MAX_TOKENS, - "temperature": LLM_TEMPERATURE if temperature is None else temperature, + "max_tokens": max_tokens or cfg["max_tokens"], + "temperature": cfg["temperature"] if temperature is None else temperature, "stream": False, }, - timeout=timeout or LLM_TIMEOUT, + timeout=timeout or cfg["timeout"], ) resp.raise_for_status() data = resp.json() diff --git a/engine/notifier.py b/engine/notifier.py new file mode 100644 index 0000000..dc575d7 --- /dev/null +++ b/engine/notifier.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +""" +舆情驱动自动化:定期扫描最新新闻 → 重要度评分 → 邮件通知 +- 邮件:SMTP(plain/starttls/ssl 三种模式),配置来自设置区 +- 重要度评分(0-100): + 类别基础分(公司40/业绩35/机构观点30/行业25/市场15) + + 情感绝对值权重(|sentiment| × 权重系数 × 100) + + 重要关键词命中(+12/个,上限 2 个) + + 关联个股数(+5/只) +- 去重:记录已处理的最大新闻 id(monitor_last_news_id),只扫描新增 +- 每次扫描结果写入 notification_log +""" +import logging +import smtplib +import threading +import time +from email.mime.text import MIMEText +from email.utils import formataddr, formatdate + +from database import query, execute, query_one +from settings import mail_config, monitor_config, monitor_state, set_monitor_state + +log = logging.getLogger("notifier") + +_scan_lock = threading.Lock() + +CATEGORY_BASE = {"公司": 40, "业绩": 35, "机构观点": 30, "行业": 25, "市场": 15} +STRONG_WORDS = ["回购", "中标", "减持", "问询", "停牌", "重组", "预警", + "上调", "下调", "超预期", "不及预期", "增持", "定增", "退市", "处罚"] + + +# ===================================================================== 邮件 +def send_email(subject, html_body, to=None, cfg=None, sender_name=None): + """发送 HTML 邮件。cfg 来自设置;失败抛异常(调用方捕获)""" + cfg = cfg or mail_config() + to = to or cfg["email_to"] + msg = MIMEText(html_body, "html", "utf-8") + msg["From"] = formataddr((sender_name or cfg["sender_name"], cfg["smtp_user"])) + msg["To"] = to + msg["Subject"] = subject + msg["Date"] = formatdate(localtime=True) + + mode = cfg.get("smtp_mode", "plain") + if mode == "ssl": + server = smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], timeout=30) + else: + server = smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=30) + server.ehlo() + if mode == "starttls": + server.starttls() + server.ehlo() + server.login(cfg["smtp_user"], cfg["smtp_pass"]) + server.sendmail(cfg["smtp_user"], [to], msg.as_string()) + server.quit() + return True + + +# ===================================================================== 重要度 +def importance_score(news, cfg): + """返回 (score, [原因...])""" + score = CATEGORY_BASE.get(news.get("category", "市场"), 15) + reasons = [f"{news.get('category','市场')}类"] + # 情感 + sent = abs(float(news.get("sentiment") or 0)) + if sent > 0.05: + bonus = round(sent * cfg.get("sentiment_weight", 0.2) * 100) + score += bonus + reasons.append(f"情感强度{sent:.2f}(+{bonus})") + # 关键词 + text = (news.get("title") or "") + (news.get("content") or "") + hit = 0 + for kw in cfg.get("keywords", []): + if kw and kw in text: + hit += 1 + if hit > 2: + break + if hit: + score += hit * 12 + reasons.append(f"关键词×{hit}(+{hit*12})") + # 关联个股 + related = [c for c in (news.get("related_stocks") or "").split(",") if c] + if related: + score += min(len(related), 3) * 5 + reasons.append(f"关联{len(related)}只个股(+{min(len(related),3)*5})") + return min(score, 100), reasons + + +# ===================================================================== 扫描 +def _fetch_related_names(codes): + names = {} + if not codes: + return names + for c in codes: + r = query_one("SELECT name FROM stocks WHERE code=?", (c,)) + if r: + names[c] = r["name"] + return names + + +def build_email_html(items): + """把重要新闻渲染成 HTML 邮件正文""" + rows = [] + for it in items: + n = it["news"] + tone = "利好" if n["sentiment"] > 0 else ("利空" if n["sentiment"] < 0 else "中性") + tone_color = "#e03e3e" if tone == "利好" else ("#17a34a" if tone == "利空" else "#888") + names = "、".join(f"{k}({v})" for k, v in it["stocks"].items()) or "—" + rows.append(f""" + + +
{n['title']}
+
{n['category']} · {n['source']} · {n['publish_date']}
+
重要度 {it['score']:.0f} | 情感 {tone}({n['sentiment']:+.2f}) | 关联:{names}
+
{n['content'][:120]}{'…' if len(n['content']) > 120 else ''}
+ +""") + return f""" + +
+
+
📰 舆情监控 · 重要资讯通知
+
智能荐股系统自动扫描 · {time.strftime('%Y-%m-%d %H:%M:%S')}
+
+
+

本次扫描发现 {len(items)} 条重要资讯(重要度达到阈值):

+ {''.join(rows)}
+
+
+ 本邮件由系统自动生成,内容基于模拟数据,仅供演示,不构成投资建议。可在系统「设置」中关闭通知。 +
+
""" + + +def scan_news(force=False): + """ + 扫描一次新闻:找出新增且重要度达标的新闻,发送邮件。 + force=True 时也扫描历史未通知过的(用于手动测试)。 + 返回 {"checked": n, "important": n, "sent": n, "failed": n} + """ + with _scan_lock: + cfg = monitor_config() + if not cfg["enabled"] and not force: + return {"error": "监控未启用"} + + news_rows = query("SELECT id, title, content, source, category, publish_date, " + "related_stocks, sentiment FROM news ORDER BY id ASC") + if not news_rows: + return {"checked": 0, "important": 0, "sent": 0, "failed": 0} + + max_id = max(n["id"] for n in news_rows) + state = monitor_state() + last_id = state["last_news_id"] + + # 首次运行:仅记录水位,不通知历史新闻 + if last_id == 0: + set_monitor_state(last_news_id=max_id, last_scan=time.strftime("%Y-%m-%d %H:%M:%S"), last_sent=0) + return {"checked": 0, "important": 0, "sent": 0, "failed": 0, "initialized": True} + + candidates = [n for n in news_rows if n["id"] > last_id] + + important = [] + for n in candidates: + score, _r = importance_score(n, cfg) + if score >= cfg["importance_threshold"]: + important.append({"news": n, "score": score, "reasons": _r}) + + sent, failed = 0, 0 + if important: + # 单封邮件最多带 20 条,避免超大邮件 + shown = important[:20] + omitted = len(important) - len(shown) + items = [{"news": it["news"], "score": it["score"], + "stocks": _fetch_related_names([c for c in (it["news"]["related_stocks"] or "").split(",") if c])} + for it in shown] + subject = f"[舆情监控] {len(important)} 条重要资讯:{important[0]['news']['title'][:24]}" + \ + (f" 等{len(important)}条" if len(important) > 1 else "") + try: + html = build_email_html(items) + if omitted: + html += f"

……另有 {omitted} 条重要资讯已省略,详见系统。

" + if cfg.get("email_enabled", True): + send_email(subject, html) + status, msg = "sent", "" + else: + status, msg = "skipped", "邮件功能未启用" + except Exception as e: + status, msg = "failed", str(e) + failed = len(important) + log.warning("notify email fail: %s", e) + # 写日志 + for it in important: + execute( + "INSERT INTO notification_log(news_id, title, category, sentiment, importance, related, status, message) " + "VALUES(?,?,?,?,?,?,?,?)", + (it["news"]["id"], it["news"]["title"], it["news"]["category"], + it["news"]["sentiment"], round(it["score"], 1), + it["news"]["related_stocks"], status, msg)) + if status == "sent": + sent = len(important) + + # 更新水位:前进到已处理的最后一条 + if candidates: + set_monitor_state(last_news_id=max(max_id, last_id), + last_scan=time.strftime("%Y-%m-%d %H:%M:%S"), last_sent=sent) + return {"checked": len(candidates), "important": len(important), + "sent": sent, "failed": failed} + + +def notification_log(limit=50): + return query("SELECT * FROM notification_log ORDER BY id DESC LIMIT ?", (limit,)) + + +# ===================================================================== 调度器 +class MonitorThread(threading.Thread): + """后台调度:每 interval 分钟扫描一次""" + + def __init__(self): + super().__init__(daemon=True, name="monitor") + self._stop = threading.Event() + + def stop(self): + self._stop.set() + + def run(self): + log.info("舆情监控调度器启动") + while not self._stop.is_set(): + try: + cfg = monitor_config() + if cfg["enabled"]: + try: + r = scan_news() + if r.get("checked"): + log.info("monitor scan: %s", r) + except Exception as e: + log.warning("monitor scan error: %s", e) + except Exception as e: + log.warning("monitor loop error: %s", e) + self._stop.wait(cfg.get("interval_min", 30) * 60) + log.info("舆情监控调度器停止") + + +_monitor = None + + +def start_monitor(): + global _monitor + if _monitor and _monitor.is_alive(): + return _monitor + _monitor = MonitorThread() + _monitor.start() + return _monitor diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..6f9eea3 --- /dev/null +++ b/settings.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +""" +设置管理器:SQLite settings 表 + config.py 默认值合并 +- get_setting(key, default) / set_setting(key, value) / all_settings() / save_all(dict) +- 提供 LLM / 邮件 / 监控 三段配置的便捷读取 +""" +from database import query, execute, query_one +from config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_MAX_TOKENS, \ + LLM_TEMPERATURE, LLM_TIMEOUT, MAIL_DEFAULTS, MONITOR_DEFAULTS + + +def get_setting(key, default=""): + r = query_one("SELECT value FROM settings WHERE key=?", (key,)) + return r["value"] if r else default + + +def set_setting(key, value): + execute("INSERT OR REPLACE INTO settings(key, value) VALUES(?,?)", (key, str(value))) + + +def all_settings(): + rows = query("SELECT key, value FROM settings") + return {r["key"]: r["value"] for r in rows} + + +def save_all(pairs): + """pairs: {key: value},仅保存存在的 key(白名单)""" + for k, v in pairs.items(): + set_setting(k, v) + + +# ===================================================================== LLM +def llm_config(): + """运行时 LLM 配置(设置 > 默认)""" + return { + "base_url": get_setting("llm_base_url", LLM_BASE_URL).rstrip("/"), + "api_key": get_setting("llm_api_key", LLM_API_KEY), + "model": get_setting("llm_model", LLM_MODEL), + "max_tokens": int(get_setting("llm_max_tokens", LLM_MAX_TOKENS)), + "temperature": float(get_setting("llm_temperature", LLM_TEMPERATURE)), + "timeout": int(get_setting("llm_timeout", LLM_TIMEOUT)), + } + + +# ===================================================================== 邮件 +def mail_config(): + return { + "smtp_host": get_setting("smtp_host", MAIL_DEFAULTS["smtp_host"]), + "smtp_port": int(get_setting("smtp_port", MAIL_DEFAULTS["smtp_port"])), + "smtp_user": get_setting("smtp_user", MAIL_DEFAULTS["smtp_user"]), + "smtp_pass": get_setting("smtp_pass", MAIL_DEFAULTS["smtp_pass"]), + "smtp_mode": get_setting("smtp_mode", MAIL_DEFAULTS["smtp_mode"]), + "email_to": get_setting("email_to", MAIL_DEFAULTS["email_to"]), + "sender_name": get_setting("sender_name", MAIL_DEFAULTS["sender_name"]), + "email_enabled": get_setting("email_enabled", "1") == "1", + } + + +# ===================================================================== 监控 +def monitor_config(): + cats = get_setting("monitor_categories", "").strip() + # 关键词去重保序 + kws = get_setting("monitor_keywords", MONITOR_DEFAULTS["monitor_keywords"]) + kw_list = [] + for k in kws.split(","): + k = k.strip() + if k and k not in kw_list: + kw_list.append(k) + return { + "enabled": get_setting("monitor_enabled", "1") == "1", + "interval_min": max(5, int(get_setting("monitor_interval", MONITOR_DEFAULTS["monitor_interval"]))), + "categories": [c for c in cats.split(",") if c] if cats else [], + "sentiment_weight": float(get_setting("monitor_sentiment", MONITOR_DEFAULTS["monitor_sentiment"])), + "importance_threshold": float(get_setting("monitor_importance", MONITOR_DEFAULTS["monitor_importance"])), + "keywords": kw_list, + } + + +def monitor_state(): + """监控运行状态(最近处理 id / 上次扫描时间)""" + return { + "last_news_id": int(get_setting("monitor_last_news_id", "0")), + "last_scan": get_setting("monitor_last_scan", ""), + "last_sent_count": int(get_setting("monitor_last_sent", "0")), + } + + +def set_monitor_state(last_news_id=None, last_scan=None, last_sent=None): + if last_news_id is not None: + set_setting("monitor_last_news_id", last_news_id) + if last_scan is not None: + set_setting("monitor_last_scan", last_scan) + if last_sent is not None: + set_setting("monitor_last_sent", last_sent) diff --git a/static/css/style.css b/static/css/style.css index 370d282..b1c55d1 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -202,6 +202,20 @@ tr:hover td { background: rgba(59,130,246,.05); } .between { justify-content: space-between; } .wrap { flex-wrap: wrap; } +/* ===== 设置页 ===== */ +.settings-form .sf-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } +.settings-form .sf-row label { width: 110px; color: var(--text2); font-size: 13px; flex-shrink: 0; } +.settings-form .input { flex: 1; } +.chk-group { display: flex; gap: 14px; flex-wrap: wrap; padding: 6px 0; } +.chk-group label { display: flex; align-items: center; gap: 5px; color: var(--text); font-size: 13px; cursor: pointer; } +.chk-group input { accent-color: var(--accent); } +.switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0; } +.switch input { opacity: 0; width: 0; height: 0; } +.switch span { position: absolute; inset: 0; background: #262d3a; border-radius: 24px; transition: .2s; cursor: pointer; } +.switch span::before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: .2s; } +.switch input:checked + span { background: var(--accent); } +.switch input:checked + span::before { transform: translateX(20px); } + /* ===== 评分条 ===== */ .score-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 12px; } .score-bar .sb-label { width: 36px; color: var(--text2); } diff --git a/static/js/settings.js b/static/js/settings.js new file mode 100644 index 0000000..1c35d11 --- /dev/null +++ b/static/js/settings.js @@ -0,0 +1,126 @@ +/* 系统设置页 */ +let curSettings = null; + +async function loadSettings() { + try { + curSettings = await api('/api/settings'); + const mail = curSettings.mail, mono = curSettings.monitor, llm = curSettings.llm; + $('#emailEnabled').checked = !!mail.email_enabled; + $('#smtpHost').value = mail.smtp_host; + $('#smtpPort').value = mail.smtp_port; + $('#smtpMode').value = mail.smtp_mode || 'plain'; + $('#smtpUser').value = mail.smtp_user; + $('#smtpPass').value = mail.smtp_pass; + $('#senderName').value = mail.sender_name; + $('#emailTo').value = mail.email_to; + $('#llmBaseUrl').value = llm.base_url; + $('#llmApiKey').value = llm.api_key; + $('#llmModel').value = llm.model; + $('#monitorEnabled').checked = !!mono.enabled; + $('#monitorInterval').value = mono.interval_min; + $('#monitorImportance').value = mono.importance_threshold; + $('#monitorSentiment').value = mono.sentiment_weight; + $('#monitorKeywords').value = (mono.keywords || []).join(','); + // 分类勾选 + const cats = mono.categories || []; + $$('#monitorCats input').forEach(c => c.checked = cats.includes(c.value)); + renderState(curSettings.state); + loadLog(); + } catch (e) { + toast('设置加载失败'); + } +} + +function renderState(state) { + $('#monitorState').innerHTML = ` +
已处理新闻水位
#${state.last_news_id || 0}
+
上次扫描
${state.last_scan || '—'}
+
上次通知
${state.last_sent_count || 0} 条
`; +} + +function collectMail() { + return { + smtp_host: $('#smtpHost').value.trim(), smtp_port: $('#smtpPort').value, + smtp_mode: $('#smtpMode').value, smtp_user: $('#smtpUser').value.trim(), + smtp_pass: $('#smtpPass').value, sender_name: $('#senderName').value.trim(), + email_to: $('#emailTo').value.trim(), email_enabled: $('#emailEnabled').checked + }; +} + +function collectMonitor() { + const cats = $$('#monitorCats input:checked').map(c => c.value); + return { + monitor_enabled: $('#monitorEnabled').checked, + monitor_interval: $('#monitorInterval').value, + monitor_importance: $('#monitorImportance').value, + monitor_sentiment: $('#monitorSentiment').value, + monitor_categories: cats.join(','), + monitor_keywords: $('#monitorKeywords').value + }; +} + +async function save(which) { + try { + const body = {}; + if (which === 'mail') body.mail = collectMail(); + if (which === 'llm') body.llm = { + llm_base_url: $('#llmBaseUrl').value.trim(), + llm_api_key: $('#llmApiKey').value.trim(), + llm_model: $('#llmModel').value.trim() + }; + if (which === 'monitor') body.monitor = collectMonitor(); + const r = await api('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + toast(r.msg || '已保存'); + } catch (e) { toast('保存失败:' + e.message); } +} + +async function testEmail() { + const btn = event.target; btn.disabled = true; btn.textContent = '发送中…'; + try { + await api('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mail: collectMail() }) }); + const r = await api('/api/settings/test-email', { method: 'POST' }); + toast(r.msg || '发送成功'); + } catch (e) { toast('发送失败:' + e.message); } + btn.disabled = false; btn.textContent = '📨 发送测试邮件'; +} + +async function testLlm() { + const btn = event.target; btn.disabled = true; btn.textContent = '测试中…'; + try { + await api('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ llm: { + llm_base_url: $('#llmBaseUrl').value.trim(), llm_api_key: $('#llmApiKey').value.trim(), llm_model: $('#llmModel').value.trim() + } }) }); + const r = await api('/api/settings/test-llm', { method: 'POST' }); + toast(r.msg || '连接正常'); + } catch (e) { toast('连接失败:' + e.message); } + btn.disabled = false; btn.textContent = '🔌 测试连接'; +} + +async function scanNow() { + const btn = event.target; btn.disabled = true; + try { + const r = await api('/api/monitor/scan', { method: 'POST' }); + toast(`扫描完成:检查 ${r.checked} 条,重要 ${r.important} 条,发送 ${r.sent} 条` + (r.error ? '(' + r.error + ')' : '')); + loadSettings(); + } catch (e) { toast('扫描失败:' + e.message); } + btn.disabled = false; +} + +async function loadLog() { + try { + const d = await api('/api/monitor/log'); + const items = d.items || []; + if (!items.length) return; + $('#logTb').innerHTML = items.map(n => ` + + ${n.sent_at} + ${escapeHtml(n.title)} + ${n.category} + ${n.sentiment > 0 ? '利好' : n.sentiment < 0 ? '利空' : '中性'} (${Number(n.sentiment).toFixed(2)}) + ${n.importance} + ${n.status} + `).join(''); + } catch (e) {} +} + +loadSettings(); diff --git a/templates/base.html b/templates/base.html index 6270697..b948055 100644 --- a/templates/base.html +++ b/templates/base.html @@ -33,6 +33,7 @@ 📈 量化策略 📰 财经新闻 🏦 机构动向 + 🔧 系统设置 ⚙️ 数据管理