Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4f316efe7 |
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"""
|
||||
<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
@@ -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)
|
||||
@@ -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); }
|
||||
|
||||
@@ -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();
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user