Commit 5e5097d0 authored by 郭阔's avatar 郭阔

feat: setup.sh bootstrap and cross-platform web panel with harvest toggle

- bin/setup.sh: one-command machine bootstrap (auto platform detect) —
  directory layout, dependency check (brew install on consent), claude
  auth check, skills repo auto-clone from SKILLS_REPO_URL + npm install
  + full bundle import (all sources), dual skill symlink registration,
  default.conf/web.conf generation (random panel password), optional
  web panel service install, digest schedule, remaining-manual-steps
  summary; idempotent
- web/app.py: platform_layer integration — paths/timer reads/trigger/
  active-harvest checks unified; add/edit/disable actions now work on
  darwin; per-source harvest toggle route (schedule load/unload +
  skill enable/disable; auto staggered schedule creation on first enable)
- index.html: toggle switch column, unscheduled skill sources merged
  into list, platform-neutral labels
- bin/platform_layer.py: python facade reusing platform.sh via
  'platform.sh call' dispatcher for all mutations; file reads native
parent 3ac999b0
#!/bin/bash
# setup.sh — 一键自举(自动识别 macOS / Linux),新机器从此脚本开始。
#
# 用法:
# ./bin/setup.sh [--skills-repo <git-url>] [--with-web] [--yes]
#
# 做的事(幂等,可重复运行):
# 1. 平台检测 + 目录结构(WORK_DIR / conf / logs / agents)
# 2. 依赖检查:claude / node / jq / python3 / gnu-sed(darwin);brew 可装的征询后自动装
# 3. claude 配置检查(~/.claude/settings.json 的 auth;缺失提示从旧机拷贝)
# 4. 技能仓库:自动 clone(地址来自 --skills-repo 或 default.conf 的 SKILLS_REPO_URL)
# → 三技能 npm install → 全量导入 bundles → vault 配置 → symlink 注册
# 5. default.conf / web.conf 生成(面板密码随机,仅打印一次)
# 6. 可选 --with-web:安装 Web 面板常驻服务(launchd / systemd)
# 7. 收尾:打印剩余人工事项与首个 add-source 命令示例
#
# 环境变量:
# SKILLS_REPO_URL 技能仓库 git 地址(与 --skills-repo 等价)
# WORK_DIR / CONF_DIR / NH_SCHEDULE_DIR 覆盖默认路径
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/platform.sh"
nh_bootstrap_env
ASSUME_YES=0 WITH_WEB=0 SKILLS_REPO_ARG=""
while [ $# -gt 0 ]; do
case "$1" in
--skills-repo) SKILLS_REPO_ARG="${2:?--skills-repo 需要值}"; shift 2;;
--with-web) WITH_WEB=1; shift;;
--yes|-y) ASSUME_YES=1; shift;;
--help|-h) sed -n '2,20p' "$0"; exit 0;;
*) echo "未知参数: $1" >&2; exit 1;;
esac
done
ok() { echo "✅ $*"; }
warn() { echo "⚠️ $*"; }
miss() { echo "❌ $*"; }
confirm() {
[ "$ASSUME_YES" = "1" ] && return 0
local reply; read -rp "$1 [y/N] " reply
case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
}
echo "=========================================="
echo " News Harvester 自举($NH_OS)"
echo "=========================================="
# ── 1. 目录结构 ────────────────────────────────────────────
WORK_DIR="$(nh_default_work_dir)"
CONF_DIR="$(nh_default_conf_dir "$WORK_DIR")"
LOG_DIR="${LOG_DIR:-$WORK_DIR/logs}"
mkdir -p "$WORK_DIR" "$CONF_DIR/providers" "$LOG_DIR" "$WORK_DIR/agents"
ok "目录就绪: $WORK_DIR"
# ── 2. 依赖检查 ────────────────────────────────────────────
need_brew=()
command -v python3 >/dev/null 2>&1 || { miss "python3 缺失"; exit 1; }
command -v jq >/dev/null 2>&1 || need_brew+=(jq)
command -v node >/dev/null 2>&1 || need_brew+=(node)
command -v git >/dev/null 2>&1 || need_brew+=(git)
if nh_is_darwin; then
sed --version 2>/dev/null | head -1 | grep -q "GNU sed" || need_brew+=(gnu-sed)
fi
if [ ${#need_brew[@]} -gt 0 ]; then
warn "缺少依赖: ${need_brew[*]}"
if nh_is_darwin && command -v brew >/dev/null 2>&1; then
confirm "用 Homebrew 自动安装?" && brew install "${need_brew[@]}" \
&& ok "依赖已安装" || warn "跳过安装,请手动处理后再跑一次"
else
warn "请手动安装后重新运行(Linux: pacman/apt;macOS: 先装 Homebrew)"
fi
else
ok "依赖齐全(python3/jq/node/git$(nh_is_darwin && echo "/gnu-sed"))"
fi
if ! command -v claude >/dev/null 2>&1; then
warn "claude CLI 未安装 —— 采集核心依赖"
echo " 安装:npm install -g @anthropic-ai/claude-code"
echo " 装好后重跑本脚本"
fi
# ── 3. claude 配置检查 ─────────────────────────────────────
if [ -f "$HOME/.claude/settings.json" ] && jq -e '.env.ANTHROPIC_AUTH_TOKEN // empty' "$HOME/.claude/settings.json" >/dev/null 2>&1; then
ok "claude 认证已配置"
else
warn "claude 认证未配置(~/.claude/settings.json 无 ANTHROPIC_AUTH_TOKEN)"
echo " 含敏感信息不入仓库:从旧机拷贝该文件,或在文件中配置:"
echo ' {"env": {"ANTHROPIC_AUTH_TOKEN": "…", "ANTHROPIC_BASE_URL": "…", "ANTHROPIC_MODEL": "…"}}'
fi
# ── 4. 技能仓库(clone + 依赖 + 全量导入) ──────────────────
if [ -n "$SKILLS_REPO_ARG" ]; then
export SKILLS_REPO_URL="$SKILLS_REPO_ARG"
# 写入 default.conf 供后续 setup 增量使用
if [ -f "$CONF_DIR/default.conf" ] && ! grep -q "^SKILLS_REPO_URL=" "$CONF_DIR/default.conf"; then
printf '\n# 技能仓库(自动 clone 与全量导入)\nSKILLS_REPO_URL=%s\nSKILLS_DIR=%s\n' "$SKILLS_REPO_ARG" "$(nh_skills_root)" >> "$CONF_DIR/default.conf"
fi
fi
if [ -d "$(nh_skill_dir)" ]; then
ok "技能仓库已存在: $(nh_skills_root)"
confirm "重新全量导入 bundles?(新提交的源会补装)" && nh_skill_bootstrap
else
if [ -n "${SKILLS_REPO_URL:-}" ]; then
confirm "clone 技能仓库 ${SKILLS_REPO_URL} 到 $(nh_skills_root)?" && nh_skill_bootstrap \
&& ok "技能就绪(全部 bundle 已导入)" || miss "技能仓库初始化失败"
else
warn "技能仓库不存在,且未提供地址"
echo " 重跑:./bin/setup.sh --skills-repo <git-url>"
fi
fi
nh_skill_installed && echo " 已装源:$(nh_skill_list | awk '{printf "%s ", $1}')"
# symlink 注册(claude 技能发现:全局 + 项目级双保险)
if [ -d "$(nh_skill_dir)" ]; then
mkdir -p "$HOME/.agents/skills" "$WORK_DIR/.claude/skills"
for s in news-harvester news-source-analyzer news-hub-feed; do
[ -d "$(nh_skills_root)/skills/$s" ] || continue
ln -sfn "$(nh_skills_root)/skills/$s" "$HOME/.agents/skills/$s"
ln -sfn "$(nh_skills_root)/skills/$s" "$WORK_DIR/.claude/skills/$s"
done
ok "技能 symlink 已注册(~/.agents/skills + $WORK_DIR/.claude/skills)"
fi
# ── 5. 配置生成 ────────────────────────────────────────────
if [ ! -f "$CONF_DIR/default.conf" ]; then
cat > "$CONF_DIR/default.conf" <<EOF
# 全局默认配置(由 setup.sh 生成,可编辑)
COUNT=20
MAIL_TO=
MAIL_ON_SUCCESS=0
MAX_BUDGET_USD=15
ALERT_COST_OVER=
DIGEST_MAIL_ALWAYS=1
DIGEST_RETAIN_DAYS=30
DIGEST_BUDGET_USD=2
OBSIDIAN_VAULT_PATH=$HOME/NewsHub
EOF
ok "已生成 $CONF_DIR/default.conf"
else
ok "default.conf 已存在(保留)"
fi
mkdir -p "$HOME/NewsHub"
if [ ! -f "$CONF_DIR/web.conf" ]; then
WEB_PASS="$(openssl rand -hex 8 2>/dev/null || head -c8 /dev/urandom | xxd -p)"
printf 'WEB_USER=admin\nWEB_PASS=%s\n' "$WEB_PASS" > "$CONF_DIR/web.conf"
chmod 600 "$CONF_DIR/web.conf"
ok "面板认证已生成 —— 账号 admin 密码 $WEB_PASS(仅显示这一次,存于 $CONF_DIR/web.conf)"
fi
# ── 6. Web 面板服务(可选) ────────────────────────────────
if [ "$WITH_WEB" = "1" ]; then
if nh_is_darwin; then
if [ ! -f "$WORK_DIR/web-venv/bin/python" ]; then
python3 -m venv "$WORK_DIR/web-venv" && "$WORK_DIR/web-venv/bin/pip" install -q flask \
&& ok "面板 venv 就绪"
fi
if [ ! -f "$WORK_DIR/run-web.sh" ]; then
cat > "$WORK_DIR/run-web.sh" <<EOF
#!/bin/bash
# Web 面板启动入口(由 setup.sh 生成)
set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:\$PATH"
set +u; source "$CONF_DIR/web.conf"; set -u
export WEB_USER="\${WEB_USER:-admin}" WEB_PASS="\${WEB_PASS:?}"
export SYSTEMD_DIR="$HOME/Library/LaunchAgents" CONF_DIR="$CONF_DIR" WORK_DIR="$WORK_DIR" LOG_DIR="$LOG_DIR"
exec "$WORK_DIR/web-venv/bin/python" "$SCRIPT_DIR/../web/app.py"
EOF
chmod +x "$WORK_DIR/run-web.sh"
fi
mkdir -p "$WORK_DIR/agents"
printf '#!/bin/bash\n# LaunchAgent 入口:Web 管理面板(127.0.0.1:8080)\nexec "%s/run-web.sh"\n' "$WORK_DIR" > "$WORK_DIR/agents/news-harvester-web.sh"
chmod +x "$WORK_DIR/agents/news-harvester-web.sh"
cat > "$(nh_schedule_dir)/com.newsharvester.web.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.newsharvester.web</string>
<key>ProgramArguments</key><array><string>$WORK_DIR/agents/news-harvester-web.sh</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>$LOG_DIR/launchd-web.log</string>
<key>StandardErrorPath</key><string>$LOG_DIR/launchd-web.log</string>
</dict>
</plist>
EOF
launchctl bootout "gui/$(id -u)/com.newsharvester.web" >/dev/null 2>&1 || true
launchctl bootstrap "gui/$(id -u)" "$(nh_schedule_dir)/com.newsharvester.web.plist" \
&& ok "面板常驻服务已启动(http://127.0.0.1:8080)"
else
warn "Linux 面板安装请沿用 systemd/news-harvester-web.service(见 README)"
fi
fi
# ── 7. digest 调度(默认 22:00,已存在则跳过) ──────────────
if ! nh_schedule_exists digest; then
confirm "创建每日汇总调度(每天 22:00)?" \
&& nh_schedule_write digest "22:00" && nh_schedule_enable digest \
&& ok "digest 调度已创建(22:00)"
else
ok "digest 调度已存在:$(nh_schedule_read digest)"
fi
echo ""
echo "=========================================="
ok "自举完成"
echo "=========================================="
echo ""
echo "剩余人工事项:"
echo " 1. 浏览器源登录(如 DigiTimes):cd $(nh_skill_dir) && node scripts/ensure-chromium.js --login --url <站点>"
echo " 已有登录脚本的可直接运行(见 ~/NewsHarvester/cdp-login.js 等)"
echo " 2. macOS 需在 系统设置→用户与群组 开启自动登录(launchd 与浏览器的前提)"
echo ""
echo "添加首个采集源(自动错峰):"
echo " $SCRIPT_DIR/add-source.sh <source-id> --time 09:00"
echo " 可用源见:$(nh_skill_dir)/bundles/ 或面板开关一键开启"
echo ""
echo "手动测试一次采集:"
echo " $SCRIPT_DIR/harvest.sh <source-id> 1 (只采 1 条验证)"
......@@ -28,15 +28,17 @@ from flask import (
Flask, abort, flash, redirect, render_template, request, url_for,
)
# ── 路径常量(与 bash 脚本一致) ──────────────────────────────
SYSTEMD_DIR = Path(os.environ.get("SYSTEMD_DIR", "/etc/systemd/system"))
CONF_DIR = Path(os.environ.get("CONF_DIR", "/etc/news-harvester"))
PROVIDER_DIR = CONF_DIR / "providers"
WORK_DIR = Path(os.environ.get("WORK_DIR", "/home/yichu/Documents/News-Harvester"))
LOG_DIR = Path(os.environ.get("LOG_DIR", str(WORK_DIR / "logs")))
# ── 路径常量(跨平台,与 platform.sh 同规则) ──────────────────
PROJECT_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_DIR / "bin"))
from schedule import calendar_times, parse_times, read_timer_times, update_timer
import platform_layer as pl
from schedule import calendar_times, parse_times, read_schedule_times, update_timer
SYSTEMD_DIR = pl.schedule_dir() # 兼容名:调度目录(darwin=LaunchAgents,linux=systemd)
WORK_DIR = Path(os.environ.get("WORK_DIR", pl.default_work_dir()))
CONF_DIR = Path(os.environ.get("CONF_DIR", pl.default_conf_dir(str(WORK_DIR))))
PROVIDER_DIR = CONF_DIR / "providers"
LOG_DIR = Path(os.environ.get("LOG_DIR", str(WORK_DIR / "logs")))
# 可被 deploy.sh 部署的 bin 脚本路径(服务器上)
ADD_SOURCE = str(WORK_DIR / "bin" / "add-source.sh")
......@@ -46,7 +48,8 @@ DIGEST_SCRIPT = str(WORK_DIR / "bin" / "daily-digest.sh")
# ── 平台适配 ──────────────────────────────────────────────
# macOS 上调度走 launchd(标签 com.newsharvester.<source>),无 systemctl/sudo -u。
IS_DARWIN = sys.platform == "darwin"
# NH_OS 环境变量可覆盖检测(与 platform.sh 同规则,测试模拟另一平台用)。
IS_DARWIN = os.environ.get("NH_OS", "darwin" if sys.platform == "darwin" else "linux") == "darwin"
LAUNCHD_GUI = f"gui/{os.getuid()}"
# ── Flask 应用 ─────────────────────────────────────────────
......@@ -151,14 +154,31 @@ def require_auth(view):
# ── 数据读取辅助函数 ───────────────────────────────────────
def get_timer_files():
"""扫描所有 news-harvester@*.timer 文件(不含 digest)"""
return sorted(SYSTEMD_DIR.glob("news-harvester@*.timer"))
"""扫描所有调度定义文件(darwin: plist;linux: timer),不含 digest"""
files = []
for name, _state in pl.list_schedules().items():
active, _disabled = pl.schedule_files(name)
if active.exists():
files.append(active)
return sorted(files, key=lambda f: f.name)
def source_name_of(path):
"""从调度文件名提取源名(timer/plist/disabled 通用)。"""
n = path.name
if n.endswith(".disabled"):
n = n[: -len(".disabled")]
if n.endswith(".plist"):
return n[len("com.newsharvester."):-len(".plist")]
if n.startswith("news-harvester@") and n.endswith(".timer"):
return n[len("news-harvester@"):-len(".timer")]
return n
def parse_timer_file(timer_path):
"""从 timer 文件及实例 drop-in 读取全部日运行时间。"""
"""从调度文件(timer/plist)读取全部日运行时间。"""
try:
return ",".join(read_timer_times(timer_path)) or "-"
return ",".join(read_schedule_times(timer_path)) or "-"
except OSError:
return "-"
......@@ -171,12 +191,16 @@ def valid_run_times(value):
return False
def read_oncalendar_from_systemd(timer_name):
"""从 systemctl show 读取实际生效的 OnCalendar(含 drop-in 覆盖)。
def _source_from_timer_name(timer_name):
"""news-harvester@x.timer / news-harvester-digest.timer → x / digest"""
n = timer_name.replace("news-harvester@", "").removesuffix(".timer")
return "digest" if n == "news-harvester-digest" else n
先读直接属性 OnCalendar;多数版本该属性输出为空,再从
TimersCalendar={ OnCalendar=*-*-* 22:00:00 ; next_elapse=... CST } 中提取。
秒可以省略,多个表达式按时间排序去重。"""
def read_oncalendar_from_systemd(timer_name):
"""读取实际生效的运行时间:dawrin 直接读调度文件;linux 走 systemctl show(含 drop-in 覆盖)。"""
if IS_DARWIN:
return pl.schedule_times(_source_from_timer_name(timer_name)) or None
try:
out = subprocess.run(
["systemctl", "show", timer_name, "-p", "OnCalendar", "-p", "TimersCalendar"],
......@@ -196,8 +220,24 @@ def read_oncalendar_from_systemd(timer_name):
def get_systemd_props(timer_name):
"""获取 timer 的 systemd 属性:is-active / NextElapse / LastTrigger"""
"""获取调度属性:is-active / 下次触发 / 上次触发(跨平台)"""
props = {"active": "unknown", "next": "-", "last": "从未触发"}
if IS_DARWIN:
src = _source_from_timer_name(timer_name)
times = pl.schedule_times(src)
state_map = {"running": "active", "idle": "inactive"}
props["active"] = state_map.get(pl.service_state(src), "unknown")
if times:
props["next"] = pl.next_fire(times)
if src != "digest":
props["last"] = pl.last_run_at(src, LOG_DIR)
else:
import glob as _glob
mds = sorted(_glob.glob(str(LOG_DIR / "digest.*.md")))
if mds:
props["last"] = datetime.fromtimestamp(
Path(mds[-1]).stat().st_mtime).strftime("%Y-%m-%d %H:%M")
return props
try:
result = subprocess.run(
["systemctl", "is-active", timer_name],
......@@ -448,11 +488,10 @@ def read_provider(name):
def get_source_list():
"""获取所有 source 的完整信息列表"""
sources = []
rows = {r["name"]: r for r in pl.source_rows(LOG_DIR)}
for timer_path in get_timer_files():
# 从文件名提取 source 名
name = timer_path.stem # news-harvester@bloomberg-technology → 去掉 .timer
source = name.split("@", 1)[1] if "@" in name else name
timer_name = f"news-harvester@{source}.timer"
source = source_name_of(timer_path)
timer_name = timer_path.name
# 运行时间:优先 systemctl show,回退读文件
run_time = read_oncalendar_from_systemd(timer_name) or parse_timer_file(timer_path)
......@@ -477,6 +516,7 @@ def get_source_list():
else:
status_class = "unknown"
row = rows.get(source, {})
sources.append({
"source": source,
"timer_name": timer_name,
......@@ -486,6 +526,8 @@ def get_source_list():
"last": props["last"],
"log": log_info,
"status_class": status_class,
"skill_enabled": row.get("skill_enabled", False),
"toggle_on": row.get("toggle_on", False),
"provider": provider,
"fallback_provider": fallback_provider,
})
......@@ -508,28 +550,8 @@ def run_script(cmd, timeout=30):
def _active_harvests():
"""返回正在运行的采集源名列表(并发保护用)。
macOS 查 launchd agent 状态;Linux 查 systemd service is-active。
单个源查询失败视为未运行,不影响页面。"""
names = []
for timer in get_timer_files():
name = timer.name[len("news-harvester@"):-len(".timer")]
if IS_DARWIN:
rc, out, _ = run_script(
["launchctl", "print", f"{LAUNCHD_GUI}/com.newsharvester.{name}"],
timeout=5,
)
if rc == 0 and "state = running" in out:
names.append(name)
else:
rc, out, _ = run_script(
["systemctl", "is-active", f"news-harvester@{name}.service"],
timeout=5,
)
if rc == 0 and out.strip() == "active":
names.append(name)
return names
"""返回正在运行的采集源名列表(并发保护用;跨平台)。"""
return pl.active_harvests()
# ── 路由 ───────────────────────────────────────────────────
......@@ -538,6 +560,19 @@ def _active_harvests():
@require_auth
def index():
sources = get_source_list()
# 合并已安装但未建调度的技能源(可通过开关开启,开启时自动错峰创建调度)
scheduled = {s["source"] for s in sources}
for row in pl.source_rows(LOG_DIR):
n = row["name"]
if n not in scheduled:
sources.append({
"source": n, "timer_name": "—", "run_time": "-",
"active": "unknown", "next": "-", "last": "从未触发",
"log": None, "status_class": "unknown",
"skill_enabled": row["skill_enabled"], "toggle_on": row["toggle_on"],
"provider": "", "fallback_provider": "",
})
sources.sort(key=lambda s: s["source"])
# digest timer 状态
digest_props = get_systemd_props("news-harvester-digest.timer")
digest_run_time = (read_oncalendar_from_systemd("news-harvester-digest.timer")
......@@ -577,11 +612,11 @@ def index():
def source_detail(name):
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
abort(404)
timer_path = SYSTEMD_DIR / f"news-harvester@{name}.timer"
if not timer_path.exists():
timer_path, _disabled_path = pl.schedule_files(name)
if not timer_path.exists() and not _disabled_path.exists():
abort(404)
timer_name = f"news-harvester@{name}.timer"
timer_name = timer_path.name
run_time = read_oncalendar_from_systemd(timer_name) or parse_timer_file(timer_path)
props = get_systemd_props(timer_name)
log_info = parse_last_log(name)
......@@ -786,11 +821,7 @@ def run_source(name):
"error",
)
return redirect(url_for("source_detail", name=name))
if IS_DARWIN:
trigger = ["launchctl", "kickstart", f"{LAUNCHD_GUI}/com.newsharvester.{name}"]
else:
trigger = ["systemctl", "start", f"news-harvester@{name}.service"]
rc, stdout, stderr = run_script(trigger, timeout=10)
rc, stdout, stderr = pl.service_trigger(name)
if rc == 0:
flash(f"已触发 {name} 抓取", "success")
else:
......@@ -899,26 +930,39 @@ def continue_source(name):
@app.route("/disable/<name>", methods=["POST"])
@require_auth
def disable_source(name):
"""停用栏目:stop + disable timer,清空 OnCalendar 使其不再触发。
不删除 timer/conf 文件,随时可以重新编辑启用。"""
"""停用栏目:调度文件级禁用(重启不复活)+ 技能源停用。
文件保留为 *.disabled,随时可通过编辑/开关重新启用。"""
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
abort(404)
timer_name = f"news-harvester@{name}.timer"
timer_path = SYSTEMD_DIR / f"news-harvester@{name}.timer"
messages = []
# 停用 timer
run_script(["systemctl", "stop", timer_name], timeout=10)
run_script(["systemctl", "disable", timer_name], timeout=10)
messages.append(f"已停用 {timer_name}")
# 清空 OnCalendar(改为永不触发),保留文件
if timer_path.exists():
update_timer(timer_path, [])
messages.append(f"已清空全部运行时间(含实例覆盖配置)")
run_script(["systemctl", "daemon-reload"], timeout=10)
flash(f"栏目 {name} 已停用\n" + "\n".join(messages) + "\n\n文件保留,可通过编辑重新设置时间来启用", "success")
steps = pl.toggle(name, False)
messages = [f"{label} {'✓' if rc == 0 else '失败: ' + (err or out)}" for label, (rc, out, err) in steps]
flash(f"栏目 {name} 已停用\n" + "\n".join(messages) + "\n\n调度文件保留为 .disabled,可通过开关重新启用", "success")
return redirect(url_for("source_detail", name=name))
@app.route("/toggle/<name>", methods=["POST"])
@require_auth
def toggle_source(name):
"""采集总开关:ON=技能启用+调度加载(无调度则自动错峰创建);OFF=调度文件级禁用+技能停用。"""
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
abort(404)
on = request.form.get("on", "1") == "1"
if on and _active_harvests():
flash(f"已有采集任务在运行({('、'.join(_active_harvests()))}),请等其完成后再开启。", "error")
return redirect(url_for("index"))
steps = pl.toggle(name, on)
messages, all_ok = [], True
for label, (rc, out, err) in steps:
if rc == 0:
messages.append(f"{label} ✓")
else:
all_ok = False
messages.append(f"{label} 失败: {err or out}")
flash(f"{'开启' if on else '关闭'}采集 {name}\n" + "\n".join(messages),
"success" if all_ok else "error")
return redirect(url_for("index"))
# ── Provider 管理路由 ──────────────────────────────────────
@app.route("/providers")
......
......@@ -4,6 +4,12 @@
{% if auto_refresh %}
<meta http-equiv="refresh" content="10">
{% endif %}
<style>
.switch{width:44px;height:24px;border-radius:12px;border:1px solid #2a2d37;background:#3a3d47;position:relative;cursor:pointer;padding:0;transition:background .15s}
.switch.on{background:#2ea043}
.switch .knob{position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;background:#d4d4d4;transition:left .15s;display:block}
.switch.on .knob{left:22px}
</style>
{% endblock %}
{% block content %}
......@@ -30,6 +36,7 @@
<table>
<thead>
<tr>
<th>采集</th>
<th>栏目</th>
<th>状态</th>
<th>运行时间</th>
......@@ -44,6 +51,16 @@
<tbody>
{% for s in sources %}
<tr class="{{ s.status_class }}">
<td>
<form method="POST" action="{{ url_for('toggle_source', name=s.source) }}"
{% if s.toggle_on %}onsubmit="return confirm('关闭 {{ s.source }} 的自动采集?(调度文件保留,可随时再开)')"{% endif %}>
<input type="hidden" name="on" value="{{ '0' if s.toggle_on else '1' }}">
<button type="submit" class="switch {{ 'on' if s.toggle_on else '' }}"
title="{{ '采集开启中,点击关闭' if s.toggle_on else '采集已关闭/未启用,点击开启(无调度时自动错峰创建)' }}">
<span class="knob"></span>
</button>
</form>
</td>
<td>
<a class="source-link" href="{{ url_for('source_detail', name=s.source) }}">{{ s.source }}</a>
</td>
......@@ -122,7 +139,7 @@
</thead>
<tbody>
<tr>
<td><code>news-harvester-digest.timer</code></td>
<td><code>每日汇总</code></td>
<td>
{% if digest_props.active == 'active' %}
<span class="badge active">active</span>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment