3 Commits
10 changed files with 604 additions and 83 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
一款面向 AI 领域的**自动采集 → 智能分析 → 邮件推送**的新闻跟踪系统。后台定时从数据源采集资讯,先规则打分、再 LLM 深度分析,把与你兴趣画像相关且重要的资讯**实时发邮件提醒**,并每天 10:00 发一份**AI 资讯日报**。带完整网页管理台,兴趣画像/数据源/通知参数全部可配。
> 当前版本 **v1.3.0**:数据源**独立采集周期**(每源可覆盖全局)+ **定制监控与新闻监控完全分离**(独立间隔/独立汇总)+ **历史采样留档与提取**
> 当前版本 **v1.4.3**:大模型接口 token/次数统计 + 调用优先级自动切换 + 通知日志查看正文/增强分页/批量删除
---
@@ -236,4 +236,4 @@ cd works/news-tracker
## Git
- 仓库:`hz4th_coder/news-tracker`
- 版本:`v1.0.0`(初版)→ `v1.0.1`(补 README)→ `v1.1.0`(真实网页采集+全文入库 / 大模型接口多预置一键切换 / 数据源可编辑)→ `v1.2.0`(数据源完整编辑模态框 / 定制监控类型:无权重+推送标准+LLM判断推送+测试按钮)→ **`v1.3.0`**(每源独立采集周期 / 定制监控与新闻监控分离独立配置+独立汇总 / 历史采样留档+查看+提取API)→ **`v1.4.0`**(通知日志分页+筛选 / 仪表盘 TOP10+更多链接 / 数据源走 web-capture-api 抓取+获取方式与参数可编辑 / 系统错误邮件通知:频率+静默时段)
- 版本:`v1.0.0`(初版)→ `v1.0.1`(补 README)→ `v1.1.0`(真实网页采集+全文入库 / 大模型接口多预置一键切换 / 数据源可编辑)→ `v1.2.0`(数据源完整编辑模态框 / 定制监控类型:无权重+推送标准+LLM判断推送+测试按钮)→ **`v1.3.0`**(每源独立采集周期 / 定制监控与新闻监控分离独立配置+独立汇总 / 历史采样留档+查看+提取API)→ **`v1.4.0`**(通知日志分页+筛选 / 仪表盘 TOP10+更多链接 / 数据源走 web-capture-api 抓取+获取方式与参数可编辑 / 系统错误邮件通知:频率+静默时段)**`v1.4.1`**(修复邮件退信:发信补 `Date` 头,mail.tphai.com amavisd 强制要求否则 554 退信 / 数据源状态列新增一键启动/停用开关,点击即生效无需刷新)→ **`v1.4.2`****修复日报/定制汇总刷屏**`_maybe_daily` 日期判断元组嵌套 bug 导致每天只发一次的逻辑失效、到点后每 30s 重复发;改为纯字符串 + 发送日期持久化到 settings,重启也不重复 / **一键全部启用/停用**:数据源页顶部两个按钮 + `/api/sources` 新 action enable_all/disable_all / **快速筛选**:关键词搜索 + 监控方式 + 启用状态 三条件过滤,重置一键还原)→ **`v1.4.3`**(**大模型调用统计**:新增 llm_stats 表按天/接口记录调用次数、Prompt/Completion/总 Token、失败次数,设置页新增「LLM 调用统计」区块(各接口累计 + 最近14天) / **大模型调用优先级**llm_providers 新增 priority 字段,设置页 ↑↓ 调整优先级,调用按优先级顺序(高优先失败→自动切换下一个,全部失败才算异常并邮件通知),每次调用自动记录统计 / **通知日志增强**:发信时存正文(body),网页点击主题/👁查看弹窗渲染邮件正文(旧记录回退到关联资讯链接);分页增强(每页条数选择/首页/末页/跳转);支持勾选批量删除与「删除当前筛选结果全部」)
+27 -12
View File
@@ -208,22 +208,30 @@ def get_llm_cfg():
"api_key": config.LLM_API_KEY, "model": config.LLM_MODEL}
def _llm_chat(prompt):
"""调用大模型,返回 (content, provider_name)。激活接口失败自动切换下一可用接口。"""
active = get_llm_cfg()
chain = [active]
cur = db.get_active_provider()
if cur:
for p in db.enabled_providers_except(cur["id"]):
chain.append({"name": p["name"], "base_url": (p["base_url"] or "").rstrip("/"),
"api_key": p.get("api_key", ""), "model": p.get("model", "")})
else:
chain.append({"name": "DeepSeek 官方", "base_url": config.LLM_BASE_URL,
def _provider_chain():
"""按优先级排序的大模型接口调用链(priority 小优先,其次 id)。
全部由 llm_providers 表启用项决定;无任何启用接口时回退 config 默认。"""
chain = []
for p in db.list_providers_ordered():
chain.append({"id": p["id"], "name": p["name"],
"base_url": (p["base_url"] or "").rstrip("/"),
"api_key": p.get("api_key", ""), "model": p.get("model", "")})
if not chain:
chain.append({"id": 0, "name": "DeepSeek 官方", "base_url": config.LLM_BASE_URL,
"api_key": config.LLM_API_KEY, "model": config.LLM_MODEL})
return chain
def _llm_chat(prompt):
"""按优先级顺序调用大模型:高优先级失败 → 自动切换下一个;全部失败才抛异常。
每次调用记录统计(次数 / token / 失败)。返回 (content, provider_name)。"""
chain = _provider_chain()
last_err = ""
for cfg in chain:
if not cfg.get("base_url"):
continue
pname = cfg.get("name", cfg["base_url"])
pid = cfg.get("id", 0)
try:
resp = requests.post(
f"{cfg['base_url']}/chat/completions",
@@ -237,9 +245,16 @@ def _llm_chat(prompt):
timeout=config.LLM_TIMEOUT,
)
data = resp.json()
if not resp.ok or "choices" not in data:
raise RuntimeError(data.get("error", {}).get("message", f"HTTP {resp.status_code}"))
content = data["choices"][0]["message"]["content"]
return content, cfg.get("name", cfg["base_url"])
usage = data.get("usage") or {}
pt = usage.get("prompt_tokens", 0) or 0
ct = usage.get("completion_tokens", 0) or 0
db.add_llm_stat(pname, pid, pt, ct, success=True)
return content, pname
except Exception as e:
db.add_llm_stat(pname, pid, 0, 0, success=False)
last_err = str(e)
continue
raise RuntimeError(f"所有大模型接口调用失败: {last_err}")
+54 -1
View File
@@ -114,6 +114,35 @@ def api_logs():
"total": total, "pages": max(1, (total + page_size - 1) // page_size)})
@app.route("/api/logs/delete", methods=["POST"])
def api_logs_delete():
"""批量删除通知日志。POST {ids: [1,2,3]} 或 {all: true}(删除筛选条件下的全部)"""
data = request.get_json(force=True) or {}
ids = [int(x) for x in (data.get("ids") or []) if str(x).isdigit()]
if data.get("all"):
# 删除当前筛选条件下的全部
f_type = data.get("type", "") or None
f_status = data.get("status", "") or None
q = data.get("q", "") or None
ids = db.list_log_ids(type_=f_type, status=f_status, q=q)
n = db.delete_logs(ids)
return jsonify({"ok": True, "deleted": n})
@app.route("/api/logs/<int:lid>")
def api_log_one(lid):
"""取单条通知日志(含 body 正文)"""
row = db.get_log(lid)
if not row:
return jsonify({"ok": False, "error": "not found"})
d = dict(row)
try:
d["article_ids"] = json.loads(d["article_ids"] or "[]")
except Exception:
d["article_ids"] = []
return jsonify({"ok": True, "log": d})
@app.route("/api/errors")
def api_errors():
"""系统错误日志(最近 N 条)"""
@@ -121,6 +150,19 @@ def api_errors():
return jsonify({"ok": True, "errors": db.list_system_errors(limit=limit)})
@app.route("/api/articles/batch")
def api_articles_batch():
"""按 ID 批量取资讯(id+title+url),用于旧日志无 body 时展示链接。"""
ids = [int(x) for x in request.args.get("ids", "").split(",") if x.strip().isdigit()]
if not ids:
return jsonify({"ok": True, "articles": []})
conn = db.get_conn()
marks = ",".join("?" * len(ids))
rows = conn.execute(f"SELECT id, title, url FROM articles WHERE id IN ({marks})", ids).fetchall()
conn.close()
return jsonify({"ok": True, "articles": [dict(r) for r in rows]})
@app.route("/settings")
def settings_page():
auto = db.get_all_settings()
@@ -141,7 +183,8 @@ def settings_page():
merged = dict(dft)
merged.update(v)
auto[k] = merged
return render_template("settings.html", auto=auto, providers=db.list_providers())
return render_template("settings.html", auto=auto, providers=db.list_providers(),
llm_stats=db.llm_stats_summary(), llm_daily=db.llm_stats_daily(14))
# ---------------- API ----------------
@@ -189,6 +232,12 @@ def api_sources():
s = db.get_source(data["id"])
db.update_source(data["id"], enabled=0 if s["enabled"] else 1)
return jsonify({"ok": True})
if action == "enable_all":
n = db.set_all_sources_enabled(1)
return jsonify({"ok": True, "count": n})
if action == "disable_all":
n = db.set_all_sources_enabled(0)
return jsonify({"ok": True, "count": n})
if action == "test_standard":
# 定制监控:用大模型测试「推送标准」是否好使(给一段示例内容看判不判得出)
standard = (data.get("monitor_standard") or "").strip()
@@ -348,6 +397,10 @@ def api_llm():
db.set_active_provider(data["id"])
p = db.get_provider(data["id"])
return jsonify({"ok": True, "name": p["name"] if p else ""})
if action == "move":
# 优先级上移/下移:direction=-1 上移(优先),+1 下移
ok = db.reorder_provider(data["id"], int(data.get("direction", 0)))
return jsonify({"ok": ok, "error": "无法移动(已在边界或不存在)"} if not ok else {"ok": True})
if action == "toggle":
p = db.get_provider(data["id"])
if not p:
+174 -7
View File
@@ -188,6 +188,27 @@ def init_db():
if "full_text" not in cols:
conn.execute("ALTER TABLE articles ADD COLUMN full_text TEXT DEFAULT ''")
conn.commit()
# 迁移:大模型接口增加优先级字段(小优先)+ 通知日志增加 body 列 + LLM 统计表
cols = [r["name"] for r in conn.execute("PRAGMA table_info(llm_providers)").fetchall()]
if "priority" not in cols:
conn.execute("ALTER TABLE llm_providers ADD COLUMN priority INTEGER DEFAULT 0")
cols = [r["name"] for r in conn.execute("PRAGMA table_info(notification_log)").fetchall()]
if "body" not in cols:
conn.execute("ALTER TABLE notification_log ADD COLUMN body TEXT DEFAULT ''")
conn.execute("""CREATE TABLE IF NOT EXISTS llm_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT DEFAULT '',
provider_id INTEGER DEFAULT 0,
day TEXT DEFAULT '',
calls INTEGER DEFAULT 0,
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
failures INTEGER DEFAULT 0
)""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_llmstats_prov ON llm_stats(provider, day)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_llmstats_day ON llm_stats(day)")
conn.commit()
conn.close()
@@ -290,6 +311,16 @@ def delete_source(sid):
conn.close()
def set_all_sources_enabled(val):
"""一键全部启用/停用。返回受影响的数据源数。"""
conn = get_conn()
cur = conn.execute("UPDATE sources SET enabled=?", (1 if val else 0,))
conn.commit()
n = cur.rowcount
conn.close()
return n
def update_source_fetch(sid, status="ok", count=0):
conn = get_conn()
conn.execute(
@@ -659,16 +690,152 @@ def enabled_providers_except(pid):
return [dict(r) for r in rows]
# ---------------- 通知日志(分页 + 筛选) ----------------
def add_log(type_, subject, count, article_ids, status="ok", detail=""):
def list_providers_ordered():
"""按优先级排序的已启用大模型接口:priority 小优先,其次 id(用于调用顺序)。"""
conn = get_conn()
conn.execute(
"INSERT INTO notification_log(type,subject,count,article_ids,status,detail,sent_at) "
"VALUES(?,?,?,?,?,?,?)",
(type_, subject, count, json.dumps(article_ids, ensure_ascii=False), status, detail, now_str()),
)
rows = conn.execute(
"SELECT * FROM llm_providers WHERE enabled=1 ORDER BY priority, id").fetchall()
conn.close()
return [dict(r) for r in rows]
def reorder_provider(pid, direction):
"""把某接口在优先级列表中上移(-1)/下移(+1)。
优先级列表包含全部接口(含停用的),但调用链只取启用的。返回是否成功。"""
conn = get_conn()
rows = conn.execute(
"SELECT id, priority FROM llm_providers ORDER BY priority, id").fetchall()
idx = next((i for i, r in enumerate(rows) if r["id"] == pid), None)
if idx is None:
conn.close()
return False
j = idx + direction
if j < 0 or j >= len(rows):
conn.close()
return False
a, b = rows[idx], rows[j]
if a["priority"] == b["priority"]:
# 优先级相等时先重排为 0..n-1,再交换目标两个位置
for k, r in enumerate(rows):
conn.execute("UPDATE llm_providers SET priority=? WHERE id=?", (k, r["id"]))
conn.execute("UPDATE llm_providers SET priority=? WHERE id=?", (j, a["id"]))
conn.execute("UPDATE llm_providers SET priority=? WHERE id=?", (idx, b["id"]))
else:
conn.execute("UPDATE llm_providers SET priority=? WHERE id=?", (b["priority"], a["id"]))
conn.execute("UPDATE llm_providers SET priority=? WHERE id=?", (a["priority"], b["id"]))
conn.commit()
conn.close()
return True
# ---------------- LLM 调用统计(次数 + Token ----------------
def add_llm_stat(provider, provider_id, prompt_tokens, completion_tokens, success=True):
"""记录一次大模型调用(按天/接口聚合)。success=False 时只累加 calls 与 failures。"""
day = datetime.now().strftime("%Y-%m-%d")
conn = get_conn()
row = conn.execute(
"SELECT id FROM llm_stats WHERE provider=? AND day=?", (provider, day)).fetchone()
if row:
if success:
conn.execute(
"UPDATE llm_stats SET calls=calls+1, prompt_tokens=prompt_tokens+?, "
"completion_tokens=completion_tokens+?, total_tokens=total_tokens+? WHERE id=?",
(prompt_tokens, completion_tokens, prompt_tokens + completion_tokens, row["id"]))
else:
conn.execute("UPDATE llm_stats SET calls=calls+1, failures=failures+1 WHERE id=?",
(row["id"],))
else:
if success:
conn.execute(
"INSERT INTO llm_stats(provider,provider_id,day,calls,prompt_tokens,"
"completion_tokens,total_tokens,failures) VALUES(?,?,?,1,?,?,?,0)",
(provider, provider_id, day, prompt_tokens, completion_tokens,
prompt_tokens + completion_tokens))
else:
conn.execute(
"INSERT INTO llm_stats(provider,provider_id,day,calls,prompt_tokens,"
"completion_tokens,total_tokens,failures) VALUES(?,?,?,1,0,0,0,1)",
(provider, provider_id, day))
conn.commit()
conn.close()
def llm_stats_summary(day=None):
"""各接口累计统计。day='YYYY-MM-DD' 只看当天;None 看全部。"""
if day:
where, args = " WHERE day=?", [day]
else:
where, args = "", []
conn = get_conn()
rows = conn.execute(
f"SELECT provider, provider_id, SUM(calls) calls, SUM(prompt_tokens) prompt_tokens, "
f"SUM(completion_tokens) completion_tokens, SUM(total_tokens) total_tokens, "
f"SUM(failures) failures FROM llm_stats{where} "
"GROUP BY provider, provider_id ORDER BY calls DESC", args).fetchall()
conn.close()
return [dict(r) for r in rows]
def llm_stats_daily(limit=14):
"""最近 N 天每日汇总(全部接口合计)。"""
conn = get_conn()
rows = conn.execute(
"SELECT day, SUM(calls) calls, SUM(prompt_tokens) prompt_tokens, "
"SUM(completion_tokens) completion_tokens, SUM(total_tokens) total_tokens, "
"SUM(failures) failures FROM llm_stats GROUP BY day ORDER BY day DESC LIMIT ?",
(limit,)).fetchall()
conn.close()
return [dict(r) for r in rows]
# ---------------- 通知日志(分页 + 筛选) ----------------
def add_log(type_, subject, count, article_ids, status="ok", detail="", body=""):
conn = get_conn()
conn.execute(
"INSERT INTO notification_log(type,subject,count,article_ids,status,detail,sent_at,body) "
"VALUES(?,?,?,?,?,?,?,?)",
(type_, subject, count, json.dumps(article_ids, ensure_ascii=False), status, detail,
now_str(), body))
conn.commit()
conn.close()
def delete_logs(ids):
"""批量删除通知日志,返回删除条数。"""
if not ids:
return 0
marks = ",".join("?" * len(ids))
conn = get_conn()
cur = conn.execute(f"DELETE FROM notification_log WHERE id IN ({marks})", ids)
conn.commit()
n = cur.rowcount
conn.close()
return n
def get_log(lid):
conn = get_conn()
row = conn.execute("SELECT * FROM notification_log WHERE id=?", (lid,)).fetchone()
conn.close()
return dict(row) if row else None
def list_log_ids(type_=None, status=None, q=None):
"""返回匹配筛选条件的全部日志 ID(用于一键删除筛选结果)。"""
where, args = "1=1", []
if type_:
where += " AND type=?"
args.append(type_)
if status:
where += " AND status=?"
args.append(status)
if q:
where += " AND (subject LIKE ? OR detail LIKE ?)"
args += [f"%{q}%", f"%{q}%"]
conn = get_conn()
rows = conn.execute(f"SELECT id FROM notification_log WHERE {where}", args).fetchall()
conn.close()
return [r["id"] for r in rows]
def list_logs(page=1, page_size=20, type_=None, status=None, q=None):
+7 -5
View File
@@ -8,7 +8,7 @@ from datetime import datetime
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr
from email.utils import formataddr, formatdate
import config
import db
@@ -27,6 +27,8 @@ def send_email(subject, html, to=None):
msg["From"] = formataddr((str(Header(cfg.get("sender_name", "新闻智能跟踪"), "utf-8")), cfg["smtp_user"]))
msg["To"] = to
msg["Subject"] = Header(subject, "utf-8")
# mail.tphai.com 的 amavisd 强制要求 Date 头,缺失会退信(554 5.6.0 BAD HEADER)
msg["Date"] = formatdate(localtime=True)
msg.attach(MIMEText(html, "html", "utf-8"))
mode = cfg.get("smtp_mode", "plain")
@@ -101,7 +103,7 @@ def _send_error_digest(errors):
f"<p style='color:#374151;font-size:13px;'>新闻智能跟踪系统检测到以下异常,请及时处理:</p>{rows}",
)
send_email(subject, html)
db.add_log("error", subject, len(errors), [], status="ok", detail="系统错误邮件通知")
db.add_log("error", subject, len(errors), [], status="ok", detail="系统错误邮件通知", body=html)
def _try_send_pending():
@@ -226,7 +228,7 @@ def send_realtime(articles):
ids = [a["id"] for a in articles]
for aid in ids:
db.update_article(aid, notified=1)
db.add_log("realtime", subject, len(articles), ids, status="ok", detail="实时通知")
db.add_log("realtime", subject, len(articles), ids, status="ok", detail="实时通知", body=html)
return len(articles)
@@ -252,7 +254,7 @@ def send_daily_summary(articles, window_label):
)
send_email(subject, html)
ids = [a["id"] for a in articles if a["id"]]
db.add_log("summary", subject, len(articles), ids, status="ok", detail=f"汇总{len(top)}")
db.add_log("summary", subject, len(articles), ids, status="ok", detail=f"汇总{len(top)}", body=html)
for aid in ids:
art = db.get_article(aid)
if art and art.get("status") != "summarized":
@@ -278,5 +280,5 @@ def send_custom_summary(articles, window_label):
)
send_email(subject, html)
ids = [a["id"] for a in articles if a["id"]]
db.add_log("custom_summary", subject, len(articles), ids, status="ok", detail=f"汇总{len(top)}")
db.add_log("custom_summary", subject, len(articles), ids, status="ok", detail=f"汇总{len(top)}", body=html)
return len(top)
+15 -9
View File
@@ -147,11 +147,16 @@ def send_custom_summary():
return notifier.send_custom_summary(articles, label)
def _maybe_daily(now, last_day, time_key, send_fn, kind):
"""到点触发的通用逻辑。time_key 存 'HH:MM'send_fn 返回发送条数。"""
def _maybe_daily(now, last_day, time_key, send_fn, kind, persist_key):
"""到点触发的通用逻辑。time_key 存 'HH:MM'send_fn 返回发送条数。
last_day 存上次发送的日期字符串 'YYYY-MM-DD'None=尚未发送)。
修复:每天只发一次——last_day 必须是纯字符串,不能嵌套元组;
并把已发送日期持久化到 settings,服务重启后也不会当天重复发。"""
day_key = now.strftime("%Y-%m-%d")
if last_day[0] == day_key:
return last_day
if last_day is None:
last_day = db.get_setting(persist_key, "")
if last_day == day_key:
return last_day # 今天已发过,不再发
hm = str(db.get_setting(time_key, config.AUTO_DEFAULTS.get(time_key) or
config.CUSTOM_DEFAULTS.get(time_key) or "10:00"))
try:
@@ -161,7 +166,8 @@ def _maybe_daily(now, last_day, time_key, send_fn, kind):
if now.strftime("%H:%M") >= hm and now.hour >= hh:
try:
send_fn()
last_day = (day_key,)
db.set_setting(persist_key, day_key)
return day_key
except Exception as e:
db.add_log(kind, f"{time_key} 汇总异常", 0, [], status="error", detail=str(e))
notifier.report_error("汇总", f"{time_key} 汇总发送异常", str(e)[:300])
@@ -174,10 +180,10 @@ def scheduler_loop(stop_event):
while not stop_event.is_set():
now = datetime.now()
try:
last_summary_day = _maybe_daily(now, (last_summary_day,), "summary_time",
send_daily_summary, "summary")
last_custom_summary_day = _maybe_daily(now, (last_custom_summary_day,), "custom_summary_time",
send_custom_summary, "custom_summary")
last_summary_day = _maybe_daily(now, last_summary_day, "summary_time",
send_daily_summary, "summary", "summary_last_day")
last_custom_summary_day = _maybe_daily(now, last_custom_summary_day, "custom_summary_time",
send_custom_summary, "custom_summary", "custom_summary_last_day")
except Exception as e:
notifier.report_error("汇总", "定时汇总检查异常", str(e)[:300])
# 采集到期数据源(每源独立周期)
+8
View File
@@ -68,6 +68,14 @@ a { color:#2563eb; text-decoration:none; }
.tag.ok { background:#ecfdf5; color:#059669; }
.tag.err { background:#fef2f2; color:#dc2626; }
/* 一键启停开关 */
.switch { position:relative; display:inline-block; width:38px; height:20px; vertical-align:middle; cursor:pointer; }
.switch input { opacity:0; width:0; height:0; }
.switch .slider { position:absolute; inset:0; background:#d1d5db; border-radius:20px; transition:.2s; }
.switch .slider:before { content:""; position:absolute; height:14px; width:14px; left:3px; top:3px; background:#fff; border-radius:50%; transition:.2s; }
.switch input:checked + .slider { background:#059669; }
.switch input:checked + .slider:before { transform:translateX(18px); }
.filters { display:flex; gap:10px; margin-bottom:16px; flex-wrap:wrap; align-items:center; }
.filters select, .filters input { padding:8px 10px; border:1px solid #d1d5db; border-radius:8px; background:#fff; font-size:13px; }
.filters input[type=text]{ width:260px; }
+184 -40
View File
@@ -3,67 +3,209 @@
{% block title %}通知日志 - 新闻智能跟踪{% endblock %}
{% block content %}
<div class="page-head">
<h1>✉️ 通知日志 <span class="muted">共 {{ total }} 条</span></h1>
<h1>✉️ 通知日志 <span class="muted" id="total_label"></span></h1>
<div class="actions">
<button class="btn" onclick="testMail()">📮 发送测试邮件</button>
<button class="btn" onclick="testErrorMail()">⚠️ 测试错误通知</button>
</div>
</div>
<form class="filters" method="get" action="/logs">
<select name="type">
<div class="filters">
<select id="f_type" onchange="loadPage(1)">
<option value="">全部类型</option>
<option value="realtime" {{ 'selected' if f_type=='realtime' }}>🔥 实时重要资讯</option>
<option value="summary" {{ 'selected' if f_type=='summary' }}>📰 新闻汇总</option>
<option value="custom_summary" {{ 'selected' if f_type=='custom_summary' }}>🎯 定制监控汇总</option>
<option value="error" {{ 'selected' if f_type=='error' }}>⚠️ 系统错误</option>
<option value="realtime">🔥 实时重要资讯</option>
<option value="summary">📰 新闻汇总</option>
<option value="custom_summary">🎯 定制监控汇总</option>
<option value="error">⚠️ 系统错误</option>
</select>
<select name="status">
<select id="f_status" onchange="loadPage(1)">
<option value="">全部状态</option>
<option value="ok" {{ 'selected' if f_status=='ok' }}>✅ 成功</option>
<option value="error" {{ 'selected' if f_status=='error' }}>❌ 失败</option>
<option value="ok">✅ 成功</option>
<option value="error">❌ 失败</option>
</select>
<input type="text" name="q" value="{{ q }}" placeholder="搜索主题 / 详情...">
<button class="btn" type="submit">筛选</button>
<a class="btn" href="/logs">重置</a>
</form>
<input type="text" id="f_q" placeholder="搜索主题 / 详情..." onkeydown="if(event.key==='Enter')loadPage(1)">
<button class="btn" onclick="loadPage(1)">筛选</button>
<button class="btn" onclick="resetFilter()">重置</button>
<span class="muted small" style="margin-left:auto;">勾选行后可批量删除</span>
</div>
<div class="card">
<div class="form-row" style="justify-content:flex-start;margin-bottom:8px;">
<label style="display:flex;align-items:center;gap:4px;font-size:13px;">
<input type="checkbox" id="check_all" onchange="toggleAll(this)"> 全选本页
</label>
<button class="btn mini danger" onclick="delSelected()">🗑 删除选中 (<span id="sel_count">0</span>)</button>
<button class="btn mini" onclick="delFiltered()">🗑 删除当前筛选结果全部</button>
</div>
<table>
<thead><tr><th>ID</th><th>类型</th><th>主题</th><th>条数</th><th>状态</th><th>发送时间</th></tr></thead>
<tbody>
{% for l in logs %}
<tr>
<td>{{ l.id }}</td>
<td>
{% if l.type=='realtime' %}<span class="tag hot">🔥 实时</span>
{% elif l.type=='summary' %}<span class="tag ok">📰 新闻汇总</span>
{% elif l.type=='custom_summary' %}<span class="tag">🎯 定制汇总</span>
{% elif l.type=='error' %}<span class="tag err">⚠️ 系统错误</span>
{% else %}<span class="tag">{{ l.type }}</span>{% endif %}
</td>
<td>{{ l.subject }}
{% if l.detail %}<div class="muted small">{{ l.detail }}</div>{% endif %}
</td>
<td>{{ l.count }}</td>
<td>{% if l.status=='ok' %}<span class="tag ok">成功</span>{% else %}<span class="tag err">失败</span>{% endif %}</td>
<td class="small">{{ l.sent_at }}</td>
</tr>
{% else %}
<tr><td colspan="6" class="empty">暂无匹配的通知记录</td></tr>
{% endfor %}
<thead><tr><th style="width:34px;"></th><th>ID</th><th>类型</th><th>主题(点击查看内容)</th><th>条数</th><th>状态</th><th>发送时间</th><th>操作</th></tr></thead>
<tbody id="log_tbody">
<tr><td colspan="8" class="empty">⏳ 加载中...</td></tr>
</tbody>
</table>
</div>
<div class="pager">
{% if page > 1 %}<a class="btn" href="/logs?page={{ page-1 }}&type={{ f_type }}&status={{ f_status }}&q={{ q }}">上一页</a>{% endif %}
<span class="muted">第 {{ page }} / {{ pages }} 页</span>
{% if page < pages %}<a class="btn" href="/logs?page={{ page+1 }}&type={{ f_type }}&status={{ f_status }}&q={{ q }}">下一页</a>{% endif %}
<div class="pager" id="pager"></div>
<!-- 查看通知内容 模态框 -->
<div class="modal-mask" id="viewModal" style="display:none;">
<div class="modal" style="width:720px;">
<div class="modal-head">
<h3 id="viewTitle">通知内容</h3>
<span class="modal-x" onclick="document.getElementById('viewModal').style.display='none'"></span>
</div>
<div class="modal-body" id="viewBody" style="padding:12px 16px;"></div>
<div class="modal-foot">
<button class="btn" onclick="document.getElementById('viewModal').style.display='none'">关闭</button>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
let CUR = {page:1, page_size:20, total:0, pages:1};
const TYPE_TAG = {
realtime: '<span class="tag hot">🔥 实时</span>',
summary: '<span class="tag ok">📰 新闻汇总</span>',
custom_summary: '<span class="tag">🎯 定制汇总</span>',
error: '<span class="tag err">⚠️ 系统错误</span>'
};
const esc = s => (s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
async function loadPage(page){
const qs = {
page: Math.max(1, page),
page_size: document.getElementById('f_page_size') ? document.getElementById('f_page_size').value : CUR.page_size,
type: document.getElementById('f_type').value,
status: document.getElementById('f_status').value,
q: document.getElementById('f_q').value
};
const r = await API.get('/api/logs', qs);
if (!r.ok){ document.getElementById('log_tbody').innerHTML = '<tr><td colspan="8" class="empty">加载失败</td></tr>'; return; }
CUR = r;
renderRows(r.logs);
renderPager(r);
}
function renderRows(logs){
const tb = document.getElementById('log_tbody');
if (!logs.length){
tb.innerHTML = '<tr><td colspan="8" class="empty">暂无匹配的通知记录</td></tr>';
document.getElementById('total_label').textContent = '';
return;
}
document.getElementById('total_label').textContent = '共 ' + CUR.total + ' 条';
tb.innerHTML = logs.map(l => `
<tr data-lid="${l.id}">
<td><input type="checkbox" class="row_check" onchange="updSel()"></td>
<td>${l.id}</td>
<td>${TYPE_TAG[l.type] || '<span class="tag">'+esc(l.type)+'</span>'}</td>
<td><a href="javascript:void(0)" onclick="viewLog(${l.id})" style="color:#2563eb;">${esc(l.subject)}</a>
${l.detail ? '<div class="muted small">' + esc(l.detail) + '</div>' : ''}
</td>
<td>${l.count}</td>
<td>${l.status==='ok' ? '<span class="tag ok">成功</span>' : '<span class="tag err">失败</span>'}</td>
<td class="small">${esc(l.sent_at)}</td>
<td style="white-space:nowrap;">
<button class="btn mini" onclick="viewLog(${l.id})">👁 查看</button>
<button class="btn mini danger" onclick="delOne(${l.id})">删除</button>
</td>
</tr>`).join('');
updSel();
}
function renderPager(r){
const el = document.getElementById('pager');
const p = r.page, pages = r.pages;
if (pages <= 1 && r.total <= 0){ el.innerHTML = ''; return; }
const q = '?page=' + p; // 当前页参数(保留给表单用,实际分页走 JS)
let html = '<select id="f_page_size" onchange="loadPage(1)" style="padding:6px 8px;border:1px solid #d1d5db;border-radius:8px;font-size:13px;">' +
[10,20,50,100].map(n => `<option value="${n}" ${n==CUR.page_size?'selected':''}>每页 ${n} 条</option>`).join('') + '</select>';
html += `<button class="btn mini" ${p<=1?'disabled':''} onclick="loadPage(1)">⏮ 首页</button>`;
html += `<button class="btn mini" ${p<=1?'disabled':''} onclick="loadPage(${p-1})">上一页</button>`;
html += `<span class="muted">第 ${p} / ${pages} 页</span>`;
html += `<button class="btn mini" ${p>=pages?'disabled':''} onclick="loadPage(${p+1})">下一页</button>`;
html += `<button class="btn mini" ${p>=pages?'disabled':''} onclick="loadPage(${pages})">末页 ⏭</button>`;
html += `<span class="muted">跳转</span><input id="jump_page" type="number" min="1" max="${pages}" value="${p}" style="width:70px;padding:6px;border:1px solid #d1d5db;border-radius:8px;">`;
html += `<button class="btn mini" onclick="jump()">GO</button>`;
el.innerHTML = html;
}
function jump(){
const v = parseInt(document.getElementById('jump_page').value) || 1;
loadPage(Math.max(1, Math.min(v, CUR.pages)));
}
function resetFilter(){
['f_type','f_status','f_q'].forEach(id => document.getElementById(id).value = '');
loadPage(1);
}
// ---------- 选中 / 批量删除 ----------
function updSel(){
const cbs = Array.from(document.querySelectorAll('.row_check'));
const n = cbs.filter(c => c.checked).length;
document.getElementById('sel_count').textContent = n;
const all = document.getElementById('check_all');
if (all){ all.checked = cbs.length > 0 && n === cbs.length; }
}
function toggleAll(el){
document.querySelectorAll('.row_check').forEach(c => c.checked = el.checked);
updSel();
}
function selectedIds(){
return Array.from(document.querySelectorAll('.row_check:checked'))
.map(c => parseInt(c.closest('tr').dataset.lid)).filter(Boolean);
}
async function delSelected(){
const ids = selectedIds();
if (!ids.length){ toast('请先勾选要删除的日志', false); return; }
if (!confirm(`确认删除选中的 ${ids.length} 条日志?`)) return;
const r = await API.json('/api/logs/delete', {ids});
toast(r.ok ? `✅ 已删除 ${r.deleted}` : '❌ 删除失败', r.ok);
if (r.ok) loadPage(CUR.page);
}
async function delFiltered(){
const q = {
type: document.getElementById('f_type').value,
status: document.getElementById('f_status').value,
q: document.getElementById('f_q').value
};
if (!confirm('确认删除当前筛选条件下的全部日志?此操作不可恢复!')) return;
const r = await API.json('/api/logs/delete', {all:true, ...q});
toast(r.ok ? `✅ 已删除 ${r.deleted}` : '❌ 删除失败', r.ok);
if (r.ok) loadPage(1);
}
async function delOne(id){
if (!confirm('确认删除该条日志?')) return;
const r = await API.json('/api/logs/delete', {ids:[id]});
toast(r.ok ? '✅ 已删除' : '❌ 删除失败', r.ok);
if (r.ok) loadPage(CUR.page);
}
// ---------- 查看通知内容 ----------
async function viewLog(id){
const row = document.querySelector(`tr[data-lid="${id}"]`);
const subject = row ? row.querySelector('td a').textContent : ('日志 #' + id);
const r = await API.get('/api/logs/' + id);
document.getElementById('viewTitle').textContent = '📄 ' + subject;
const vb = document.getElementById('viewBody');
if (r.ok && r.log && r.log.body){
vb.innerHTML = '<iframe id="viewFrame" style="width:100%;height:62vh;border:1px solid #e5e7eb;border-radius:8px;"></iframe>';
document.getElementById('viewFrame').srcdoc = r.log.body;
} else if (r.ok && r.log && r.log.article_ids && r.log.article_ids.length){
vb.innerHTML = '<div class="muted small" style="margin-bottom:8px;">该条为旧记录(未存正文),关联资讯:</div><div id="art_links"></div>';
const art = await API.get('/api/articles/batch', {ids: r.log.article_ids.join(',')});
document.getElementById('art_links').innerHTML = (art.ok && art.articles.length)
? art.articles.map(a => `<div style="padding:6px 0;border-bottom:1px solid #f3f4f6;"><a href="/news/${a.id}" target="_blank">${esc(a.title)}</a> <span class="muted small">#${a.id}</span></div>`).join('')
: '<div class="empty">无关联资讯</div>';
} else {
vb.innerHTML = '<div class="empty">该条记录未保存正文内容</div>' +
(r.ok && r.log && r.log.detail ? `<div class="muted" style="margin-top:8px;">${esc(r.log.detail)}</div>` : '');
}
document.getElementById('viewModal').style.display = 'flex';
}
async function testMail(){
const r = await API.json('/api/actions', {action:'test_mail'});
toast(r.ok ? '✅ 测试邮件已发送' : ('❌ ' + r.error), r.ok);
@@ -72,5 +214,7 @@ async function testErrorMail(){
const r = await API.json('/api/actions', {action:'test_error_mail'});
toast(r.ok ? '✅ 测试错误通知邮件已发送' : ('❌ ' + r.error), r.ok);
}
loadPage(1);
</script>
{% endblock %}
+48 -1
View File
@@ -178,10 +178,14 @@
</div>
<table>
<thead><tr><th>名称</th><th>Base URL</th><th>模型</th><th>状态</th><th>操作</th></tr></thead>
<thead><tr><th>优先级</th><th>名称</th><th>Base URL</th><th>模型</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
{% for p in providers %}
<tr data-pid="{{ p.id }}" data-name="{{ p.name }}" data-url="{{ p.base_url }}" data-model="{{ p.model }}" data-key="{{ p.api_key }}">
<td style="white-space:nowrap;">
<button class="btn mini" onclick="moveProvider({{ p.id }}, -1)" title="上移(提高优先级)"></button>
<button class="btn mini" onclick="moveProvider({{ p.id }}, 1)" title="下移(降低优先级)"></button>
</td>
<td>
<b>{{ p.name }}</b>
{% if p.active %}<span class="tag ok">当前</span>{% endif %}
@@ -203,8 +207,46 @@
{% endfor %}
</tbody>
</table>
<div class="muted small" style="margin-top:8px;">💡 调用顺序按优先级:高优先级失败会自动切换下一个,全部失败才算调用异常(邮件通知)。</div>
<div id="lp_test_result" class="muted small" style="margin-top:10px;"></div>
</section>
<section class="card">
<h3>📊 LLM 调用统计 <span class="muted small">次数 / Token / 失败(按天按接口记录)</span></h3>
<div class="two-col">
<div>
<h4 style="font-size:14px;color:#111827;margin:4px 0 8px;">各接口累计</h4>
<table>
<thead><tr><th>接口</th><th>调用次数</th><th>Prompt Tokens</th><th>Completion</th><th>总 Tokens</th><th>失败</th></tr></thead>
<tbody>
{% for s in llm_stats %}
<tr>
<td>{{ s.provider }}</td><td>{{ s.calls }}</td><td>{{ s.prompt_tokens }}</td>
<td>{{ s.completion_tokens }}</td><td>{{ s.total_tokens }}</td>
<td>{% if s.failures %}<span class="tag err">{{ s.failures }}</span>{% else %}0{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="6" class="empty">暂无调用记录(有 LLM 分析后自动统计)</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div>
<h4 style="font-size:14px;color:#111827;margin:4px 0 8px;">最近 14 天合计</h4>
<table>
<thead><tr><th>日期</th><th>调用</th><th>总 Tokens</th><th>失败</th></tr></thead>
<tbody>
{% for d in llm_daily %}
<tr><td>{{ d.day }}</td><td>{{ d.calls }}</td><td>{{ d.total_tokens }}</td>
<td>{% if d.failures %}<span class="tag err">{{ d.failures }}</span>{% else %}0{% endif %}</td></tr>
{% else %}
<tr><td colspan="4" class="empty">暂无数据</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
{% block script %}
<script>
@@ -307,6 +349,11 @@ async function testProvider(id){
async function toggleProvider(id){
await API.json('/api/llm', {action:'toggle', id}); location.reload();
}
async function moveProvider(id, dir){
const r = await API.json('/api/llm', {action:'move', id, direction: dir});
toast(r.ok ? '✅ 已调整优先级' : ('❌ ' + (r.error || '移动失败')), r.ok);
if (r.ok) location.reload();
}
async function delProvider(id){
if (!confirm('确认删除该大模型接口?')) return;
await API.json('/api/llm', {action:'delete', id});
+85 -6
View File
@@ -4,15 +4,37 @@
{% block content %}
<div class="page-head">
<h1>📡 数据源管理</h1>
<button class="btn accent" onclick="openModal()"> 新增数据源</button>
<div class="actions">
<button class="btn" onclick="setAllSources(1)">✅ 全部启用</button>
<button class="btn" onclick="setAllSources(0)">⏸ 全部停用</button>
<button class="btn accent" onclick="openModal()"> 新增数据源</button>
</div>
</div>
<div class="card" style="margin-bottom:14px;">
<div class="form-row" style="justify-content:flex-start;gap:10px;flex-wrap:wrap;">
<input id="f_keyword" placeholder="🔍 搜索名称 / 类型 / URL / 描述..."
style="flex:1;min-width:220px;" oninput="applyFilter()">
<select id="f_kind" onchange="applyFilter()" style="width:150px;">
<option value="">全部监控方式</option>
<option value="normal">普通数据源</option>
<option value="custom">🎯 定制监控</option>
</select>
<select id="f_enabled" onchange="applyFilter()" style="width:120px;">
<option value="">全部状态</option>
<option value="1">启用</option>
<option value="0">停用</option>
</select>
<button class="btn mini" onclick="clearFilter()">重置</button>
</div>
</div>
<div class="card">
<table>
<table id="srcTable">
<thead><tr><th>ID</th><th>名称</th><th>监控方式</th><th>获取方式</th><th>类型</th><th>权重</th><th>采集周期</th><th>状态</th><th>最近采样</th><th>条数</th><th>操作</th></tr></thead>
<tbody>
{% for s in sources %}
<tr data-sid="{{ s.id }}" data-name="{{ s.name }}" data-type="{{ s.type }}" data-url="{{ s.url }}" data-desc="{{ s.description }}" data-weight="{{ s.weight }}" data-kind="{{ s.kind }}" data-standard="{{ s.monitor_standard or '' }}" data-interval="{{ s.scan_interval_min or 0 }}" data-fetch="{{ s.fetch_method or 'auto' }}" data-capture="{{ s.capture_params or '{}' }}">
<tr data-sid="{{ s.id }}" data-enabled="{{ s.enabled }}" data-name="{{ s.name }}" data-type="{{ s.type }}" data-url="{{ s.url }}" data-desc="{{ s.description }}" data-weight="{{ s.weight }}" data-kind="{{ s.kind }}" data-standard="{{ s.monitor_standard or '' }}" data-interval="{{ s.scan_interval_min or 0 }}" data-fetch="{{ s.fetch_method or 'auto' }}" data-capture="{{ s.capture_params or '{}' }}">
<td>{{ s.id }}</td>
<td><b>{{ s.name }}</b><br><span class="muted small">{{ s.description }}</span><br><span class="muted small" style="word-break:break-all;">{{ s.url }}</span></td>
<td>{% if s.kind == 'custom' %}<span class="tag hot">🎯 定制监控</span>{% else %}<span class="tag ok">普通</span>{% endif %}</td>
@@ -20,13 +42,19 @@
<td>{{ s.type }}</td>
<td>{% if s.kind == 'custom' %}<span class="muted small"></span>{% else %}{{ s.weight }}{% endif %}</td>
<td>{% if s.scan_interval_min %}{{ s.scan_interval_min }}分{% else %}<span class="muted small">默认</span>{% endif %}</td>
<td>{% if s.enabled %}<span class="tag ok">启用</span>{% else %}<span class="tag">停用</span>{% endif %}</td>
<td>
<label class="switch" title="一键启动/停用">
<input type="checkbox" onchange="toggleSource({{ s.id }}, this)" {{ 'checked' if s.enabled }}>
<span class="slider"></span>
</label>
<span id="st-{{ s.id }}" class="tag {{ 'ok' if s.enabled }}">{{ '启用' if s.enabled else '停用' }}</span>
</td>
<td>{{ s.last_fetch or '—' }}</td>
<td>{{ s.last_count }}</td>
<td>
<button class="btn mini" onclick="openModal({{ s.id }})">✏️ 编辑</button>
<button class="btn mini" onclick="openHistory({{ s.id }})">📜 历史采样</button>
<button class="btn mini" onclick="toggleSource({{ s.id }})">{{ '停用' if s.enabled else '启用' }}</button>
<button class="btn mini" onclick="toggleFromButton({{ s.id }})">{{ '停用' if s.enabled else '启用' }}</button>
<button class="btn mini danger" onclick="delSource({{ s.id }})">删除</button>
</td>
</tr>
@@ -291,7 +319,58 @@ async function openHistArticles(){
).join('') + '</div>';
}
async function toggleSource(id){ await API.json('/api/sources', {action:'toggle', id}); location.reload(); }
async function toggleSource(id, cb){
cb = cb || document.querySelector(`tr[data-sid="${id}"] .switch input`);
if (!cb) return;
const orig = cb.checked;
const r = await API.json('/api/sources', {action:'toggle', id});
if (!r.ok){ toast('操作失败', false); cb.checked = orig; return; }
const on = cb.checked;
const row = document.querySelector(`tr[data-sid="${id}"]`);
if (row) row.setAttribute('data-enabled', on ? '1' : '0');
const tag = document.getElementById('st-'+id);
if (tag){ tag.textContent = on ? '启用' : '停用'; tag.className = 'tag ' + (on ? 'ok' : ''); }
const nameEl = document.querySelector(`tr[data-sid="${id}"] b`);
const name = nameEl ? nameEl.textContent : '';
toast((on ? '✅ 已启用「' : '⏸ 已停用「') + name + '」', true);
applyFilter();
}
function toggleFromButton(id){
const cb = document.querySelector(`tr[data-sid="${id}"] .switch input`);
if (!cb) return;
cb.checked = !cb.checked;
toggleSource(id, cb);
}
// ---------- 快速筛选 ----------
function applyFilter(){
const kw = (document.getElementById('f_keyword').value || '').trim().toLowerCase();
const kind = document.getElementById('f_kind').value;
const en = document.getElementById('f_enabled').value;
document.querySelectorAll('#srcTable tbody tr').forEach(tr => {
const text = ((tr.dataset.name||'') + ' ' + (tr.dataset.type||'') + ' ' + (tr.dataset.url||'') + ' ' + (tr.dataset.desc||'')).toLowerCase();
const okKw = !kw || text.includes(kw);
const okKind = !kind || (tr.dataset.kind||'normal') === kind;
const okEn = en === '' || (tr.dataset.enabled||'1') === en;
tr.style.display = (okKw && okKind && okEn) ? '' : 'none';
});
}
function clearFilter(){
document.getElementById('f_keyword').value = '';
document.getElementById('f_kind').value = '';
document.getElementById('f_enabled').value = '';
applyFilter();
}
// ---------- 一键全部启用 / 全部停用 ----------
async function setAllSources(val){
const action = val ? 'enable_all' : 'disable_all';
if (!confirm(val ? '确定要【全部启用】所有数据源吗?' : '确定要【全部停用】所有数据源吗?')) return;
const r = await API.json('/api/sources', {action});
if (!r.ok){ toast('操作失败', false); return; }
toast((val ? '✅ 已全部启用 ' : '⏸ 已全部停用 ') + (r.count||0) + ' 个数据源', true);
setTimeout(()=>location.reload(), 400);
}
async function delSource(id){
if (!confirm('确认删除该数据源?')) return;
await API.json('/api/sources', {action:'delete', id}); toast('✅ 已删除'); setTimeout(()=>location.reload(), 400);