1 Commits
10 changed files with 726 additions and 8 deletions
+97
View File
@@ -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/<code>")
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(
"[智能荐股] 邮件配置测试",
"<h3>✅ 邮件配置生效</h3><p>如果你收到这封邮件,说明 SMTP 设置正确,舆情通知将正常送达。</p>"
"<p style='color:#888'>发送时间:" + time.strftime("%Y-%m-%d %H:%M:%S") + "</p>",
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)
+21
View File
@@ -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": "回购,中标,减持,问询,停牌,上调,下调,超预期,不及预期,预警,重组,增持,定增,业绩,退市",
}
+20 -1
View File
@@ -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 保留(用户配置不清空)
+10 -7
View File
@@ -23,18 +23,21 @@ _jobs_lock = threading.Lock()
# ------------------------------------------------------------------ LLM
def llm_chat(messages, max_tokens=None, temperature=None, timeout=None):
"""调用 DeepSeekOpenAI 兼容)。返回最终 content(忽略推理过程)"""
"""调用 DeepSeekOpenAI 兼容)。返回最终 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()
+251
View File
@@ -0,0 +1,251 @@
# -*- coding: utf-8 -*-
"""
舆情驱动自动化:定期扫描最新新闻 → 重要度评分 → 邮件通知
- 邮件:SMTPplain/starttls/ssl 三种模式),配置来自设置区
- 重要度评分(0-100):
类别基础分(公司40/业绩35/机构观点30/行业25/市场15
+ 情感绝对值权重(|sentiment| × 权重系数 × 100
+ 重要关键词命中(+12/个,上限 2 个)
+ 关联个股数(+5/只)
- 去重:记录已处理的最大新闻 idmonitor_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"""
<tr style="border-bottom:1px solid #eee;">
<td style="padding:10px;">
<div style="font-size:15px;font-weight:bold;">{n['title']}</div>
<div style="color:#888;font-size:12px;margin:4px 0;">{n['category']} · {n['source']} · {n['publish_date']}</div>
<div style="font-size:12px;">重要度 <b>{it['score']:.0f}</b> 情感 <b style="color:{tone_color};">{tone}{n['sentiment']:+.2f}</b> 关联:{names}</div>
<div style="color:#555;font-size:13px;margin-top:4px;">{n['content'][:120]}{'' if len(n['content']) > 120 else ''}</div>
</td>
</tr>""")
return f"""
<html><body style="font-family:Microsoft YaHei,Arial,sans-serif;background:#f5f6f8;padding:20px;">
<div style="max-width:680px;margin:auto;background:#fff;border-radius:8px;overflow:hidden;border:1px solid #e5e7eb;">
<div style="background:#1e293b;color:#fff;padding:16px 20px;">
<div style="font-size:18px;font-weight:bold;">📰 舆情监控 · 重要资讯通知</div>
<div style="font-size:12px;opacity:.8;margin-top:4px;">智能荐股系统自动扫描 · {time.strftime('%Y-%m-%d %H:%M:%S')}</div>
</div>
<div style="padding:6px 20px 20px;">
<p style="color:#555;font-size:13px;">本次扫描发现 <b>{len(items)}</b> 条重要资讯(重要度达到阈值):</p>
<table style="width:100%;border-collapse:collapse;">{''.join(rows)}</table>
</div>
<div style="background:#f8fafc;padding:10px 20px;color:#94a3b8;font-size:11px;text-align:center;">
本邮件由系统自动生成,内容基于模拟数据,仅供演示,不构成投资建议。可在系统「设置」中关闭通知。
</div>
</div></body></html>"""
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"<p style='color:#888;font-size:12px'>……另有 {omitted} 条重要资讯已省略,详见系统。</p>"
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
+94
View File
@@ -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)
+14
View File
@@ -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); }
+126
View File
@@ -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 = `
<div class="mini-stat"><div class="ms-label">已处理新闻水位</div><div class="ms-value num">#${state.last_news_id || 0}</div></div>
<div class="mini-stat"><div class="ms-label">上次扫描</div><div class="ms-value">${state.last_scan || '—'}</div></div>
<div class="mini-stat"><div class="ms-label">上次通知</div><div class="ms-value num">${state.last_sent_count || 0} 条</div></div>`;
}
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 => `
<tr>
<td style="color:var(--muted)">${n.sent_at}</td>
<td style="max-width:300px;white-space:normal">${escapeHtml(n.title)}</td>
<td><span class="cat-tag ${n.category}">${n.category}</span></td>
<td class="num ${pctClass(n.sentiment)}">${n.sentiment > 0 ? '利好' : n.sentiment < 0 ? '利空' : '中性'} (${Number(n.sentiment).toFixed(2)})</td>
<td class="num"><b>${n.importance}</b></td>
<td><span class="tag ${n.status === 'sent' ? 'tag-推荐' : n.status === 'skipped' ? 'tag-关注' : 'tag-负'}">${n.status}</span></td>
</tr>`).join('');
} catch (e) {}
}
loadSettings();
+1
View File
@@ -33,6 +33,7 @@
<a href="/strategies" class="nav-item" data-nav="/strategies">📈 量化策略</a>
<a href="/news" class="nav-item" data-nav="/news">📰 财经新闻</a>
<a href="/institutions" class="nav-item" data-nav="/institutions">🏦 机构动向</a>
<a href="/settings" class="nav-item" data-nav="/settings">🔧 系统设置</a>
<a href="/admin" class="nav-item" data-nav="/admin">⚙️ 数据管理</a>
</nav>
<div class="sidebar-foot">
+92
View File
@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}系统设置{% endblock %}
{% block page_title %}系统设置{% endblock %}
{% block content %}
<div class="grid grid-2">
<!-- 邮件通知 -->
<div class="card">
<div class="between">
<div class="card-title" style="margin-bottom:0"><span class="bar"></span>📧 邮件通知</div>
<label class="switch"><input type="checkbox" id="emailEnabled"><span></span></label>
</div>
<div class="mt8 settings-form">
<div class="sf-row"><label>SMTP 服务器</label><input class="input" id="smtpHost" style="width:100%"></div>
<div class="sf-row"><label>端口</label><input class="input" id="smtpPort" type="number" style="width:100%"></div>
<div class="sf-row"><label>加密模式</label>
<select class="input" id="smtpMode" style="width:100%">
<option value="plain">无加密 (plain)</option>
<option value="starttls">STARTTLS</option>
<option value="ssl">SSL/TLS</option>
</select>
</div>
<div class="sf-row"><label>发件账号</label><input class="input" id="smtpUser" style="width:100%"></div>
<div class="sf-row"><label>发件密码</label><input class="input" id="smtpPass" type="password" style="width:100%"></div>
<div class="sf-row"><label>发件人名称</label><input class="input" id="senderName" style="width:100%"></div>
<div class="sf-row"><label>通知收件人</label><input class="input" id="emailTo" style="width:100%"></div>
</div>
<div class="flex mt16 wrap">
<button class="btn btn-primary" onclick="save('mail')">💾 保存邮件设置</button>
<button class="btn" onclick="testEmail()">📨 发送测试邮件</button>
</div>
</div>
<!-- 大模型 -->
<div class="card">
<div class="card-title"><span class="bar" style="background:var(--gold)"></span>🤖 大模型接口</div>
<div class="mt8 settings-form">
<div class="sf-row"><label>Base URL</label><input class="input" id="llmBaseUrl" style="width:100%"></div>
<div class="sf-row"><label>API Key</label><input class="input" id="llmApiKey" type="password" style="width:100%"></div>
<div class="sf-row"><label>模型</label><input class="input" id="llmModel" style="width:100%"></div>
</div>
<div class="flex mt16 wrap">
<button class="btn btn-primary" onclick="save('llm')">💾 保存模型设置</button>
<button class="btn" onclick="testLlm()">🔌 测试连接</button>
</div>
<div class="mt16" style="border-top:1px solid var(--border);padding-top:12px;color:var(--muted);font-size:12px;line-height:1.8">
用于 AI 深度研报生成。支持任意 OpenAI 兼容接口(DeepSeek / 火山方舟等)。修改后立即生效。
</div>
</div>
</div>
<!-- 舆情监控 -->
<div class="card mt16">
<div class="between">
<div class="card-title" style="margin-bottom:0"><span class="bar" style="background:var(--cyan)"></span>📰 舆情驱动自动化</div>
<label class="switch"><input type="checkbox" id="monitorEnabled"><span></span></label>
</div>
<div class="mt8 settings-form">
<div class="sf-row"><label>扫描间隔(分钟)</label><input class="input" id="monitorInterval" type="number" min="5" style="width:120px"></div>
<div class="sf-row"><label>重要度阈值 (0-100)</label><input class="input" id="monitorImportance" type="number" min="0" max="100" style="width:120px"></div>
<div class="sf-row"><label>情感权重</label><input class="input" id="monitorSentiment" type="number" step="0.05" style="width:120px"></div>
<div class="sf-row" style="align-items:flex-start"><label>监控分类</label>
<div class="chk-group" id="monitorCats">
<label><input type="checkbox" value="公司"> 公司</label>
<label><input type="checkbox" value="业绩"> 业绩</label>
<label><input type="checkbox" value="机构观点"> 机构观点</label>
<label><input type="checkbox" value="行业"> 行业</label>
<label><input type="checkbox" value="市场"> 市场</label>
</div>
</div>
<div class="sf-row" style="align-items:flex-start"><label>关键词(逗号分隔)</label><textarea class="input" id="monitorKeywords" rows="2" style="width:100%"></textarea></div>
</div>
<div class="flex mt16 wrap">
<button class="btn btn-primary" onclick="save('monitor')">💾 保存监控设置</button>
<button class="btn" onclick="scanNow()">⚡ 立即扫描</button>
</div>
<div class="mt16 mini-stats" id="monitorState"></div>
</div>
<!-- 通知日志 -->
<div class="card mt16">
<div class="card-title"><span class="bar" style="background:var(--cyan)"></span>通知日志</div>
<div style="overflow:auto;max-height:380px">
<table>
<thead><tr><th>时间</th><th>标题</th><th>分类</th><th>情感</th><th>重要度</th><th>状态</th></tr></thead>
<tbody id="logTb"><tr><td colspan="6" class="empty">暂无记录</td></tr></tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/settings.js') }}"></script>
{% endblock %}