3 Commits
10 changed files with 604 additions and 83 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
一款面向 AI 领域的**自动采集 → 智能分析 → 邮件推送**的新闻跟踪系统。后台定时从数据源采集资讯,先规则打分、再 LLM 深度分析,把与你兴趣画像相关且重要的资讯**实时发邮件提醒**,并每天 10:00 发一份**AI 资讯日报**。带完整网页管理台,兴趣画像/数据源/通知参数全部可配。 一款面向 AI 领域的**自动采集 → 智能分析 → 邮件推送**的新闻跟踪系统。后台定时从数据源采集资讯,先规则打分、再 LLM 深度分析,把与你兴趣画像相关且重要的资讯**实时发邮件提醒**,并每天 10:00 发一份**AI 资讯日报**。带完整网页管理台,兴趣画像/数据源/通知参数全部可配。
> 当前版本 **v1.3.0**:数据源**独立采集周期**(每源可覆盖全局)+ **定制监控与新闻监控完全分离**(独立间隔/独立汇总)+ **历史采样留档与提取** > 当前版本 **v1.4.3**:大模型接口 token/次数统计 + 调用优先级自动切换 + 通知日志查看正文/增强分页/批量删除
--- ---
@@ -236,4 +236,4 @@ cd works/news-tracker
## Git ## Git
- 仓库:`hz4th_coder/news-tracker` - 仓库:`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} "api_key": config.LLM_API_KEY, "model": config.LLM_MODEL}
def _llm_chat(prompt): def _provider_chain():
"""调用大模型,返回 (content, provider_name)。激活接口失败自动切换下一可用接口。""" """按优先级排序的大模型接口调用链(priority 小优先,其次 id)。
active = get_llm_cfg() 全部由 llm_providers 表启用项决定;无任何启用接口时回退 config 默认。"""
chain = [active] chain = []
cur = db.get_active_provider() for p in db.list_providers_ordered():
if cur: chain.append({"id": p["id"], "name": p["name"],
for p in db.enabled_providers_except(cur["id"]): "base_url": (p["base_url"] or "").rstrip("/"),
chain.append({"name": p["name"], "base_url": (p["base_url"] or "").rstrip("/"), "api_key": p.get("api_key", ""), "model": p.get("model", "")})
"api_key": p.get("api_key", ""), "model": p.get("model", "")}) if not chain:
else: chain.append({"id": 0, "name": "DeepSeek 官方", "base_url": config.LLM_BASE_URL,
chain.append({"name": "DeepSeek 官方", "base_url": config.LLM_BASE_URL,
"api_key": config.LLM_API_KEY, "model": config.LLM_MODEL}) "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 = "" last_err = ""
for cfg in chain: for cfg in chain:
if not cfg.get("base_url"): if not cfg.get("base_url"):
continue continue
pname = cfg.get("name", cfg["base_url"])
pid = cfg.get("id", 0)
try: try:
resp = requests.post( resp = requests.post(
f"{cfg['base_url']}/chat/completions", f"{cfg['base_url']}/chat/completions",
@@ -237,9 +245,16 @@ def _llm_chat(prompt):
timeout=config.LLM_TIMEOUT, timeout=config.LLM_TIMEOUT,
) )
data = resp.json() 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"] 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: except Exception as e:
db.add_llm_stat(pname, pid, 0, 0, success=False)
last_err = str(e) last_err = str(e)
continue continue
raise RuntimeError(f"所有大模型接口调用失败: {last_err}") 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)}) "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") @app.route("/api/errors")
def api_errors(): def api_errors():
"""系统错误日志(最近 N 条)""" """系统错误日志(最近 N 条)"""
@@ -121,6 +150,19 @@ def api_errors():
return jsonify({"ok": True, "errors": db.list_system_errors(limit=limit)}) 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") @app.route("/settings")
def settings_page(): def settings_page():
auto = db.get_all_settings() auto = db.get_all_settings()
@@ -141,7 +183,8 @@ def settings_page():
merged = dict(dft) merged = dict(dft)
merged.update(v) merged.update(v)
auto[k] = merged 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 ---------------- # ---------------- API ----------------
@@ -189,6 +232,12 @@ def api_sources():
s = db.get_source(data["id"]) s = db.get_source(data["id"])
db.update_source(data["id"], enabled=0 if s["enabled"] else 1) db.update_source(data["id"], enabled=0 if s["enabled"] else 1)
return jsonify({"ok": True}) 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": if action == "test_standard":
# 定制监控:用大模型测试「推送标准」是否好使(给一段示例内容看判不判得出) # 定制监控:用大模型测试「推送标准」是否好使(给一段示例内容看判不判得出)
standard = (data.get("monitor_standard") or "").strip() standard = (data.get("monitor_standard") or "").strip()
@@ -348,6 +397,10 @@ def api_llm():
db.set_active_provider(data["id"]) db.set_active_provider(data["id"])
p = db.get_provider(data["id"]) p = db.get_provider(data["id"])
return jsonify({"ok": True, "name": p["name"] if p else ""}) 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": if action == "toggle":
p = db.get_provider(data["id"]) p = db.get_provider(data["id"])
if not p: if not p:
+174 -7
View File
@@ -188,6 +188,27 @@ def init_db():
if "full_text" not in cols: if "full_text" not in cols:
conn.execute("ALTER TABLE articles ADD COLUMN full_text TEXT DEFAULT ''") conn.execute("ALTER TABLE articles ADD COLUMN full_text TEXT DEFAULT ''")
conn.commit() 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() conn.close()
@@ -290,6 +311,16 @@ def delete_source(sid):
conn.close() 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): def update_source_fetch(sid, status="ok", count=0):
conn = get_conn() conn = get_conn()
conn.execute( conn.execute(
@@ -659,16 +690,152 @@ def enabled_providers_except(pid):
return [dict(r) for r in rows] return [dict(r) for r in rows]
# ---------------- 通知日志(分页 + 筛选) ---------------- def list_providers_ordered():
def add_log(type_, subject, count, article_ids, status="ok", detail=""): """按优先级排序的已启用大模型接口:priority 小优先,其次 id(用于调用顺序)。"""
conn = get_conn() conn = get_conn()
conn.execute( rows = conn.execute(
"INSERT INTO notification_log(type,subject,count,article_ids,status,detail,sent_at) " "SELECT * FROM llm_providers WHERE enabled=1 ORDER BY priority, id").fetchall()
"VALUES(?,?,?,?,?,?,?)", conn.close()
(type_, subject, count, json.dumps(article_ids, ensure_ascii=False), status, detail, now_str()), 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.commit()
conn.close() 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): 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.header import Header
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.utils import formataddr from email.utils import formataddr, formatdate
import config import config
import db 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["From"] = formataddr((str(Header(cfg.get("sender_name", "新闻智能跟踪"), "utf-8")), cfg["smtp_user"]))
msg["To"] = to msg["To"] = to
msg["Subject"] = Header(subject, "utf-8") 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")) msg.attach(MIMEText(html, "html", "utf-8"))
mode = cfg.get("smtp_mode", "plain") 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}", f"<p style='color:#374151;font-size:13px;'>新闻智能跟踪系统检测到以下异常,请及时处理:</p>{rows}",
) )
send_email(subject, html) 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(): def _try_send_pending():
@@ -226,7 +228,7 @@ def send_realtime(articles):
ids = [a["id"] for a in articles] ids = [a["id"] for a in articles]
for aid in ids: for aid in ids:
db.update_article(aid, notified=1) 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) return len(articles)
@@ -252,7 +254,7 @@ def send_daily_summary(articles, window_label):
) )
send_email(subject, html) send_email(subject, html)
ids = [a["id"] for a in articles if a["id"]] 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: for aid in ids:
art = db.get_article(aid) art = db.get_article(aid)
if art and art.get("status") != "summarized": if art and art.get("status") != "summarized":
@@ -278,5 +280,5 @@ def send_custom_summary(articles, window_label):
) )
send_email(subject, html) send_email(subject, html)
ids = [a["id"] for a in articles if a["id"]] 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) return len(top)
+15 -9
View File
@@ -147,11 +147,16 @@ def send_custom_summary():
return notifier.send_custom_summary(articles, label) return notifier.send_custom_summary(articles, label)
def _maybe_daily(now, last_day, time_key, send_fn, kind): def _maybe_daily(now, last_day, time_key, send_fn, kind, persist_key):
"""到点触发的通用逻辑。time_key 存 'HH:MM'send_fn 返回发送条数。""" """到点触发的通用逻辑。time_key 存 'HH:MM'send_fn 返回发送条数。
last_day 存上次发送的日期字符串 'YYYY-MM-DD'None=尚未发送)。
修复:每天只发一次——last_day 必须是纯字符串,不能嵌套元组;
并把已发送日期持久化到 settings,服务重启后也不会当天重复发。"""
day_key = now.strftime("%Y-%m-%d") day_key = now.strftime("%Y-%m-%d")
if last_day[0] == day_key: if last_day is None:
return last_day 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 hm = str(db.get_setting(time_key, config.AUTO_DEFAULTS.get(time_key) or
config.CUSTOM_DEFAULTS.get(time_key) or "10:00")) config.CUSTOM_DEFAULTS.get(time_key) or "10:00"))
try: 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: if now.strftime("%H:%M") >= hm and now.hour >= hh:
try: try:
send_fn() send_fn()
last_day = (day_key,) db.set_setting(persist_key, day_key)
return day_key
except Exception as e: except Exception as e:
db.add_log(kind, f"{time_key} 汇总异常", 0, [], status="error", detail=str(e)) db.add_log(kind, f"{time_key} 汇总异常", 0, [], status="error", detail=str(e))
notifier.report_error("汇总", f"{time_key} 汇总发送异常", str(e)[:300]) notifier.report_error("汇总", f"{time_key} 汇总发送异常", str(e)[:300])
@@ -174,10 +180,10 @@ def scheduler_loop(stop_event):
while not stop_event.is_set(): while not stop_event.is_set():
now = datetime.now() now = datetime.now()
try: try:
last_summary_day = _maybe_daily(now, (last_summary_day,), "summary_time", last_summary_day = _maybe_daily(now, last_summary_day, "summary_time",
send_daily_summary, "summary") send_daily_summary, "summary", "summary_last_day")
last_custom_summary_day = _maybe_daily(now, (last_custom_summary_day,), "custom_summary_time", last_custom_summary_day = _maybe_daily(now, last_custom_summary_day, "custom_summary_time",
send_custom_summary, "custom_summary") send_custom_summary, "custom_summary", "custom_summary_last_day")
except Exception as e: except Exception as e:
notifier.report_error("汇总", "定时汇总检查异常", str(e)[:300]) 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.ok { background:#ecfdf5; color:#059669; }
.tag.err { background:#fef2f2; color:#dc2626; } .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 { 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 select, .filters input { padding:8px 10px; border:1px solid #d1d5db; border-radius:8px; background:#fff; font-size:13px; }
.filters input[type=text]{ width:260px; } .filters input[type=text]{ width:260px; }
+184 -40
View File
@@ -3,67 +3,209 @@
{% block title %}通知日志 - 新闻智能跟踪{% endblock %} {% block title %}通知日志 - 新闻智能跟踪{% endblock %}
{% block content %} {% block content %}
<div class="page-head"> <div class="page-head">
<h1>✉️ 通知日志 <span class="muted">共 {{ total }} 条</span></h1> <h1>✉️ 通知日志 <span class="muted" id="total_label"></span></h1>
<div class="actions"> <div class="actions">
<button class="btn" onclick="testMail()">📮 发送测试邮件</button> <button class="btn" onclick="testMail()">📮 发送测试邮件</button>
<button class="btn" onclick="testErrorMail()">⚠️ 测试错误通知</button> <button class="btn" onclick="testErrorMail()">⚠️ 测试错误通知</button>
</div> </div>
</div> </div>
<form class="filters" method="get" action="/logs"> <div class="filters">
<select name="type"> <select id="f_type" onchange="loadPage(1)">
<option value="">全部类型</option> <option value="">全部类型</option>
<option value="realtime" {{ 'selected' if f_type=='realtime' }}>🔥 实时重要资讯</option> <option value="realtime">🔥 实时重要资讯</option>
<option value="summary" {{ 'selected' if f_type=='summary' }}>📰 新闻汇总</option> <option value="summary">📰 新闻汇总</option>
<option value="custom_summary" {{ 'selected' if f_type=='custom_summary' }}>🎯 定制监控汇总</option> <option value="custom_summary">🎯 定制监控汇总</option>
<option value="error" {{ 'selected' if f_type=='error' }}>⚠️ 系统错误</option> <option value="error">⚠️ 系统错误</option>
</select> </select>
<select name="status"> <select id="f_status" onchange="loadPage(1)">
<option value="">全部状态</option> <option value="">全部状态</option>
<option value="ok" {{ 'selected' if f_status=='ok' }}>✅ 成功</option> <option value="ok">✅ 成功</option>
<option value="error" {{ 'selected' if f_status=='error' }}>❌ 失败</option> <option value="error">❌ 失败</option>
</select> </select>
<input type="text" name="q" value="{{ q }}" placeholder="搜索主题 / 详情..."> <input type="text" id="f_q" placeholder="搜索主题 / 详情..." onkeydown="if(event.key==='Enter')loadPage(1)">
<button class="btn" type="submit">筛选</button> <button class="btn" onclick="loadPage(1)">筛选</button>
<a class="btn" href="/logs">重置</a> <button class="btn" onclick="resetFilter()">重置</button>
</form> <span class="muted small" style="margin-left:auto;">勾选行后可批量删除</span>
</div>
<div class="card"> <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> <table>
<thead><tr><th>ID</th><th>类型</th><th>主题</th><th>条数</th><th>状态</th><th>发送时间</th></tr></thead> <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> <tbody id="log_tbody">
{% for l in logs %} <tr><td colspan="8" class="empty">⏳ 加载中...</td></tr>
<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 %}
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="pager"> <div class="pager" id="pager"></div>
{% 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="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> </div>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<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(){ async function testMail(){
const r = await API.json('/api/actions', {action:'test_mail'}); const r = await API.json('/api/actions', {action:'test_mail'});
toast(r.ok ? '✅ 测试邮件已发送' : ('❌ ' + r.error), r.ok); 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'}); const r = await API.json('/api/actions', {action:'test_error_mail'});
toast(r.ok ? '✅ 测试错误通知邮件已发送' : ('❌ ' + r.error), r.ok); toast(r.ok ? '✅ 测试错误通知邮件已发送' : ('❌ ' + r.error), r.ok);
} }
loadPage(1);
</script> </script>
{% endblock %} {% endblock %}
+48 -1
View File
@@ -178,10 +178,14 @@
</div> </div>
<table> <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> <tbody>
{% for p in providers %} {% 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 }}"> <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> <td>
<b>{{ p.name }}</b> <b>{{ p.name }}</b>
{% if p.active %}<span class="tag ok">当前</span>{% endif %} {% if p.active %}<span class="tag ok">当前</span>{% endif %}
@@ -203,8 +207,46 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<div class="muted small" style="margin-top:8px;">💡 调用顺序按优先级:高优先级失败会自动切换下一个,全部失败才算调用异常(邮件通知)。</div>
<div id="lp_test_result" class="muted small" style="margin-top:10px;"></div> <div id="lp_test_result" class="muted small" style="margin-top:10px;"></div>
</section> </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 %} {% endblock %}
{% block script %} {% block script %}
<script> <script>
@@ -307,6 +349,11 @@ async function testProvider(id){
async function toggleProvider(id){ async function toggleProvider(id){
await API.json('/api/llm', {action:'toggle', id}); location.reload(); 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){ async function delProvider(id){
if (!confirm('确认删除该大模型接口?')) return; if (!confirm('确认删除该大模型接口?')) return;
await API.json('/api/llm', {action:'delete', id}); await API.json('/api/llm', {action:'delete', id});
+85 -6
View File
@@ -4,15 +4,37 @@
{% block content %} {% block content %}
<div class="page-head"> <div class="page-head">
<h1>📡 数据源管理</h1> <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>
<div class="card"> <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> <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> <tbody>
{% for s in sources %} {% 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>{{ 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><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> <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>{{ s.type }}</td>
<td>{% if s.kind == 'custom' %}<span class="muted small"></span>{% else %}{{ s.weight }}{% endif %}</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.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_fetch or '—' }}</td>
<td>{{ s.last_count }}</td> <td>{{ s.last_count }}</td>
<td> <td>
<button class="btn mini" onclick="openModal({{ s.id }})">✏️ 编辑</button> <button class="btn mini" onclick="openModal({{ s.id }})">✏️ 编辑</button>
<button class="btn mini" onclick="openHistory({{ 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> <button class="btn mini danger" onclick="delSource({{ s.id }})">删除</button>
</td> </td>
</tr> </tr>
@@ -291,7 +319,58 @@ async function openHistArticles(){
).join('') + '</div>'; ).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){ async function delSource(id){
if (!confirm('确认删除该数据源?')) return; if (!confirm('确认删除该数据源?')) return;
await API.json('/api/sources', {action:'delete', id}); toast('✅ 已删除'); setTimeout(()=>location.reload(), 400); await API.json('/api/sources', {action:'delete', id}); toast('✅ 已删除'); setTimeout(()=>location.reload(), 400);