Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bbb6150cc | ||
|
|
3f6abe7ad6 | ||
|
|
c9671880e8 |
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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])
|
||||
# 采集到期数据源(每源独立周期)
|
||||
|
||||
@@ -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
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user