diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..9e145b3 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "sproutclaw-cron": { + "command": "python", + "args": ["mcp-server/server.py"], + "env": { + "SPROUTCLAW_CRON_ROOT": "${workspaceFolder}" + } + } + } +} diff --git a/.cursor/skills/sproutclaw-cron/SKILL.md b/.cursor/skills/sproutclaw-cron/SKILL.md new file mode 100644 index 0000000..60e3d81 --- /dev/null +++ b/.cursor/skills/sproutclaw-cron/SKILL.md @@ -0,0 +1,88 @@ +--- +name: sproutclaw-cron +description: >- + Manage SproutClaw Cron scheduled tasks: list/enable/disable/run tasks, read logs, + update schedules, create tasks from templates. Use when the user mentions cronctl, + sproutclaw-cron, scheduled tasks, cron jobs, task enable/disable, or creating + new cron tasks. Prefer MCP tools (cron_*) when available; fall back to cronctl.py CLI. +--- + +# SproutClaw Cron 定时任务技能 + +## 何时启用 + +用户提到:`cronctl`、定时任务、开关任务、试跑任务、新建 cron 任务、`sproutclaw-cron`、WebUI 管理面板,或需要查看任务日志/调度时。 + +## 优先顺序 + +1. **MCP 工具**(已配置 `.cursor/mcp.json`):`cron_list_tasks`、`cron_get_task`、`cron_run_task` 等 +2. **CLI 兜底**:`python cronctl.py [task-id]` +3. **WebUI**:`start-webui.bat`(Windows)或 `webui/start.sh`(Linux) + +## 项目路径 + +- 根目录:仓库根(`cronctl.py` 所在目录) +- 可通过环境变量 `SPROUTCLAW_CRON_ROOT` 覆盖 +- Linux 生产路径示例:`/shumengya/project/agent/sproutclaw-cron` + +## MCP 工具速查 + +| 场景 | 工具 | +|------|------| +| 看有哪些任务 | `cron_list_tasks` | +| 看单个任务详情 | `cron_get_task` | +| 开/关/切换 | `cron_enable_task` / `cron_disable_task` / `cron_toggle_task` | +| 试跑 | `cron_run_task` → `cron_get_log` | +| 改调度 | `cron_update_schedule` | +| 新建任务 | `cron_list_templates` → `cron_create_task` | +| 同步系统 cron | `cron_sync_cron` | + +## 新建任务流程 + +1. 确认 `task_id` 命名:`<主机名>-<功能描述>`(如 `smallmengya-gitea-repo-sync`) +2. `cron_create_task(task_id=..., runtime="python")` — **默认禁用** +3. 编辑 `/run.py`(或对应入口)与 `schedule.cron` +4. `cron_run_task` 试跑,`cron_get_log` 确认 +5. 用户明确要求后再 `cron_enable_task` + +模板与约定详见根目录 [AGENTS.md](../../AGENTS.md)。 + +## CLI 兜底命令 + +```bash +python cronctl.py status +python cronctl.py status +python cronctl.py enable|disable|toggle +python cronctl.py run +python cronctl.py sync-cron +python cronctl.py enable all +``` + +Windows 将 `python` 换为实际 Python 路径;Linux 可用 `python3`。 + +## Agent 原则 + +- **默认新建任务保持禁用**,除非用户明确要求启用 +- **不要修改** `_template*` 示例目录(除非用户要求) +- **schedule.cron** 调度入口保持 `cronctl.py run ` 形式 +- Python 任务保留 `task_is_disabled` / `task_logging` / `acquire_cron_lock` 结构 +- 非 Python 任务只改入口脚本;锁/日志/disable 由 `cronctl run` 统一处理 +- 改完业务逻辑后先 `run` 再 `enable` + +## 任务目录结构 + +``` +/ +├── task.json # 可选:runtime + entry + tags +├── run.py|run.js|run.sh|run.ps1 +├── schedule.cron +├── logs/.log +└── *.json # 可选配置 +``` + +禁用任务位于 `.disabled//`。 + +## 附加资源 + +- CLI 与 API 详情:[reference.md](reference.md) +- 环境检查脚本:[scripts/check_cron.py](scripts/check_cron.py) diff --git a/.cursor/skills/sproutclaw-cron/reference.md b/.cursor/skills/sproutclaw-cron/reference.md new file mode 100644 index 0000000..4426eb9 --- /dev/null +++ b/.cursor/skills/sproutclaw-cron/reference.md @@ -0,0 +1,83 @@ +# SproutClaw Cron 参考 + +## cronctl.py 子命令 + +| 子命令 | 说明 | 示例 | +|--------|------|------| +| `status` | 列出或查看任务状态 | `cronctl.py status` | +| `enable` | 启用并同步 cron | `cronctl.py enable my-task` | +| `disable` | 禁用(移入 `.disabled/`) | `cronctl.py disable my-task` | +| `toggle` | 切换状态 | `cronctl.py toggle my-task` | +| `run` | 同步执行一次 | `cronctl.py run my-task` | +| `sync-cron` | 仅同步 cron.d / schtasks | `cronctl.py sync-cron my-task` | + +别名:`on` → `enable`,`off` → `disable`。批量操作传 `all`。 + +## task.json + +```json +{ + "runtime": "python", + "entry": "run.py", + "tags": ["标签1", "标签2"] +} +``` + +`runtime`:`python` | `javascript` | `bash` | `powershell` + +## schedule.cron 格式 + +- 首行注释为任务描述(WebUI / MCP 会读取) +- cron 五段 + 用户 + 命令 +- 命令应调用:`python3 /cronctl.py run ` + +示例: + +```cron +# 每天 08:00 同步仓库 +0 8 * * * root /usr/bin/python3 /path/to/sproutclaw-cron/cronctl.py run my-task >/dev/null 2>&1 +``` + +## WebUI API(FastAPI,端口见 start 脚本) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/tasks` | 任务列表 | +| GET | `/api/tasks/{id}` | 任务详情 | +| POST | `/api/tasks/{id}/enable` | 启用 | +| POST | `/api/tasks/{id}/disable` | 禁用 | +| POST | `/api/tasks/{id}/run` | 后台试跑 | +| GET | `/api/tasks/{id}/log?lines=200` | 日志 | +| PATCH | `/api/tasks/{id}/schedule` | 更新调度 | + +## 语言模板 + +| 模板目录 | runtime | 入口 | +|----------|---------|------| +| `_template` | python | `run.py` | +| `_template-javascript` | javascript | `run.js` | +| `_template-bash` | bash | `run.sh` | +| `_template-powershell` | powershell | `run.ps1` | + +## 公共库(Python 任务) + +从 `lib/runner.py` 导入: + +- `TaskContext.from_task_id(task_id)` +- `task_is_disabled(ctx)` / `task_logging(ctx)` / `acquire_cron_lock(...)` + +从 `lib/notify.py` 导入: + +- `TaskResult`、`send_task_summary(...)` + +## 开关机制 + +- 启用:`.disabled//` → `/` +- 禁用:`/` → `.disabled//` +- 禁用后 cron 仍可触发,但 `cronctl run` 会直接跳过 + +## MCP 服务器 + +- 入口:`mcp-server/server.py` +- 配置:`.cursor/mcp.json` +- 依赖:`pip install -r mcp-server/requirements.txt` diff --git a/.cursor/skills/sproutclaw-cron/scripts/check_cron.py b/.cursor/skills/sproutclaw-cron/scripts/check_cron.py new file mode 100644 index 0000000..c14b6bc --- /dev/null +++ b/.cursor/skills/sproutclaw-cron/scripts/check_cron.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""SproutClaw Cron 环境检查(跨平台,仅标准库 + 可选 fastmcp)。""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + root = Path(__file__).resolve().parents[4] + lib = root / "lib" + cronctl = root / "cronctl.py" + mcp_server = root / "mcp-server" / "server.py" + + checks: list[dict[str, object]] = [] + + def add(name: str, ok: bool, detail: str = "") -> None: + checks.append({"name": name, "ok": ok, "detail": detail}) + + add("cron_root", root.is_dir(), str(root)) + add("cronctl.py", cronctl.is_file(), str(cronctl)) + add("lib/", lib.is_dir(), str(lib)) + add("mcp-server/server.py", mcp_server.is_file(), str(mcp_server)) + + try: + import fastmcp # noqa: F401 + + add("fastmcp", True, "已安装") + except ImportError: + add("fastmcp", False, "pip install -r mcp-server/requirements.txt") + + if cronctl.is_file(): + proc = subprocess.run( + [sys.executable, str(cronctl), "status"], + cwd=str(root), + capture_output=True, + text=True, + timeout=30, + ) + add( + "cronctl status", + proc.returncode == 0, + (proc.stdout or proc.stderr).strip()[:500], + ) + + ok_all = all(c["ok"] for c in checks if c["name"] != "fastmcp") + print(json.dumps({"ok": ok_all, "checks": checks}, ensure_ascii=False, indent=2)) + return 0 if ok_all else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.gitignore b/.gitignore index e4c3bbe..1c0f797 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ logs/ *.lock *.log +# 内置邮件 API 凭据(本地 .env 不上传) +lib/vendor/mengya-mail-api/.env +lib/vendor/mengya-mail-api/.env.* + # 禁用任务目录(含本机专属脚本/凭据,不上传) .disabled/ @@ -27,3 +31,6 @@ smallmengya-gitea-repo-sync/*.lock .vscode/ *.swp .DS_Store + +# MCP 本地虚拟环境 +mcp-server/.venv/ diff --git a/AGENTS.md b/AGENTS.md index 480f90e..70793cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,37 +2,60 @@ ## 新建定时任务 -**必须**从 `_template/` 复制(`_template` 本身是可运行的 hello world 示例任务)。 +**默认从 `_template/`(Python)复制**;也可选用其他语言模板: + +| 模板目录 | 语言 | 入口 | +|---|---|---| +| `_template` | Python(默认) | `run.py` | +| `_template-javascript` | JavaScript | `run.js` | +| `_template-bash` | Bash | `run.sh` | +| `_template-powershell` | PowerShell | `run.ps1` | ```bash TASK_ID="<主机名>-<功能描述>" CRON_ROOT="/shumengya/project/agent/sproutclaw-cron" -cp -a "$CRON_ROOT/_template" "$CRON_ROOT/$TASK_ID" -sed -i "s/_template/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron" -chmod +x "$CRON_ROOT/$TASK_ID/switch.sh" +TEMPLATE="_template" # 或 _template-javascript / _template-bash / _template-powershell + +cp -a "$CRON_ROOT/$TEMPLATE" "$CRON_ROOT/$TASK_ID" +sed -i "s/$TEMPLATE/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron" +# Bash 模板额外:chmod +x "$CRON_ROOT/$TASK_ID/run.sh" ``` 然后: -1. 修改 `run.py`:保留 `task_is_disabled` / `task_logging` / `acquire_cron_lock` 结构,替换 `hello world` 为实际逻辑 -2. 修改 `schedule.cron`:调整 cron 表达式与注释说明 -3. 可选:添加任务专属 JSON 配置(如 `targets.json`) -4. 用 `python3 cronctl.py run ` 试跑,确认日志正常 -5. 默认保持关闭;用户要求启用时再 `cronctl enable ` +1. **Python**:修改 `run.py`,保留 `task_is_disabled` / `task_logging` / `acquire_cron_lock` 结构 +2. **其他语言**:只改入口脚本(`run.js` / `run.sh` / `run.ps1`);disable / 锁 / 日志由 `cronctl run` 统一处理 +3. 修改 `schedule.cron`:调整 cron 表达式与注释说明 +4. 可选:任务专属 JSON 配置(如 `targets.json`) +5. 用 `python3 cronctl.py run ` 试跑,确认日志正常 +6. 默认保持关闭;用户要求启用时再 `cronctl enable ` + +## task.json + +可选清单,声明运行时与入口(无则按入口文件自动推断): + +```json +{ + "runtime": "python", + "entry": "run.py" +} +``` + +`runtime` 取值:`python` | `javascript` | `bash` | `powershell` ## 不要修改 -- `_template/` 是 hello world 示例任务,也是新任务复制源;除非用户要求,不要改其示例行为 +- `_template*` 是 hello world 示例任务,也是新任务复制源;除非用户要求,不要改其示例行为 - 不要删除或移动已有任务的 `schedule.cron` 里对 `cronctl.py run` 的调用方式 ## 任务目录约定 ``` / -├── run.py +├── task.json # 可选:runtime + entry +├── run.py|run.js|run.sh|run.ps1 ├── schedule.cron -├── switch.sh # 可选 -├── logs/.log # 运行时自动创建 +├── logs/.log └── *.json # 可选配置 ``` @@ -46,4 +69,10 @@ python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable ``` -WebUI:`/shumengya/project/agent/sproutclaw-cron/webui/start.sh` +WebUI:`/shumengya/project/agent/sproutclaw-cron/webui/start.sh` 或 Windows 下 `start-webui.bat` + +## AI Agent 集成 + +- **Skill**:`.cursor/skills/sproutclaw-cron/SKILL.md` — 操作规范与 workflow +- **MCP**:`.cursor/mcp.json` 注册 `sproutclaw-cron` 服务器;工具前缀 `cron_*` +- MCP 依赖:`pip install -r mcp-server/requirements.txt` diff --git a/README.md b/README.md index 8dea896..70358ec 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,21 @@ /shumengya/project/agent/sproutclaw-cron/ ├── cronctl.py # 统一管理 CLI(enable/disable/toggle/status) ├── AGENTS.md # AI 新建任务规范(必读) -├── _template/ # 示例任务 + 复制模板(每天 08:00 hello world) +├── _template/ # Python 示例 + 默认复制模板 +├── _template-javascript/ # JavaScript (Node.js) 示例 +├── _template-bash/ # Bash 示例 +├── _template-powershell/ # PowerShell 示例 ├── lib/ │ └── shumengya_cron/ -│ ├── runner.py # TaskContext、日志轮转、flock 互斥锁 +│ ├── runner.py # TaskContext、日志轮转、互斥锁 +│ ├── task_manifest.py # task.json 解析、runtime 推断 +│ ├── external_runner.py # 非 Python 任务 subprocess 启动 │ ├── notify.py # 飞书 Markdown 通知 + 邮件降级 │ └── ssh.py # 远端 SSH 辅助(bash -lc) ├── / # 任务目录(启用状态) -│ ├── run.py # 任务入口(唯一必须文件) +│ ├── task.json # 可选:runtime + entry +│ ├── run.py / run.js / run.sh / run.ps1 │ ├── schedule.cron # 系统 cron 配置(复制到 /etc/cron.d/) -│ ├── switch.sh # 一键开关(可用 cronctl 替代) │ ├── targets.json # 可选:任务自定义配置(JSON) │ ├── logs/.log # 运行日志(自动创建) │ └── .lock # flock 互斥锁文件(自动创建) @@ -31,19 +36,6 @@ --- -## 已有任务 - -| 任务 ID | 状态 | 说明 | -|---|---|---| -| `_template` | 开启 | 示例任务:每天 08:00 输出 hello world,也是新任务复制模板 | -| `bigmengya-docker-container-restart` | disabled | SSH 重启 bigmengya 上的数据库类容器 | -| `bigmengya-docker-image-update` | disabled | SSH 拉取并重建 bigmengya 上的 compose 服务 | -| `smallmengya-ai-cli-update` | disabled | 更新本机 codex / claude / opencode | -| `smallmengya-ai-memory-export` | disabled | 导出 AI 记忆数据 | -| `smallmengya-gitea-repo-sync` | 开启 | 同步 Gitea 仓库到本地 | - ---- - ## 快速上手 ### 查看状态 @@ -65,32 +57,66 @@ python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py disable all # # 推荐:通过 cronctl(自动识别 .disabled/ 下的任务) python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run <任务名> -# 也可直接运行(启用或 .disabled/<任务名> 目录均可) +# 也可直接运行 Python 任务(启用或 .disabled/<任务名> 目录均可) python3 /shumengya/project/agent/sproutclaw-cron/.disabled/<任务名>/run.py +# 其他语言任务请始终用 cronctl run ``` ### 安装到系统 cron -`cronctl enable` 会将 `schedule.cron` 复制到 `/etc/cron.d/`;`disable` **只**把任务目录移入 `.disabled//`,**不会**删除 `/etc/cron.d/` 条目。cron 到点仍会触发,但 `run.py` 检测到禁用后会直接跳过。 +**Linux**:`cronctl enable` 会将 `schedule.cron` 复制到 `/etc/cron.d/`;`disable` **只**把任务目录移入 `.disabled//`,**不会**删除 `/etc/cron.d/` 条目。cron 到点仍会触发,但禁用后任务会自动跳过。 ```bash python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py sync-cron all # 仅同步 cron.d,不改开关 ``` +**Windows**:`enable` / `sync-cron` 会通过 `schtasks` 注册任务计划(任务名 `sproutclaw-`)。目前自动转换 `分 时 * * *` 形式的 cron 表达式为「每天 HH:MM」;复杂表达式需手动创建计划任务。 + +```powershell +python D:\SmyProjects\AI\sproutclaw-cron\cronctl.py sync-cron all +python D:\SmyProjects\AI\sproutclaw-cron\cronctl.py enable <任务名> +``` + +开关机制与 Linux 相同:禁用后目录移入 `.disabled/`,计划任务仍会触发,但任务会自动跳过。 + --- ## 开关机制 - **启用**:从 `.disabled//` 移回 `/` - **关闭**:从 `/` 移入 `.disabled//` -- `run.py` 在入口检查 `task_is_disabled(ctx)`,若关闭直接 `return 0`,不写日志、不发通知、不获取锁 +- 禁用时 `cronctl run` 会直接跳过,不写日志、不获取锁 - `/etc/cron.d/` 可一直保留;`schedule.cron` 通过 `cronctl.py run ` 调度 --- +## 多语言支持 + +调度入口统一为 `cronctl.py run`(Python)。业务脚本语言由 `task.json` 或入口文件决定: + +| runtime | 入口 | 依赖 | +|---|---|---| +| `python`(默认) | `run.py` | Python 3 | +| `javascript` | `run.js` | Node.js | +| `bash` | `run.sh` | bash | +| `powershell` | `run.ps1` | pwsh / powershell | + +### task.json + +```json +{ + "runtime": "python", + "entry": "run.py" +} +``` + +无 `task.json` 时:有 `run.py` 视为 Python 任务(向后兼容)。 + +--- + ## 新建任务(模板) -**从 `_template/` 复制**。`_template` 本身是可管理的示例任务(每天 08:00 输出 hello world)。 +**默认从 `_template/`(Python)复制**;JavaScript / Bash / PowerShell 见 `_template-javascript` 等目录。 ```bash TASK_ID="smallmengya-my-new-task" @@ -98,7 +124,6 @@ CRON_ROOT="/shumengya/project/agent/sproutclaw-cron" cp -a "$CRON_ROOT/_template" "$CRON_ROOT/$TASK_ID" sed -i "s/_template/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron" -chmod +x "$CRON_ROOT/$TASK_ID/switch.sh" # 编辑 run.py 与 schedule.cron 后试跑 python3 "$CRON_ROOT/cronctl.py" run "$TASK_ID" @@ -107,6 +132,12 @@ python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID" 详细约定见 `_template/README.md`;AI 代理见根目录 `AGENTS.md`。 +### AI Agent 集成 + +- **Skill**:`.cursor/skills/sproutclaw-cron/` — Cursor Agent 自动识别定时任务操作规范 +- **MCP**:`.cursor/mcp.json` + `mcp-server/server.py` — 暴露 `cron_list_tasks`、`cron_run_task` 等工具 +- 首次使用 MCP:`pip install -r mcp-server/requirements.txt`,然后在 Cursor 设置中确认 MCP 服务器已启用 + --- ## 公共库 API @@ -204,9 +235,6 @@ python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID" 每个 `run.py` 顶部都有 3 行 `sys.path.insert` 代码,是为了支持 `schedule.cron` 直接调用 `python3 run.py`。 若将 `schedule.cron` 改为调用 `cronctl run `,则 `run.py` 里的路径注入可以全部删掉。 -### 2. `switch.sh` 冗余 -每个任务目录都有一份 `switch.sh`,其内部已经是代理调用 `cronctl.py`,新任务可以不再创建它,直接用 `cronctl enable/disable`。 - --- ## 任务配置文件格式(JSON) @@ -246,7 +274,7 @@ python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID" ## 依赖说明 -这套系统**只依赖 Python 3 标准库**(`fcntl`、`subprocess`、`pathlib` 等),无需安装任何 PyPI 包。 +这套系统**只依赖 Python 3 标准库**(`subprocess`、`pathlib` 等),无需安装任何 PyPI 包。文件锁在 Linux 用 `fcntl`,Windows 用 `msvcrt`;定时调度在 Linux 用 `/etc/cron.d/`,Windows 用任务计划程序(`schtasks`)。 通知功能依赖两个本地项目(非 PyPI 依赖,可缺失时降级): - `lark-notice-api`:飞书通知,路径 `/shumengya/project/python/lark-notice-api/` diff --git a/_template-bash/README.md b/_template-bash/README.md new file mode 100644 index 0000000..5af53d5 --- /dev/null +++ b/_template-bash/README.md @@ -0,0 +1,22 @@ +# Bash 定时任务模板 + +Bash hello world 示例。业务逻辑写在 `run.sh`;disable / 锁 / 日志由 cronctl 负责。 + +## 依赖 + +- Python 3(cronctl) +- Bash(Linux 自带;Windows 需 Git Bash 或 WSL) + +## 新建任务 + +```bash +TASK_ID="smallmengya-my-bash-task" +CRON_ROOT="/shumengya/project/agent/sproutclaw-cron" +cp -a "$CRON_ROOT/_template-bash" "$CRON_ROOT/$TASK_ID" +sed -i "s/_template-bash/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron" +chmod +x "$CRON_ROOT/$TASK_ID/run.sh" +``` + +## 其他语言模板 + +见 `_template-javascript/README.md` 中的对照表。 diff --git a/_template-bash/run.sh b/_template-bash/run.sh new file mode 100644 index 0000000..1a2a680 --- /dev/null +++ b/_template-bash/run.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo "hello world" diff --git a/_template-bash/schedule.cron b/_template-bash/schedule.cron new file mode 100644 index 0000000..291d1c1 --- /dev/null +++ b/_template-bash/schedule.cron @@ -0,0 +1,8 @@ +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +HOME=/root +CRON_MAIL_ENABLED=1 + +# 每天 08:00 Bash hello world 示例 +# 开关:python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable|disable|toggle _template-bash +0 8 * * * root /usr/bin/python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run _template-bash >/dev/null 2>&1 diff --git a/_template-bash/task.json b/_template-bash/task.json new file mode 100644 index 0000000..1a99810 --- /dev/null +++ b/_template-bash/task.json @@ -0,0 +1,8 @@ +{ + "runtime": "bash", + "entry": "run.sh", + "tags": [ + "Bash", + "模板" + ] +} diff --git a/_template-javascript/README.md b/_template-javascript/README.md new file mode 100644 index 0000000..d9c7619 --- /dev/null +++ b/_template-javascript/README.md @@ -0,0 +1,32 @@ +# JavaScript 定时任务模板 + +Node.js hello world 示例。调度仍由 `cronctl.py run` 统一管理(disable / 锁 / 日志)。 + +## 依赖 + +- Python 3(cronctl) +- Node.js(`node` 在 PATH 中) + +## 新建任务 + +```bash +TASK_ID="smallmengya-my-js-task" +CRON_ROOT="/shumengya/project/agent/sproutclaw-cron" +cp -a "$CRON_ROOT/_template-javascript" "$CRON_ROOT/$TASK_ID" +sed -i "s/_template-javascript/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron" +``` + +编辑 `run.js` 与 `schedule.cron` 后试跑: + +```bash +python3 "$CRON_ROOT/cronctl.py" run "$TASK_ID" +``` + +## 其他语言模板 + +| 目录 | 语言 | +|---|---| +| `_template` | Python(默认) | +| `_template-javascript` | JavaScript | +| `_template-bash` | Bash | +| `_template-powershell` | PowerShell | diff --git a/_template-javascript/run.js b/_template-javascript/run.js new file mode 100644 index 0000000..6b2b3db --- /dev/null +++ b/_template-javascript/run.js @@ -0,0 +1 @@ +console.log("hello world"); diff --git a/_template-javascript/schedule.cron b/_template-javascript/schedule.cron new file mode 100644 index 0000000..33160b8 --- /dev/null +++ b/_template-javascript/schedule.cron @@ -0,0 +1,8 @@ +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +HOME=/root +CRON_MAIL_ENABLED=1 + +# 每天 08:00 JavaScript hello world 示例(Node.js) +# 开关:python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable|disable|toggle _template-javascript +0 8 * * * root /usr/bin/python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run _template-javascript >/dev/null 2>&1 diff --git a/_template-javascript/task.json b/_template-javascript/task.json new file mode 100644 index 0000000..aa4aa7f --- /dev/null +++ b/_template-javascript/task.json @@ -0,0 +1,8 @@ +{ + "runtime": "javascript", + "entry": "run.js", + "tags": [ + "JavaScript", + "模板" + ] +} diff --git a/_template-powershell/README.md b/_template-powershell/README.md new file mode 100644 index 0000000..6313e19 --- /dev/null +++ b/_template-powershell/README.md @@ -0,0 +1,27 @@ +# PowerShell 定时任务模板 + +PowerShell hello world 示例。业务逻辑写在 `run.ps1`;disable / 锁 / 日志由 cronctl 负责。 + +## 依赖 + +- Python 3(cronctl) +- PowerShell(`pwsh` 或 Windows 自带 `powershell`) + +## 新建任务 + +```bash +TASK_ID="smallmengya-my-ps-task" +CRON_ROOT="/shumengya/project/agent/sproutclaw-cron" +cp -a "$CRON_ROOT/_template-powershell" "$CRON_ROOT/$TASK_ID" +sed -i "s/_template-powershell/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron" +``` + +Windows 上可直接: + +```powershell +python D:\SmyProjects\AI\sproutclaw-cron\cronctl.py run _template-powershell +``` + +## 其他语言模板 + +见 `_template-javascript/README.md` 中的对照表。 diff --git a/_template-powershell/run.ps1 b/_template-powershell/run.ps1 new file mode 100644 index 0000000..a8e2177 --- /dev/null +++ b/_template-powershell/run.ps1 @@ -0,0 +1,2 @@ +Write-Output "hello world" +exit 0 diff --git a/_template-powershell/schedule.cron b/_template-powershell/schedule.cron new file mode 100644 index 0000000..5ede6d0 --- /dev/null +++ b/_template-powershell/schedule.cron @@ -0,0 +1,8 @@ +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +HOME=/root +CRON_MAIL_ENABLED=1 + +# 每天 08:00 PowerShell hello world 示例 +# 开关:python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable|disable|toggle _template-powershell +0 8 * * * root /usr/bin/python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run _template-powershell >/dev/null 2>&1 diff --git a/_template-powershell/task.json b/_template-powershell/task.json new file mode 100644 index 0000000..dcdb141 --- /dev/null +++ b/_template-powershell/task.json @@ -0,0 +1,8 @@ +{ + "runtime": "powershell", + "entry": "run.ps1", + "tags": [ + "PowerShell", + "模板" + ] +} diff --git a/_template/README.md b/_template/README.md index 2fd6e33..95128d9 100644 --- a/_template/README.md +++ b/_template/README.md @@ -1,7 +1,18 @@ -# 定时任务模板 / 示例任务 +# 定时任务模板 / 示例任务(Python) -`_template` 本身是可管理的真实任务:每天 08:00 输出 `hello world`。 -新建任务时复制本目录,改任务名与业务逻辑即可。 +`_template` 是默认可管理的 Python hello world 任务(每天 08:00)。 +新建 Python 任务时复制本目录,改任务名与业务逻辑即可。 + +## 多语言模板 + +| 目录 | 语言 | 入口 | +|---|---|---| +| `_template` | **Python(默认)** | `run.py` | +| `_template-javascript` | JavaScript | `run.js` | +| `_template-bash` | Bash | `run.sh` | +| `_template-powershell` | PowerShell | `run.ps1` | + +非 Python 任务只需编写入口脚本;disable / 锁 / 日志由 `cronctl run` 负责。 ## 新建步骤 @@ -11,23 +22,22 @@ CRON_ROOT="/shumengya/project/agent/sproutclaw-cron" cp -a "$CRON_ROOT/_template" "$CRON_ROOT/$TASK_ID" sed -i "s/_template/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron" -chmod +x "$CRON_ROOT/$TASK_ID/switch.sh" # 编辑 run.py,替换 hello world 为实际业务逻辑 # 编辑 schedule.cron,调整 cron 表达式与注释 python3 "$CRON_ROOT/cronctl.py" status "$TASK_ID" -python3 "$CRON_ROOT/cronctl.py" run "$TASK_ID" # 手动试跑 -python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID" # 开启并同步 /etc/cron.d/ +python3 "$CRON_ROOT/cronctl.py" run "$TASK_ID" +python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID" ``` ## 必须文件 | 文件 | 说明 | |---|---| -| `run.py` | 任务入口,导出 `run(ctx) -> int` | -| `schedule.cron` | 系统 cron 配置,cron 行须调用 `cronctl.py run ` | -| `switch.sh` | 可选,代理 `cronctl` 开关 | +| `task.json` | 可选;声明 `runtime` 与 `entry` | +| `run.py` | Python 任务入口,导出 `run(ctx) -> int` | +| `schedule.cron` | cron 配置,须调用 `cronctl.py run ` | ## run.py 约定 @@ -35,7 +45,7 @@ python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID" # 开启并同步 /etc/c 2. 使用 `task_logging(ctx)` 写日志到 `logs/.log` 3. 使用 `acquire_cron_lock(ctx.lock_file, log=log)` 防止并发重入 4. 业务逻辑写在 `log("start")` 与 `log("end")` 之间 -5. 需要汇总通知时使用 `shumengya_cron.notify.send_task_summary` +5. 需要汇总通知时使用 `notify.send_task_summary` ## schedule.cron 约定 diff --git a/_template/run.py b/_template/run.py index c44deb5..faa6347 100644 --- a/_template/run.py +++ b/_template/run.py @@ -11,7 +11,7 @@ import sys as _sys, pathlib as _pl _sys.path.insert(0, str(_pl.Path(__file__).resolve().parents[1] / "lib")) del _sys, _pl -from shumengya_cron.runner import ( +from runner import ( TaskContext, acquire_cron_lock, task_is_disabled, diff --git a/_template/switch.sh b/_template/switch.sh deleted file mode 100644 index f5e4224..0000000 --- a/_template/switch.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -task_id="$(basename "$(dirname "$0")")" -action="${1:-toggle}" -shift || true - -exec python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py "$action" "$task_id" "$@" diff --git a/_template/task.json b/_template/task.json new file mode 100644 index 0000000..6bb91b0 --- /dev/null +++ b/_template/task.json @@ -0,0 +1,8 @@ +{ + "runtime": "python", + "entry": "run.py", + "tags": [ + "Python", + "模板" + ] +} diff --git a/cronctl.py b/cronctl.py index e15e250..0d305ab 100755 --- a/cronctl.py +++ b/cronctl.py @@ -9,7 +9,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent / "lib")) -from shumengya_cron.manager import ( +from manager import ( cron_root, get_context, iter_task_ids, @@ -18,7 +18,7 @@ from shumengya_cron.manager import ( set_task_state, sync_cron_d, ) -from shumengya_cron.runner import task_enabled_text, task_is_enabled +from runner import task_enabled_text, task_is_enabled CRON_ROOT = cron_root() @@ -81,7 +81,8 @@ def main(argv: list[str] | None = None) -> int: try: if action == "run": task_rc = run_task(task_id, CRON_ROOT) - print(f"▶ {task_id} (exit={task_rc})") + marker = ">" if sys.platform == "win32" else "▶" + print(f"{marker} {task_id} (exit={task_rc})") if task_rc != 0: rc = task_rc continue diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..c4cf527 --- /dev/null +++ b/lib/__init__.py @@ -0,0 +1,5 @@ +"""shumengya-cron:共用库(日志/锁、多语言调度、飞书通知、SSH 辅助)。""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/lib/cron_platform.py b/lib/cron_platform.py new file mode 100644 index 0000000..8de7367 --- /dev/null +++ b/lib/cron_platform.py @@ -0,0 +1,128 @@ +"""跨平台辅助:文件锁、Windows 任务计划程序同步。""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + + +def is_windows() -> bool: + return sys.platform == "win32" + + +def lock_ex_nb(fd: int) -> None: + """非阻塞独占锁;无法获取时抛出 BlockingIOError。""" + if is_windows(): + import msvcrt + + try: + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + except OSError as exc: + raise BlockingIOError from exc + return + + import fcntl + + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + + +def unlock(fd: int) -> None: + if is_windows(): + import msvcrt + + try: + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + except OSError: + pass + return + + import fcntl + + try: + fcntl.flock(fd, fcntl.LOCK_UN) + except OSError: + pass + + +def windows_task_name(task_id: str) -> str: + return f"sproutclaw-{task_id}" + + +def _cron_to_daily_time(expression: str) -> str | None: + """将 `分 时 * * *` 转为 schtasks 的 HH:MM;不支持则返回 None。""" + parts = expression.split() + if len(parts) != 5: + return None + minute, hour, dom, month, dow = parts + if dom != "*" or month != "*" or dow != "*": + return None + if not minute.isdigit() or not hour.isdigit(): + return None + return f"{int(hour):02d}:{int(minute):02d}" + + +def sync_windows_task( + task_id: str, + cron_root: Path, + schedule_expression: str | None, + *, + enabled: bool, +) -> str | None: + """在 Windows 任务计划程序中注册/更新定时任务。""" + if schedule_expression is None: + return "未找到 cron 表达式,跳过任务计划注册" + + daily_time = _cron_to_daily_time(schedule_expression) + if daily_time is None: + return ( + f"暂不支持 cron 表达式「{schedule_expression}」自动转换," + "请手动创建任务计划或使用 `分 时 * * *` 形式" + ) + + python_exe = Path(sys.executable).resolve() + cronctl = (cron_root / "cronctl.py").resolve() + task_name = windows_task_name(task_id) + run_cmd = f'"{python_exe}" "{cronctl}" run {task_id}' + + query = subprocess.run( + ["schtasks", "/Query", "/TN", task_name], + capture_output=True, + text=True, + ) + exists = query.returncode == 0 + + if exists: + subprocess.run( + ["schtasks", "/Delete", "/TN", task_name, "/F"], + capture_output=True, + text=True, + check=False, + ) + + create = subprocess.run( + [ + "schtasks", + "/Create", + "/TN", + task_name, + "/TR", + run_cmd, + "/SC", + "DAILY", + "/ST", + daily_time, + "/F", + ], + capture_output=True, + text=True, + check=False, + ) + if create.returncode != 0: + err = (create.stderr or create.stdout or "").strip() + return f"任务计划注册失败: {err or '未知错误'}" + + state = "开启" if enabled else "关闭(计划仍触发,任务自动跳过)" + action = "已更新" if exists else "已创建" + return f"{action} Windows 任务「{task_name}」,每天 {daily_time}({state})" diff --git a/lib/external_runner.py b/lib/external_runner.py new file mode 100644 index 0000000..c96612e --- /dev/null +++ b/lib/external_runner.py @@ -0,0 +1,91 @@ +"""非 Python 任务:由 Python 编排 disable / 锁 / 日志,subprocess 执行业务脚本。""" + +from __future__ import annotations + +import os +import shutil +import subprocess + +from runner import ( + TaskContext, + acquire_cron_lock, + append_raw_to_log, + task_is_disabled, + task_logging, +) +from task_manifest import TaskManifest + + +def _resolve_interpreter(runtime: str) -> list[str] | None: + if runtime == "javascript": + node = shutil.which("node") + return [node] if node else None + if runtime == "bash": + bash = shutil.which("bash") + return [bash] if bash else None + if runtime == "powershell": + pwsh = shutil.which("pwsh") + if pwsh: + return [pwsh, "-File"] + ps = shutil.which("powershell") + return [ps, "-File"] if ps else None + return None + + +def build_command(manifest: TaskManifest, ctx: TaskContext) -> list[str]: + interpreter = _resolve_interpreter(manifest.runtime) + if interpreter is None: + raise FileNotFoundError(f"未找到 {manifest.runtime} 解释器,请确认已安装并在 PATH 中") + entry = ctx.task_dir / manifest.entry + if manifest.runtime == "powershell": + return [*interpreter, str(entry)] + if manifest.runtime == "bash": + return [*interpreter, str(entry)] + return [*interpreter, str(entry)] + + +def _task_env(ctx: TaskContext) -> dict[str, str]: + env = os.environ.copy() + env["CRON_TASK_ID"] = ctx.task_id + env["CRON_TASK_DIR"] = str(ctx.task_dir) + env["CRON_ROOT"] = str(ctx.cron_root) + return env + + +def run_external_task(ctx: TaskContext, manifest: TaskManifest) -> int: + if task_is_disabled(ctx): + return 0 + + with task_logging(ctx) as log: + with acquire_cron_lock(ctx.lock_file, log=log) as locked: + if not locked: + return 0 + + log("start") + log(f"runtime={manifest.runtime} entry={manifest.entry}") + + try: + cmd = build_command(manifest, ctx) + except FileNotFoundError as exc: + log(str(exc)) + log("end") + return 1 + + proc = subprocess.run( + cmd, + cwd=str(ctx.task_dir), + env=_task_env(ctx), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + output = ((proc.stdout or "") + (proc.stderr or "")).strip() + if output: + append_raw_to_log(ctx.log_file, output) + + if proc.returncode != 0: + log(f"exit={proc.returncode}") + + log("end") + return proc.returncode diff --git a/lib/shumengya_cron/manager.py b/lib/manager.py similarity index 57% rename from lib/shumengya_cron/manager.py rename to lib/manager.py index e1b9928..497c4f2 100644 --- a/lib/shumengya_cron/manager.py +++ b/lib/manager.py @@ -2,30 +2,68 @@ from __future__ import annotations -import fcntl import importlib.util +import os import re import shutil from dataclasses import asdict, dataclass from datetime import datetime from pathlib import Path -from shumengya_cron.runner import ( +from external_runner import run_external_task +from cron_platform import is_windows, lock_ex_nb, sync_windows_task, unlock +from runner import ( DISABLED_DIR_NAME, LogMode, TaskContext, set_task_enabled, task_is_enabled, ) +from task_manifest import ( + effective_task_tags, + is_valid_task_dir, + load_task_manifest, + load_task_tags, + normalize_tags, + save_task_tags, +) CRON_D = Path("/etc/cron.d") _CRON_LINE = re.compile( r"^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+\S+\s+.+$", ) +_DESC_SKIP_PREFIX = ("开关",) + + +def _is_description_comment(text: str) -> bool: + if not text: + return False + if text.startswith(_DESC_SKIP_PREFIX): + return False + if "cronctl" in text: + return False + return True + + +def _validate_cron_schedule(schedule: str) -> str: + parts = schedule.strip().split() + if len(parts) != 5: + raise ValueError("cron 表达式须为 5 段:分 时 日 月 周") + return " ".join(parts) + + +def _replace_cron_expression(line: str, new_schedule: str) -> str: + stripped = line.strip() + if not _CRON_LINE.match(stripped): + return line + parts = stripped.split(None, 6) + if len(parts) < 7: + raise ValueError("schedule.cron 中的 cron 行格式无法识别") + return f"{new_schedule} {parts[5]} {parts[6]}" def cron_root() -> Path: - return Path(__file__).resolve().parents[2] + return Path(__file__).resolve().parents[1] def migrate_legacy_disabled_layout(root: Path | None = None) -> None: @@ -55,13 +93,13 @@ def iter_task_ids(root: Path | None = None) -> list[str]: if path.name.endswith(".disabled"): task_ids.add(path.name.removesuffix(".disabled")) continue - if (path / "run.py").is_file(): + if is_valid_task_dir(path): task_ids.add(path.name) disabled_root = base / DISABLED_DIR_NAME if disabled_root.is_dir(): for path in sorted(disabled_root.iterdir()): - if path.is_dir() and (path / "run.py").is_file(): + if path.is_dir() and is_valid_task_dir(path): task_ids.add(path.name) return sorted(task_ids) @@ -71,8 +109,8 @@ def get_context(task_id: str, root: Path | None = None) -> TaskContext: ctx = TaskContext.from_task_id(task_id, cron_root=root) if not ctx.task_dir.is_dir(): raise FileNotFoundError(f"任务不存在: {task_id}") - if not (ctx.task_dir / "run.py").is_file(): - raise FileNotFoundError(f"任务目录缺少 run.py: {task_id}") + if not is_valid_task_dir(ctx.task_dir): + raise FileNotFoundError(f"任务目录缺少 schedule.cron 或入口脚本: {task_id}") return ctx @@ -89,7 +127,7 @@ def _parse_schedule(task_dir: Path) -> tuple[str | None, str | None]: if line.startswith("#"): if description is None: text = line.lstrip("#").strip() - if text and not text.startswith("开关") and "cronctl" not in text: + if _is_description_comment(text): description = text continue match = _CRON_LINE.match(line) @@ -103,15 +141,16 @@ def task_is_running(ctx: TaskContext) -> bool: lock_file = ctx.lock_file if not lock_file.is_file(): return False - fd = lock_file.open("r") + fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT, 0o644) try: try: - fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + lock_ex_nb(fd) return False except BlockingIOError: return True finally: - fd.close() + unlock(fd) + os.close(fd) @dataclass @@ -124,6 +163,8 @@ class TaskInfo: log_size: int log_updated: str | None task_dir: str + runtime: str + tags: list[str] def to_dict(self) -> dict[str, object]: return asdict(self) @@ -138,6 +179,7 @@ def build_task_info(task_id: str, root: Path | None = None) -> TaskInfo: stat = ctx.log_file.stat() log_size = stat.st_size log_updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S") + manifest = load_task_manifest(ctx.task_dir) return TaskInfo( task_id=task_id, enabled=task_is_enabled(ctx), @@ -147,6 +189,8 @@ def build_task_info(task_id: str, root: Path | None = None) -> TaskInfo: log_size=log_size, log_updated=log_updated, task_dir=str(ctx.task_dir), + runtime=manifest.runtime, + tags=effective_task_tags(ctx.task_dir, manifest), ) @@ -156,13 +200,24 @@ def list_tasks(root: Path | None = None) -> list[TaskInfo]: def sync_cron_d(ctx: TaskContext, enabled: bool) -> str | None: - """将 schedule.cron 安装到 /etc/cron.d/。返回提示信息或 None。""" - cron_d_file = CRON_D / ctx.task_id + """安装定时调度:Linux 写入 /etc/cron.d/,Windows 注册任务计划程序。""" schedule = ctx.task_dir / "schedule.cron" if not schedule.is_file(): return "未找到 schedule.cron,跳过安装" + + expression, _ = _parse_schedule(ctx.task_dir) + + if is_windows(): + return sync_windows_task( + ctx.task_id, + ctx.cron_root, + expression, + enabled=enabled, + ) + + cron_d_file = CRON_D / ctx.task_id shutil.copy2(schedule, cron_d_file) - state = "开启" if enabled else "关闭(cron 仍触发,run.py 自动跳过)" + state = "开启" if enabled else "关闭(cron 仍触发,任务自动跳过)" return f"已同步到 {cron_d_file}({state})" @@ -180,10 +235,14 @@ def toggle_task(task_id: str, root: Path | None = None) -> tuple[TaskInfo, str | def run_task(task_id: str, root: Path | None = None) -> int: - """动态加载任务 run.py,调用其 run(ctx) 并返回退出码。""" + """执行任务:Python 动态加载 run.py,其余 runtime 由 external_runner 启动。""" ctx = get_context(task_id, root) - run_py = ctx.task_dir / "run.py" + manifest = load_task_manifest(ctx.task_dir) + if manifest.runtime != "python": + return run_external_task(ctx, manifest) + + run_py = ctx.task_dir / manifest.entry spec = importlib.util.spec_from_file_location(f"cron_run_{task_id}", run_py) mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] spec.loader.exec_module(mod) # type: ignore[union-attr] @@ -202,3 +261,65 @@ def read_log_tail(task_id: str, lines: int = 200, root: Path | None = None) -> s if lines <= 0 or len(parts) <= lines: return content return "\n".join(parts[-lines:]) + "\n" + + +def update_task_schedule( + task_id: str, + *, + description: str, + schedule: str, + tags: list[str] | None = None, + root: Path | None = None, + sync: bool = True, +) -> tuple[TaskInfo, str | None]: + """更新 schedule.cron 中的描述注释、cron 表达式前五段,以及 task.json 标签。""" + ctx = get_context(task_id, root) + schedule_file = ctx.task_dir / "schedule.cron" + if not schedule_file.is_file(): + raise FileNotFoundError(f"未找到 schedule.cron: {task_id}") + + new_schedule = _validate_cron_schedule(schedule) + desc_text = description.strip() + + raw_lines = schedule_file.read_text(encoding="utf-8").splitlines() + desc_updated = False + cron_updated = False + new_lines: list[str] = [] + + for line in raw_lines: + stripped = line.strip() + if not desc_updated and stripped.startswith("#"): + text = stripped.lstrip("#").strip() + if _is_description_comment(text): + new_lines.append(f"# {desc_text}" if desc_text else "#") + desc_updated = True + continue + if _CRON_LINE.match(stripped): + new_lines.append(_replace_cron_expression(line, new_schedule)) + cron_updated = True + continue + new_lines.append(line) + + if not cron_updated: + raise ValueError("schedule.cron 中未找到 cron 调度行") + + if not desc_updated and desc_text: + inserted: list[str] = [] + for line in new_lines: + if _CRON_LINE.match(line.strip()) and (not inserted or inserted[-1] != ""): + inserted.append(f"# {desc_text}") + inserted.append("") + inserted.append(line) + new_lines = inserted + + schedule_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + + if tags is not None: + save_task_tags(ctx.task_dir, normalize_tags(tags)) + + message = None + if sync: + ctx = get_context(task_id, root) + message = sync_cron_d(ctx, task_is_enabled(ctx)) + + return build_task_info(task_id, root), message diff --git a/lib/shumengya_cron/notify.py b/lib/notify.py similarity index 56% rename from lib/shumengya_cron/notify.py rename to lib/notify.py index acb6b2e..0063923 100644 --- a/lib/shumengya_cron/notify.py +++ b/lib/notify.py @@ -1,23 +1,35 @@ """ 飞书 / Lark Markdown 通知 + 任务结果汇总。 +内置 lark-notice-api 与 mengya-mail-api,无需外部 subprocess。 """ from __future__ import annotations import os -import subprocess import sys from dataclasses import dataclass, field from datetime import datetime +from pathlib import Path from typing import TYPE_CHECKING, Callable if TYPE_CHECKING: - from shumengya_cron.runner import TaskContext + from runner import TaskContext CRON_FEISHU_WEBHOOK_DEFAULT = ( "https://open.feishu.cn/open-apis/bot/v2/hook/1b13f977-f848-4a42-8e43-1b16ad592a34" ) -CRON_LARK_NOTICE_API_SRC_DEFAULT = "/shumengya/project/python/lark-notice-api/src" + +_VENDOR_ROOT = Path(__file__).resolve().parent / "vendor" +_LARK_SRC = _VENDOR_ROOT / "lark-notice-api" / "src" +_MAIL_SRC = _VENDOR_ROOT / "mengya-mail-api" / "src" +_MAIL_ENV_DEFAULT = _VENDOR_ROOT / "mengya-mail-api" / ".env" + + +def _ensure_vendor_imports() -> None: + for src in (_LARK_SRC, _MAIL_SRC): + text = str(src) + if src.is_dir() and text not in sys.path: + sys.path.insert(0, text) @dataclass @@ -76,11 +88,8 @@ def send_task_summary( log: Callable[[str], None], extra_fields: list[tuple[str, object]] | None = None, ) -> None: - """发送标准任务汇总通知(飞书 + 邮件降级)。 - - extra_fields 会插入在 结束时间 和 总数 之间,适合放主机名、目标路径等任务特有字段。 - """ - from shumengya_cron.runner import task_output_prefix + """发送标准任务汇总通知(飞书 + 邮件降级)。""" + from runner import task_output_prefix end_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") subject = f"{task_output_prefix(ctx.task_id)} {result.status_cn} {end_time}" @@ -114,95 +123,61 @@ def send_feishu_markdown( *, log: Callable[[str], None], ) -> None: - """发送飞书 Markdown(失败时自动降级为邮件通知,邮件也失败则结束)。""" + """发送飞书 Markdown(失败时自动降级为邮件通知)。""" if not title or not markdown: log("飞书通知内容为空,跳过。") return webhook = os.environ.get("LARK_NOTICE_WEBHOOK", CRON_FEISHU_WEBHOOK_DEFAULT) - api_src = os.environ.get("LARK_NOTICE_API_SRC", CRON_LARK_NOTICE_API_SRC_DEFAULT) if not webhook: log("未配置飞书 webhook,跳过飞书通知。") return - env = os.environ.copy() - pp = api_src - if env.get("PYTHONPATH"): - pp = f"{api_src}:{env['PYTHONPATH']}" - env["PYTHONPATH"] = pp + _ensure_vendor_imports() + from lark_notice_api.client import LarkNoticeError, LarkWebhookClient - feishu_ok = False try: - r = subprocess.run( - [ - sys.executable, - "-m", - "lark_notice_api", - "--webhook", - webhook, - "send-markdown", - "--title", - title, - "--markdown", - markdown, - ], - cwd=api_src if os.path.isdir(api_src) else None, - env=env, - check=False, - capture_output=True, - text=True, - ) - if r.returncode == 0: - feishu_ok = True - else: - detail = (r.stderr or r.stdout or "").strip() - if detail: - log(f"发送飞书通知失败(退出码 {r.returncode}):{detail}") - else: - log(f"发送飞书通知失败(退出码 {r.returncode})。") - except OSError: - log("发送飞书通知失败:lark-notice-api 调用异常。") - - if feishu_ok: + LarkWebhookClient(webhook).send_markdown(title, markdown) return + except LarkNoticeError as exc: + log(f"发送飞书通知失败:{exc}") + except ImportError: + log("发送飞书通知失败:内置 lark-notice-api 不可用。") log("飞书通知失败,尝试通过邮件发送…") _send_mail_fallback(title, markdown, log) -# ── 邮件降级 ────────────────────────────────────────────── - -_MAIL_SCRIPT_DEFAULT = "/shumengya/project/skills/mengya-mail-skills/scripts/mengya-mail-api.py" -_MAIL_ENV_FILE_DEFAULT = "/shumengya/project/python/mengya-mail-api/.env" - - def _send_mail_fallback(title: str, markdown: str, log: Callable[[str], None]) -> None: - """通过 mengya-mail-api 发送报告邮件(失败仅记日志,不再继续降级)。""" + """通过内置 mengya-mail-api 发送邮件(失败仅记日志)。""" enabled = os.environ.get("CRON_MAIL_ENABLED", "0") if enabled != "1": log("邮件通知未开启(export CRON_MAIL_ENABLED=1 可启用),跳过。") return - script = os.environ.get("MAIL_API_SCRIPT", _MAIL_SCRIPT_DEFAULT) - env_file = os.environ.get("MAIL_API_ENV_FILE", _MAIL_ENV_FILE_DEFAULT) + env_file = os.environ.get("MAIL_API_ENV_FILE", str(_MAIL_ENV_DEFAULT)) + if env_file and os.path.isfile(env_file): + os.environ.setdefault("MENGYA_MAIL_ENV_FILE", env_file) + mail_to = os.environ.get("MAIL_TO", "mail@smyhub.com") from_name = os.environ.get("MAIL_FROM_NAME", "cron") - if not os.path.isfile(script): - log(f"邮件脚本不存在:{script},跳过邮件通知。") - return + _ensure_vendor_imports() + from mengya_mail_api.config import ConfigError, MailConfig + from mengya_mail_api.email_client import MailClient, MailClientError try: - r = subprocess.run( - [sys.executable, script, "--env-file", env_file, "--format", "json", - "send-email", "--to", mail_to, "--subject", title, - "--from-name", from_name, "--html-body", markdown], - capture_output=True, text=True, check=False, + config = MailConfig.from_env() + MailClient(config).send_email( + to=mail_to, + subject=title, + html_body=markdown, + from_name=from_name, ) - if r.returncode == 0: - log("邮件通知发送成功。") - else: - detail = (r.stderr or r.stdout or "").strip() - log(f"邮件通知发送失败(退出码 {r.returncode}):{detail or '无详细信息'}") - except OSError as exc: - log(f"邮件通知发送异常:{exc}") + log("邮件通知发送成功。") + except ConfigError as exc: + log(f"邮件配置错误:{exc}") + except MailClientError as exc: + log(f"邮件通知发送失败:{exc}") + except ImportError: + log("邮件通知发送失败:内置 mengya-mail-api 不可用。") diff --git a/lib/shumengya_cron/runner.py b/lib/runner.py similarity index 96% rename from lib/shumengya_cron/runner.py rename to lib/runner.py index 6e6ac11..1935633 100644 --- a/lib/shumengya_cron/runner.py +++ b/lib/runner.py @@ -7,7 +7,6 @@ from __future__ import annotations -import fcntl import os import sys from contextlib import contextmanager @@ -17,6 +16,8 @@ from enum import Enum from pathlib import Path from typing import Callable, Iterator, TextIO +from cron_platform import lock_ex_nb, unlock + class LogMode(str, Enum): """日志模式:整段重定向到文件(多数任务)或直接写文件(如 AI CLI 更新)。""" @@ -26,8 +27,8 @@ class LogMode(str, Enum): def _cron_root() -> Path: - # .../lib/shumengya_cron/runner.py -> cron 根目录 - return Path(__file__).resolve().parents[2] + # .../lib/runner.py -> cron 根目录 + return Path(__file__).resolve().parents[1] DISABLED_DIR_NAME = ".disabled" @@ -184,7 +185,7 @@ def acquire_cron_lock( ) -> Iterator[bool]: """ 获取互斥锁;若已被占用则记录日志并 yield False(调用方应 sys.exit(0))。 - 使用 flock(与 bash 版一致)。 + Linux 使用 flock;Windows 使用 msvcrt 文件锁。 """ msg = busy_message or os.environ.get( "CRON_LOCK_BUSY_MSG", "已有同名任务在运行,跳过本次。" @@ -193,17 +194,14 @@ def acquire_cron_lock( fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT, 0o644) try: try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + lock_ex_nb(fd) except BlockingIOError: log(msg) yield False return yield True finally: - try: - fcntl.flock(fd, fcntl.LOCK_UN) - except OSError: - pass + unlock(fd) os.close(fd) diff --git a/lib/runtime_probe.py b/lib/runtime_probe.py new file mode 100644 index 0000000..c40212d --- /dev/null +++ b/lib/runtime_probe.py @@ -0,0 +1,114 @@ +"""检测本机 Python / Node.js / Bash / PowerShell 是否可用及版本。""" + +from __future__ import annotations + +import re +import shutil +import subprocess +import sys +from dataclasses import asdict, dataclass + + +@dataclass +class RuntimeProbe: + id: str + name: str + available: bool + version: str | None = None + path: str | None = None + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + +def _first_line(text: str) -> str: + line = (text or "").strip().splitlines()[0].strip() if text else "" + return line + + +def _run(cmd: list[str], *, timeout: float = 8.0) -> tuple[int, str]: + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + out = _first_line((proc.stdout or "") + "\n" + (proc.stderr or "")) + return proc.returncode, out + except (OSError, subprocess.TimeoutExpired): + return 1, "" + + +def _probe_python() -> RuntimeProbe: + exe = sys.executable + code, out = _run([exe, "--version"]) + version: str | None = None + if code == 0 and out: + version = out.removeprefix("Python ").strip() if out.lower().startswith("python") else out + elif sys.version: + version = sys.version.split()[0] + return RuntimeProbe( + id="python", + name="Python", + available=True, + version=version, + path=exe, + ) + + +def _probe_nodejs() -> RuntimeProbe: + path = shutil.which("node") + if not path: + return RuntimeProbe(id="nodejs", name="JavaScript", available=False) + code, out = _run([path, "--version"]) + if code != 0 or not out: + return RuntimeProbe(id="nodejs", name="JavaScript", available=False, path=path) + version = out.lstrip("vV").strip() or out + return RuntimeProbe(id="nodejs", name="JavaScript", available=True, version=version, path=path) + + +def _parse_bash_version(text: str) -> str | None: + match = re.search(r"version\s+([^\s(]+)", text, re.I) + return match.group(1) if match else (text or None) + + +def _probe_bash() -> RuntimeProbe: + path = shutil.which("bash") + if not path: + return RuntimeProbe(id="bash", name="Bash", available=False) + code, out = _run([path, "--version"]) + if code != 0 or not out: + return RuntimeProbe(id="bash", name="Bash", available=False, path=path) + version = _parse_bash_version(out) + return RuntimeProbe(id="bash", name="Bash", available=True, version=version, path=path) + + +def _probe_powershell() -> RuntimeProbe: + for cmd_name in ("pwsh", "powershell"): + path = shutil.which(cmd_name) + if not path: + continue + code, out = _run( + [path, "-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"] + ) + if code == 0 and out: + return RuntimeProbe( + id="powershell", + name="PowerShell", + available=True, + version=out.strip(), + path=path, + ) + return RuntimeProbe(id="powershell", name="PowerShell", available=False) + + +def probe_all_runtimes() -> list[dict[str, object]]: + return [ + _probe_python().to_dict(), + _probe_nodejs().to_dict(), + _probe_bash().to_dict(), + _probe_powershell().to_dict(), + ] diff --git a/lib/shumengya_cron/__init__.py b/lib/shumengya_cron/__init__.py deleted file mode 100644 index 9c2ee98..0000000 --- a/lib/shumengya_cron/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""shumengya-cron:共用库(日志/锁、飞书通知、SSH 辅助)。各任务逻辑在任务目录 run.py。""" - -__all__ = ["__version__"] - -__version__ = "0.1.0" diff --git a/lib/shumengya_cron/ssh.py b/lib/ssh.py similarity index 100% rename from lib/shumengya_cron/ssh.py rename to lib/ssh.py diff --git a/lib/task_manifest.py b/lib/task_manifest.py new file mode 100644 index 0000000..00de48d --- /dev/null +++ b/lib/task_manifest.py @@ -0,0 +1,165 @@ +"""任务清单 task.json 解析与 runtime 推断。""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +Runtime = Literal["python", "javascript", "bash", "powershell"] + +RUNTIME_DEFAULT_ENTRY: dict[str, str] = { + "python": "run.py", + "javascript": "run.js", + "bash": "run.sh", + "powershell": "run.ps1", +} + +RUNTIME_ALIASES: dict[str, Runtime] = { + "python": "python", + "py": "python", + "javascript": "javascript", + "js": "javascript", + "node": "javascript", + "bash": "bash", + "sh": "bash", + "shell": "bash", + "powershell": "powershell", + "ps1": "powershell", + "pwsh": "powershell", +} + +ENTRY_TO_RUNTIME: dict[str, Runtime] = { + "run.py": "python", + "run.js": "javascript", + "run.sh": "bash", + "run.ps1": "powershell", +} + +RUNTIME_LABEL: dict[str, str] = { + "python": "Python", + "javascript": "JavaScript", + "bash": "Bash", + "powershell": "PowerShell", +} + +MAX_TASK_TAGS = 4 +MAX_TAG_LENGTH = 32 + + +@dataclass(frozen=True) +class TaskManifest: + runtime: Runtime + entry: str + + @property + def entry_path(self) -> str: + return self.entry + + @property + def label(self) -> str: + return RUNTIME_LABEL.get(self.runtime, self.runtime) + + +def _normalize_runtime(raw: str) -> Runtime | None: + key = raw.strip().lower() + return RUNTIME_ALIASES.get(key) # type: ignore[return-value] + + +def _infer_from_entry_files(task_dir: Path) -> TaskManifest | None: + for entry, runtime in ENTRY_TO_RUNTIME.items(): + if (task_dir / entry).is_file(): + return TaskManifest(runtime=runtime, entry=entry) + return None + + +def load_task_manifest(task_dir: Path) -> TaskManifest: + """读取 task.json;缺失时按入口文件推断;默认 python + run.py。""" + manifest_file = task_dir / "task.json" + if manifest_file.is_file(): + data = json.loads(manifest_file.read_text(encoding="utf-8")) + runtime_raw = str(data.get("runtime", "python")) + runtime = _normalize_runtime(runtime_raw) + if runtime is None: + raise ValueError(f"不支持的 runtime: {runtime_raw}") + entry = str(data.get("entry", RUNTIME_DEFAULT_ENTRY[runtime])).strip() + if not entry: + entry = RUNTIME_DEFAULT_ENTRY[runtime] + entry_path = task_dir / entry + if not entry_path.is_file(): + raise FileNotFoundError(f"task.json 指定的入口不存在: {entry}") + return TaskManifest(runtime=runtime, entry=entry) + + inferred = _infer_from_entry_files(task_dir) + if inferred is not None: + return inferred + + if (task_dir / "run.py").is_file(): + return TaskManifest(runtime="python", entry="run.py") + + raise FileNotFoundError("未找到 task.json 或可识别的入口脚本") + + +def normalize_tags(raw: object) -> list[str]: + """去重、去空白,最多 MAX_TASK_TAGS 个。""" + if not isinstance(raw, (list, tuple)): + return [] + tags: list[str] = [] + for item in raw: + s = str(item).strip() + if not s or len(s) > MAX_TAG_LENGTH: + continue + if s not in tags: + tags.append(s) + if len(tags) >= MAX_TASK_TAGS: + break + return tags + + +def load_task_tags(task_dir: Path) -> list[str]: + manifest_file = task_dir / "task.json" + if not manifest_file.is_file(): + return [] + try: + data = json.loads(manifest_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return [] + return normalize_tags(data.get("tags", [])) + + +def effective_task_tags(task_dir: Path, manifest: TaskManifest) -> list[str]: + """已存标签优先;否则默认用运行时名称作为唯一标签。""" + stored = load_task_tags(task_dir) + if stored: + return stored + return [RUNTIME_LABEL.get(manifest.runtime, manifest.runtime)] + + +def save_task_tags(task_dir: Path, tags: list[str]) -> None: + """写入 task.json 的 tags 字段;无文件时按当前 manifest 创建。""" + manifest = load_task_manifest(task_dir) + normalized = normalize_tags(tags) + manifest_file = task_dir / "task.json" + data: dict[str, object] = {} + if manifest_file.is_file(): + data = json.loads(manifest_file.read_text(encoding="utf-8")) + data["runtime"] = manifest.runtime + data["entry"] = manifest.entry + data["tags"] = normalized + manifest_file.write_text( + json.dumps(data, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def is_valid_task_dir(task_dir: Path) -> bool: + try: + if not task_dir.is_dir(): + return False + if not (task_dir / "schedule.cron").is_file(): + return False + load_task_manifest(task_dir) + return True + except (OSError, ValueError, FileNotFoundError, json.JSONDecodeError): + return False diff --git a/lib/vendor/lark-notice-api/src/lark_notice_api/__init__.py b/lib/vendor/lark-notice-api/src/lark_notice_api/__init__.py new file mode 100644 index 0000000..3dd0b09 --- /dev/null +++ b/lib/vendor/lark-notice-api/src/lark_notice_api/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/lib/vendor/lark-notice-api/src/lark_notice_api/__main__.py b/lib/vendor/lark-notice-api/src/lark_notice_api/__main__.py new file mode 100644 index 0000000..5417fda --- /dev/null +++ b/lib/vendor/lark-notice-api/src/lark_notice_api/__main__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from .cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lib/vendor/lark-notice-api/src/lark_notice_api/cli.py b/lib/vendor/lark-notice-api/src/lark_notice_api/cli.py new file mode 100644 index 0000000..a370b92 --- /dev/null +++ b/lib/vendor/lark-notice-api/src/lark_notice_api/cli.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any + +from .client import LarkNoticeError, LarkWebhookClient + + +def _split_global_args(argv: list[str]) -> tuple[list[str], str | None, float | None]: + cleaned: list[str] = [] + webhook: str | None = None + timeout: float | None = None + i = 0 + while i < len(argv): + arg = argv[i] + if arg == "--webhook" and i + 1 < len(argv): + webhook = argv[i + 1] + i += 2 + continue + if arg == "--timeout" and i + 1 < len(argv): + timeout = float(argv[i + 1]) + i += 2 + continue + cleaned.append(arg) + i += 1 + return cleaned, webhook, timeout + + +def _client_from_args(args: argparse.Namespace) -> LarkWebhookClient: + webhook = args.webhook or os.getenv("LARK_NOTICE_WEBHOOK", "") + if not webhook: + raise LarkNoticeError("缺少 webhook,请传 --webhook 或设置 LARK_NOTICE_WEBHOOK") + return LarkWebhookClient(webhook=webhook, timeout=args.timeout) + + +def _normalize_cli_markdown(markdown: str) -> str: + if "\n" in markdown or "\r" in markdown: + return markdown + return markdown.replace(r"\n", "\n").replace(r"\r", "\r") + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="lark-notice-api") + parser.add_argument("--webhook", help="Feishu webhook URL") + parser.add_argument("--timeout", type=float, default=10.0, help="HTTP timeout seconds") + + sub = parser.add_subparsers(dest="command", required=True) + + text = sub.add_parser("send-text", help="Send plain text message") + text.add_argument("--text", required=True) + + post = sub.add_parser("send-post", help="Send Feishu post message") + post.add_argument("--title", required=True) + post.add_argument("--line", action="append", dest="lines", required=True) + + md = sub.add_parser("send-markdown", help="Send interactive markdown card") + md.add_argument("--title", required=True) + md.add_argument("--markdown", required=True) + + demo = sub.add_parser("demo", help="Send a markdown demo card") + demo.add_argument("--title", default="飞书消息示例") + demo.add_argument( + "--markdown", + default=( + "# 飞书 Markdown 通知测试\n" + "\n" + "这是一条 **JSON 2.0** 富文本消息。\n" + "\n" + "- 支持 *斜体*、**粗体**、~~删除线~~\n" + "- [打开示例网站](https://feishu.cn)\n" + "- 代码块示例:\n" + "\n" + "```json\n" + "{\"hello\": \"world\"}\n" + "```" + ), + ) + + return parser + + +def _emit(result: dict[str, Any]) -> None: + sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n") + + +def main(argv: list[str] | None = None) -> int: + raw_argv = sys.argv[1:] if argv is None else argv + cleaned_argv, webhook, timeout = _split_global_args(raw_argv) + args = _parser().parse_args(cleaned_argv) + if webhook is not None: + args.webhook = webhook + if timeout is not None: + args.timeout = timeout + + try: + client = _client_from_args(args) + if args.command == "send-text": + result = client.send_text(args.text) + elif args.command == "send-post": + result = client.send_post(args.title, args.lines) + elif args.command == "send-markdown": + result = client.send_markdown(args.title, _normalize_cli_markdown(args.markdown)) + elif args.command == "demo": + result = client.send_markdown(args.title, args.markdown) + else: + raise LarkNoticeError(f"未知命令: {args.command}") + except LarkNoticeError as exc: + sys.stderr.write(f"ERROR: {exc}\n") + return 2 + + _emit(result) + return 0 diff --git a/lib/vendor/lark-notice-api/src/lark_notice_api/client.py b/lib/vendor/lark-notice-api/src/lark_notice_api/client.py new file mode 100644 index 0000000..1ac5413 --- /dev/null +++ b/lib/vendor/lark-notice-api/src/lark_notice_api/client.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any +from urllib import error, request + + +class LarkNoticeError(RuntimeError): + pass + + +@dataclass(slots=True) +class LarkWebhookClient: + webhook: str + timeout: float = 10.0 + + def _post(self, payload: dict[str, Any]) -> dict[str, Any]: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = request.Request( + self.webhook, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with request.urlopen(req, timeout=self.timeout) as resp: + body = resp.read().decode("utf-8", errors="replace") + except error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") if exc.fp else "" + raise LarkNoticeError(f"HTTP {exc.code}: {body or exc.reason}") from exc + except error.URLError as exc: + raise LarkNoticeError(f"网络错误: {exc.reason}") from exc + + try: + result = json.loads(body) + except json.JSONDecodeError: + raise LarkNoticeError(f"响应不是 JSON: {body}") from None + + if result.get("code") not in (0, None): + raise LarkNoticeError( + f"飞书返回错误: code={result.get('code')} msg={result.get('msg') or result.get('message')}" + ) + return result + + def send_text(self, text: str) -> dict[str, Any]: + return self._post({"msg_type": "text", "content": {"text": text}}) + + def send_post(self, title: str, lines: list[str]) -> dict[str, Any]: + content = [[{"tag": "text", "text": line}] for line in lines] + return self._post( + { + "msg_type": "post", + "content": { + "post": { + "zh-CN": { + "title": title, + "content": content, + } + } + }, + } + ) + + @staticmethod + def _normalize_markdown(markdown: str) -> str: + return markdown.replace("\r\n", "\n").replace("\r", "\n") + + def send_markdown(self, title: str, markdown: str) -> dict[str, Any]: + return self._post( + { + "msg_type": "interactive", + "card": { + "schema": "2.0", + "config": {"wide_screen_mode": True, "enable_forward": True}, + "header": {"title": {"tag": "plain_text", "content": title}}, + "body": { + "elements": [ + { + "tag": "markdown", + "content": self._normalize_markdown(markdown), + } + ] + }, + }, + } + ) diff --git a/lib/vendor/mengya-mail-api/src/mengya_mail_api/__init__.py b/lib/vendor/mengya-mail-api/src/mengya_mail_api/__init__.py new file mode 100644 index 0000000..a05eb9a --- /dev/null +++ b/lib/vendor/mengya-mail-api/src/mengya_mail_api/__init__.py @@ -0,0 +1,3 @@ +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/lib/vendor/mengya-mail-api/src/mengya_mail_api/__main__.py b/lib/vendor/mengya-mail-api/src/mengya_mail_api/__main__.py new file mode 100644 index 0000000..5417fda --- /dev/null +++ b/lib/vendor/mengya-mail-api/src/mengya_mail_api/__main__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from .cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lib/vendor/mengya-mail-api/src/mengya_mail_api/cli.py b/lib/vendor/mengya-mail-api/src/mengya_mail_api/cli.py new file mode 100644 index 0000000..b885097 --- /dev/null +++ b/lib/vendor/mengya-mail-api/src/mengya_mail_api/cli.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any + +from .config import ConfigError, MailConfig +from .email_client import MailClient, MailClientError +from .templates import TemplateError, list_templates, render_template + + +def _load_env_file(path: str | None) -> None: + if path: + os.environ["MENGYA_MAIL_ENV_FILE"] = path + + + +def _split_global_args(argv: list[str]) -> tuple[list[str], str | None, str | None]: + cleaned: list[str] = [] + env_file: str | None = None + output_format: str | None = None + i = 0 + while i < len(argv): + arg = argv[i] + if arg == "--env-file" and i + 1 < len(argv): + env_file = argv[i + 1] + i += 2 + continue + if arg == "--format" and i + 1 < len(argv): + output_format = argv[i + 1] + i += 2 + continue + cleaned.append(arg) + i += 1 + return cleaned, env_file, output_format + + +def _client() -> MailClient: + return MailClient(MailConfig.from_env()) + + +def _parse_var_pairs(values: list[str] | None) -> dict[str, str]: + result: dict[str, str] = {} + for item in values or []: + if "=" not in item: + raise ValueError(f"变量格式应为 key=value: {item}") + key, value = item.split("=", 1) + key = key.strip() + if not key: + raise ValueError(f"变量键不能为空: {item}") + result[key] = value + return result + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="mengya-mail-api") + parser.add_argument("--env-file", help="Load env vars from a specific .env file") + parser.add_argument( + "--format", + choices=("json", "md"), + default="json", + help="Output format", + ) + + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("test-connection", help="Check SMTP/IMAP connectivity") + + send = sub.add_parser("send-email", help="Send a mail") + send.add_argument("--to", nargs="+", required=True, help="Primary recipients") + send.add_argument("--subject", required=True, help="Subject") + send.add_argument("--text-body", help="Plain text body") + send.add_argument("--html-body", help="HTML body") + send.add_argument("--cc", nargs="+", help="CC recipients") + send.add_argument("--bcc", nargs="+", help="BCC recipients") + send.add_argument("--from-name", help="Display name of the sender") + + listed = sub.add_parser("list-emails", help="List messages") + listed.add_argument("--folder", help="IMAP folder") + listed.add_argument("--criteria", nargs="+", help="IMAP search criteria") + listed.add_argument("--subject", help="Filter by subject") + listed.add_argument("--from-address", dest="from_address", help="Filter by sender") + listed.add_argument("--to-address", dest="to_address", help="Filter by recipient") + listed.add_argument("--limit", type=int, default=10, help="Max messages") + listed.add_argument("--mark-seen", action="store_true", help="Mark as seen") + + read = sub.add_parser("read-email", help="Read one message by UID") + read.add_argument("--uid", required=True, help="Message UID") + read.add_argument("--folder", help="IMAP folder") + read.add_argument("--mark-seen", action="store_true", help="Mark as seen") + + tmpl = sub.add_parser("send-template", help="Send a template mail") + tmpl.add_argument("--template", required=True, choices=sorted(list(item["key"] for item in list_templates())), help="Template key") + tmpl.add_argument("--to", nargs="+", required=True, help="Primary recipients") + tmpl.add_argument("--var", action="append", help="Template variable key=value") + tmpl.add_argument("--cc", nargs="+", help="CC recipients") + tmpl.add_argument("--bcc", nargs="+", help="BCC recipients") + tmpl.add_argument("--from-name", help="Display name of the sender") + + sub.add_parser("templates", help="List template keys") + return parser + + +def _to_markdown(command: str, result: dict[str, Any]) -> str: + if command == "test-connection": + lines = [ + "# Mail Connection", + "", + f"- Address: {result.get('address', '')}", + f"- SMTP: {result.get('smtp', '')}", + f"- IMAP: {result.get('imap', '')}", + f"- Ready: {result.get('ready', False)}", + ] + return "\n".join(lines) + "\n" + + if command == "templates": + templates = result.get("templates") or [] + lines = ["# Templates", "", f"- Count: {result.get('count', 0)}"] + for item in templates: + lines.append(f"- {item.get('key', '')}: {item.get('title', '')}") + return "\n".join(lines) + "\n" + + if command in {"send-email", "send-template"}: + lines = [ + "# Mail Sent", + "", + f"- Status: {result.get('status', '')}", + f"- From: {result.get('from', '')}", + f"- To: {', '.join(result.get('to') or [])}", + f"- Subject: {result.get('subject', '')}", + ] + if result.get("template"): + lines.append(f"- Template: {result.get('template')}") + if result.get("date"): + lines.append(f"- Date: {result.get('date')}") + if result.get("message_id"): + lines.append(f"- Message-ID: {result.get('message_id')}") + return "\n".join(lines) + "\n" + + if command == "list-emails": + lines = [ + "# Mail List", + "", + f"- Folder: {result.get('folder', '')}", + f"- Count: {result.get('count', 0)}", + ] + for item in result.get("messages") or []: + lines.extend( + [ + "", + f"## {item.get('subject', '(no subject)')}", + "", + f"- UID: {item.get('uid', '')}", + f"- From: {item.get('from', '')}", + f"- To: {item.get('to', '')}", + f"- Date: {item.get('date', '') or ''}", + f"- Seen: {item.get('seen', False)}", + f"- Attachments: {item.get('has_attachments', False)}", + f"- Snippet: {item.get('snippet', '')}", + ] + ) + return "\n".join(lines) + "\n" + + if command == "read-email": + lines = [ + "# Mail Detail", + "", + f"- UID: {result.get('uid', '')}", + f"- Subject: {result.get('subject', '')}", + f"- From: {result.get('from', '')}", + f"- To: {result.get('to', '')}", + f"- CC: {result.get('cc', '')}", + f"- Date: {result.get('date', '') or ''}", + f"- Message-ID: {result.get('message_id', '')}", + f"- Seen: {result.get('seen', False)}", + f"- Attachments: {result.get('has_attachments', False)}", + ] + attachments = result.get("attachments") or [] + if attachments: + lines.extend(["", "## Attachments", ""] + [f"- {item}" for item in attachments]) + text_body = result.get("text_body") or "" + html_body = result.get("html_body") or "" + if text_body: + lines.extend(["", "## Text Body", "", text_body]) + if html_body: + lines.extend(["", "## HTML Body", "", html_body]) + return "\n".join(lines) + "\n" + + return json.dumps(result, ensure_ascii=False, indent=2) + "\n" + + +def _emit(command: str, result: dict[str, Any], fmt: str) -> None: + if fmt == "md": + sys.stdout.write(_to_markdown(command, result)) + else: + sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n") + + +def main(argv: list[str] | None = None) -> int: + raw_argv = sys.argv[1:] if argv is None else argv + cleaned_argv, env_file, output_format = _split_global_args(raw_argv) + args = _build_parser().parse_args(cleaned_argv) + _load_env_file(env_file or args.env_file) + + try: + client = _client() + if args.command == "test-connection": + result = client.test_connection() + elif args.command == "send-email": + result = client.send_email( + to=args.to, + subject=args.subject, + text_body=args.text_body, + html_body=args.html_body, + cc=args.cc, + bcc=args.bcc, + from_name=args.from_name, + ) + elif args.command == "list-emails": + result = client.list_emails( + folder=args.folder, + criteria=args.criteria, + limit=args.limit, + mark_seen=args.mark_seen, + subject=args.subject, + from_address=args.from_address, + to_address=args.to_address, + ) + elif args.command == "read-email": + result = client.read_email(uid=args.uid, folder=args.folder, mark_seen=args.mark_seen) + elif args.command == "send-template": + variables = _parse_var_pairs(args.var) + subject, text_body, html_body = render_template(args.template, variables) + result = client.send_email( + to=args.to, + subject=subject, + text_body=text_body, + html_body=html_body, + cc=args.cc, + bcc=args.bcc, + from_name=args.from_name, + ) + result["template"] = args.template + elif args.command == "templates": + template_items = list_templates() + result = {"count": len(template_items), "templates": template_items} + else: + sys.stderr.write(f"ERROR: unsupported command: {args.command}\n") + return 2 + except (ConfigError, MailClientError, TemplateError, ValueError) as exc: + sys.stderr.write(f"ERROR: {exc}\n") + return 2 + + final_format = output_format or args.format + _emit(args.command, result, final_format) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lib/vendor/mengya-mail-api/src/mengya_mail_api/config.py b/lib/vendor/mengya-mail-api/src/mengya_mail_api/config.py new file mode 100644 index 0000000..12fed9e --- /dev/null +++ b/lib/vendor/mengya-mail-api/src/mengya_mail_api/config.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +class ConfigError(ValueError): + pass + + +def _strip_quotes(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + return value[1:-1] + return value + + +def _candidate_env_files() -> list[Path]: + candidates: list[Path] = [] + + explicit = os.getenv("MENGYA_MAIL_ENV_FILE") + if explicit: + candidates.append(Path(explicit).expanduser()) + + cwd_env = Path.cwd() / ".env" + package_env = Path(__file__).resolve().parents[2] / ".env" + + candidates.extend([cwd_env, package_env]) + + unique: list[Path] = [] + seen: set[Path] = set() + for candidate in candidates: + resolved = candidate.resolve(strict=False) + if resolved not in seen: + seen.add(resolved) + unique.append(candidate) + return unique + + +def _load_env_file(path: Path) -> None: + if not path.is_file(): + return + + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[7:].strip() + if "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = _strip_quotes(value.strip()) + if key and key not in os.environ: + os.environ[key] = value + + +def load_env_if_present() -> None: + for candidate in _candidate_env_files(): + _load_env_file(candidate) + + +@dataclass(frozen=True) +class MailConfig: + address: str + password: str + smtp_host: str = "smtp.qiye.aliyun.com" + smtp_port: int = 465 + imap_host: str = "imap.qiye.aliyun.com" + imap_port: int = 993 + default_folder: str = "INBOX" + timeout_seconds: float = 30.0 + + @classmethod + def from_env(cls) -> "MailConfig": + load_env_if_present() + + address = os.getenv("MENGYA_MAIL_ADDRESS") or os.getenv("ALIYUN_MAIL_ADDRESS") + password = os.getenv("MENGYA_MAIL_PASSWORD") or os.getenv("ALIYUN_MAIL_PASSWORD") + if not address: + raise ConfigError("缺少环境变量 MENGYA_MAIL_ADDRESS") + if not password: + raise ConfigError("缺少环境变量 MENGYA_MAIL_PASSWORD") + + return cls( + address=address, + password=password, + smtp_host=os.getenv("MENGYA_MAIL_SMTP_HOST", "smtp.qiye.aliyun.com"), + smtp_port=int(os.getenv("MENGYA_MAIL_SMTP_PORT", "465")), + imap_host=os.getenv("MENGYA_MAIL_IMAP_HOST", "imap.qiye.aliyun.com"), + imap_port=int(os.getenv("MENGYA_MAIL_IMAP_PORT", "993")), + default_folder=os.getenv("MENGYA_MAIL_DEFAULT_FOLDER", "INBOX"), + timeout_seconds=float(os.getenv("MENGYA_MAIL_TIMEOUT_SECONDS", "30")), + ) diff --git a/lib/vendor/mengya-mail-api/src/mengya_mail_api/email_client.py b/lib/vendor/mengya-mail-api/src/mengya_mail_api/email_client.py new file mode 100644 index 0000000..23cb86b --- /dev/null +++ b/lib/vendor/mengya-mail-api/src/mengya_mail_api/email_client.py @@ -0,0 +1,425 @@ +from __future__ import annotations + +import email +import imaplib +import json +import re +import shlex +import smtplib +from dataclasses import dataclass +from datetime import datetime +from email import policy +from email.header import decode_header, make_header +from email.message import EmailMessage, Message +from email.utils import formataddr, formatdate, make_msgid, parsedate_to_datetime +from html.parser import HTMLParser +from typing import Any + +from .config import MailConfig + + +class MailClientError(RuntimeError): + pass + + +@dataclass +class MailSummary: + uid: str + subject: str + from_address: str + to: str + date: str | None + seen: bool + has_attachments: bool + snippet: str + + def to_dict(self) -> dict[str, Any]: + return { + "uid": self.uid, + "subject": self.subject, + "from": self.from_address, + "to": self.to, + "date": self.date, + "seen": self.seen, + "has_attachments": self.has_attachments, + "snippet": self.snippet, + } + + +class _HTMLTextExtractor(HTMLParser): + def __init__(self) -> None: + super().__init__() + self._chunks: list[str] = [] + + def handle_data(self, data: str) -> None: + if data: + self._chunks.append(data) + + def text(self) -> str: + return " ".join(chunk.strip() for chunk in self._chunks if chunk.strip()) + + +def _decode_header_value(value: str | None) -> str: + if not value: + return "" + try: + return str(make_header(decode_header(value))) + except Exception: + return value + + +def _normalize_recipients(value: str | list[str] | tuple[str, ...] | None) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + items = [part.strip() for part in value.split(",")] + return [item for item in items if item] + return [item.strip() for item in value if item and item.strip()] + + +def _strip_html(html: str) -> str: + parser = _HTMLTextExtractor() + parser.feed(html) + parser.close() + return parser.text() + + +def _clean_text(text: str, limit: int | None = None) -> str: + normalized = re.sub(r"\s+", " ", text).strip() + if limit is not None and len(normalized) > limit: + return normalized[: limit - 1] + "…" + return normalized + + +def _pick_text_part(message: Message) -> tuple[str, str]: + text_body = "" + html_body = "" + + if message.is_multipart(): + for part in message.walk(): + disposition = part.get_content_disposition() + if disposition == "attachment": + continue + content_type = part.get_content_type() + payload = part.get_payload(decode=True) + if payload is None: + continue + charset = part.get_content_charset() or "utf-8" + try: + body = payload.decode(charset, errors="replace") + except LookupError: + body = payload.decode("utf-8", errors="replace") + if content_type == "text/plain" and not text_body: + text_body = body + elif content_type == "text/html" and not html_body: + html_body = body + else: + payload = message.get_payload(decode=True) + if payload is not None: + charset = message.get_content_charset() or "utf-8" + try: + text_body = payload.decode(charset, errors="replace") + except LookupError: + text_body = payload.decode("utf-8", errors="replace") + + if not text_body and html_body: + text_body = _strip_html(html_body) + return text_body.strip(), html_body.strip() + + +def _has_attachments(message: Message) -> bool: + for part in message.walk(): + if part.get_content_disposition() == "attachment": + return True + return False + + +def _attachment_names(message: Message) -> list[str]: + names: list[str] = [] + for part in message.walk(): + if part.get_content_disposition() == "attachment": + filename = part.get_filename() + if filename: + names.append(_decode_header_value(filename)) + return names + + +def _parse_date(value: str | None) -> str | None: + if not value: + return None + try: + dt = parsedate_to_datetime(value) + if isinstance(dt, datetime): + return dt.isoformat() + except Exception: + return value + return value + + +def _contains(haystack: str, needle: str | None) -> bool: + if not needle: + return True + return needle.casefold() in haystack.casefold() + + +class MailClient: + def __init__(self, config: MailConfig) -> None: + self.config = config + + def send_email( + self, + *, + to: str | list[str], + subject: str, + text_body: str | None = None, + html_body: str | None = None, + cc: str | list[str] | None = None, + bcc: str | list[str] | None = None, + from_name: str | None = None, + ) -> dict[str, Any]: + to_list = _normalize_recipients(to) + cc_list = _normalize_recipients(cc) + bcc_list = _normalize_recipients(bcc) + all_recipients = to_list + cc_list + bcc_list + if not to_list: + raise MailClientError("至少需要一个主收件人") + if not all_recipients: + raise MailClientError("至少需要一个收件人") + if not text_body and not html_body: + raise MailClientError("text_body 与 html_body 至少提供一个") + + message = EmailMessage() + message["From"] = formataddr((from_name, self.config.address)) if from_name else self.config.address + message["To"] = ", ".join(to_list) + if cc_list: + message["Cc"] = ", ".join(cc_list) + message["Subject"] = subject + message["Date"] = formatdate(localtime=True) + message["Message-ID"] = make_msgid(domain=self.config.address.split("@", 1)[-1]) + + if text_body: + message.set_content(text_body) + elif html_body: + message.set_content(_strip_html(html_body) or "请查看 HTML 正文") + + if html_body: + message.add_alternative(html_body, subtype="html") + + try: + with smtplib.SMTP_SSL( + self.config.smtp_host, + self.config.smtp_port, + timeout=self.config.timeout_seconds, + ) as smtp: + smtp.login(self.config.address, self.config.password) + smtp.send_message(message, from_addr=self.config.address, to_addrs=all_recipients) + except Exception as exc: + raise MailClientError(f"SMTP 发送失败: {exc}") from exc + + return { + "status": "sent", + "from": message["From"], + "to": to_list, + "cc": cc_list, + "bcc": bcc_list, + "subject": subject, + "date": _parse_date(message["Date"]), + "message_id": message["Message-ID"], + } + + def test_connection(self) -> dict[str, Any]: + smtp_status = "ok" + imap_status = "ok" + + try: + with smtplib.SMTP_SSL( + self.config.smtp_host, + self.config.smtp_port, + timeout=self.config.timeout_seconds, + ) as smtp: + smtp.login(self.config.address, self.config.password) + except Exception as exc: + smtp_status = f"failed: {exc}" + + try: + with imaplib.IMAP4_SSL( + self.config.imap_host, + self.config.imap_port, + timeout=self.config.timeout_seconds, + ) as imap: + imap.login(self.config.address, self.config.password) + except Exception as exc: + imap_status = f"failed: {exc}" + + return { + "address": self.config.address, + "smtp": smtp_status, + "imap": imap_status, + "ready": smtp_status == "ok" and imap_status == "ok", + } + + def list_emails( + self, + *, + folder: str | None = None, + criteria: str | list[str] | None = None, + limit: int = 10, + mark_seen: bool = False, + subject: str | None = None, + from_address: str | None = None, + to_address: str | None = None, + ) -> dict[str, Any]: + selected_folder = folder or self.config.default_folder + if limit < 1: + raise MailClientError("limit 必须大于 0") + + use_client_side_filters = any([subject, from_address, to_address]) + base_search_terms = self._build_base_search_terms(criteria=criteria, use_all=use_client_side_filters) + + try: + with imaplib.IMAP4_SSL( + self.config.imap_host, + self.config.imap_port, + timeout=self.config.timeout_seconds, + ) as imap: + imap.login(self.config.address, self.config.password) + status, _ = imap.select(selected_folder, readonly=not mark_seen) + if status != "OK": + raise MailClientError(f"无法选择文件夹 {selected_folder}") + + status, data = imap.uid("search", None, *base_search_terms) + if status != "OK": + raise MailClientError(f"IMAP 搜索失败: {' '.join(base_search_terms)}") + + uids = [uid.decode() for uid in data[0].split()] if data and data[0] else [] + candidate_limit = max(limit * 10, 50) if use_client_side_filters else limit + selected_uids = list(reversed(uids[-candidate_limit:])) + + messages: list[dict[str, Any]] = [] + for uid in selected_uids: + summary = self._fetch_summary(imap, uid, mark_seen=mark_seen) + if use_client_side_filters and not self._matches_summary_filters( + summary, + subject=subject, + from_address=from_address, + to_address=to_address, + ): + continue + messages.append(summary.to_dict()) + if len(messages) >= limit: + break + except MailClientError: + raise + except Exception as exc: + raise MailClientError(f"IMAP 列取邮件失败: {exc}") from exc + + return { + "folder": selected_folder, + "criteria": base_search_terms, + "count": len(messages), + "messages": messages, + } + + def read_email( + self, + *, + uid: str, + folder: str | None = None, + mark_seen: bool = False, + ) -> dict[str, Any]: + selected_folder = folder or self.config.default_folder + fetch_query = "(RFC822 FLAGS)" if mark_seen else "(BODY.PEEK[] FLAGS)" + + try: + with imaplib.IMAP4_SSL( + self.config.imap_host, + self.config.imap_port, + timeout=self.config.timeout_seconds, + ) as imap: + imap.login(self.config.address, self.config.password) + status, _ = imap.select(selected_folder, readonly=not mark_seen) + if status != "OK": + raise MailClientError(f"无法选择文件夹 {selected_folder}") + status, data = imap.uid("fetch", uid, fetch_query) + if status != "OK" or not data or data[0] is None: + raise MailClientError(f"找不到 UID={uid} 的邮件") + metadata = data[0][0].decode(errors="replace") if isinstance(data[0], tuple) else str(data[0]) + raw_message = data[0][1] if isinstance(data[0], tuple) else b"" + message = email.message_from_bytes(raw_message, policy=policy.default) + except MailClientError: + raise + except Exception as exc: + raise MailClientError(f"读取邮件失败: {exc}") from exc + + text_body, html_body = _pick_text_part(message) + return { + "uid": uid, + "subject": _decode_header_value(message.get("Subject")), + "from": _decode_header_value(message.get("From")), + "to": _decode_header_value(message.get("To")), + "cc": _decode_header_value(message.get("Cc")), + "date": _parse_date(message.get("Date")), + "message_id": _decode_header_value(message.get("Message-ID")), + "seen": "\\Seen" in metadata, + "has_attachments": _has_attachments(message), + "attachments": _attachment_names(message), + "text_body": text_body[:20000], + "html_body": html_body[:20000], + } + + def _fetch_summary(self, imap: imaplib.IMAP4_SSL, uid: str, *, mark_seen: bool) -> MailSummary: + fetch_query = "(RFC822 FLAGS)" if mark_seen else "(BODY.PEEK[] FLAGS)" + status, data = imap.uid("fetch", uid, fetch_query) + if status != "OK" or not data or data[0] is None: + raise MailClientError(f"无法读取 UID={uid} 的邮件") + + metadata = data[0][0].decode(errors="replace") if isinstance(data[0], tuple) else str(data[0]) + raw_message = data[0][1] if isinstance(data[0], tuple) else b"" + message = email.message_from_bytes(raw_message, policy=policy.default) + text_body, html_body = _pick_text_part(message) + snippet_source = text_body or _strip_html(html_body) + return MailSummary( + uid=uid, + subject=_decode_header_value(message.get("Subject")), + from_address=_decode_header_value(message.get("From")), + to=_decode_header_value(message.get("To")), + date=_parse_date(message.get("Date")), + seen="\\Seen" in metadata, + has_attachments=_has_attachments(message), + snippet=_clean_text(snippet_source, limit=160), + ) + + @staticmethod + def _build_base_search_terms(*, criteria: str | list[str] | None, use_all: bool) -> list[str]: + if criteria is None: + return ["ALL"] if use_all else ["UNSEEN"] + return MailClient._normalize_search_terms(criteria) + + @staticmethod + def _matches_summary_filters( + summary: MailSummary, + *, + subject: str | None, + from_address: str | None, + to_address: str | None, + ) -> bool: + return ( + _contains(summary.subject, subject) + and _contains(summary.from_address, from_address) + and _contains(summary.to, to_address) + ) + + @staticmethod + def _normalize_search_terms(criteria: str | list[str] | None) -> list[str]: + if criteria is None: + return ["UNSEEN"] + if isinstance(criteria, str): + parsed = shlex.split(criteria) + return parsed or ["UNSEEN"] + if not criteria: + return ["UNSEEN"] + return [item for item in criteria if item] + + +def format_result(data: dict[str, Any]) -> str: + return json.dumps(data, ensure_ascii=False, indent=2) diff --git a/lib/vendor/mengya-mail-api/src/mengya_mail_api/http_server.py b/lib/vendor/mengya-mail-api/src/mengya_mail_api/http_server.py new file mode 100644 index 0000000..c866435 --- /dev/null +++ b/lib/vendor/mengya-mail-api/src/mengya_mail_api/http_server.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import os +from typing import Any + +import uvicorn +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from . import __version__ +from .config import ConfigError, MailConfig +from .email_client import MailClient, MailClientError +from .templates import TemplateError, list_templates as get_template_list, render_template + + +API_TOKEN_ENV = "MENGYA_MAIL_API_TOKEN" +DEFAULT_API_TOKEN = "shumengya520" +API_HOST_ENV = "MENGYA_MAIL_API_HOST" +API_PORT_ENV = "MENGYA_MAIL_API_PORT" + + +class SendEmailRequest(BaseModel): + to: str | list[str] + subject: str + text_body: str | None = None + html_body: str | None = None + cc: str | list[str] | None = None + bcc: str | list[str] | None = None + from_name: str | None = None + + +class ListEmailsRequest(BaseModel): + folder: str | None = None + criteria: str | list[str] | None = None + subject: str | None = None + from_address: str | None = None + to_address: str | None = None + limit: int | None = None + mark_seen: bool = False + + +class ReadEmailRequest(BaseModel): + uid: str + folder: str | None = None + mark_seen: bool = False + + +class SendTemplateRequest(BaseModel): + template: str + to: str | list[str] + variables: dict[str, str] | None = None + cc: str | list[str] | None = None + bcc: str | list[str] | None = None + from_name: str | None = None + + +def _get_api_token() -> str: + return os.getenv(API_TOKEN_ENV, DEFAULT_API_TOKEN) + + +def _extract_token(request: Request) -> str | None: + auth_header = request.headers.get("authorization", "") + if auth_header.lower().startswith("bearer "): + return auth_header[7:].strip() or None + return request.headers.get("x-auth-token") + + +def require_token(request: Request) -> None: + expected = _get_api_token() + provided = _extract_token(request) + if not provided or provided != expected: + raise HTTPException(status_code=401, detail="Unauthorized") + + +def create_app(mail_client: MailClient | None = None) -> FastAPI: + app = FastAPI( + title="萌芽邮箱 API", + version=__version__, + docs_url=None, + redoc_url=None, + ) + client = mail_client or MailClient(MailConfig.from_env()) + + @app.exception_handler(ConfigError) + async def handle_config_error(_: Request, exc: ConfigError) -> JSONResponse: + return JSONResponse({"error": str(exc)}, status_code=400) + + @app.exception_handler(MailClientError) + async def handle_mail_error(_: Request, exc: MailClientError) -> JSONResponse: + return JSONResponse({"error": str(exc)}, status_code=400) + + @app.get("/health") + async def health() -> dict[str, str]: + return {"status": "ok"} + + @app.post("/api/send-email") + async def send_email( + payload: SendEmailRequest, + _: None = Depends(require_token), + ) -> dict[str, Any]: + result = client.send_email( + **payload.model_dump(exclude_none=True) + ) + return result + + @app.post("/api/list-emails") + async def list_emails( + payload: ListEmailsRequest, + _: None = Depends(require_token), + ) -> dict[str, Any]: + result = client.list_emails( + **payload.model_dump(exclude_none=True) + ) + return result + + @app.post("/api/read-email") + async def read_email( + payload: ReadEmailRequest, + _: None = Depends(require_token), + ) -> dict[str, Any]: + result = client.read_email( + **payload.model_dump(exclude_none=True) + ) + return result + + @app.get("/api/test-connection") + async def test_connection(_: None = Depends(require_token)) -> dict[str, Any]: + return client.test_connection() + + @app.get("/api/templates") + async def list_templates(_: None = Depends(require_token)) -> dict[str, Any]: + templates = get_template_list() + return {"count": len(templates), "templates": templates} + + @app.post("/api/send-template") + async def send_template( + payload: SendTemplateRequest, + _: None = Depends(require_token), + ) -> dict[str, Any]: + try: + subject, text_body, html_body = render_template(payload.template, payload.variables) + except TemplateError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + result = client.send_email( + to=payload.to, + subject=subject, + text_body=text_body, + html_body=html_body, + cc=payload.cc, + bcc=payload.bcc, + from_name=payload.from_name, + ) + result["template"] = payload.template + return result + + return app + + +app = create_app() + + +def main() -> None: + host = os.getenv(API_HOST_ENV, "0.0.0.0") + port = int(os.getenv(API_PORT_ENV, "8080")) + uvicorn.run("mengya_mail_api.http_server:app", host=host, port=port) + + +if __name__ == "__main__": + main() diff --git a/lib/vendor/mengya-mail-api/src/mengya_mail_api/templates.py b/lib/vendor/mengya-mail-api/src/mengya_mail_api/templates.py new file mode 100644 index 0000000..a2e0fda --- /dev/null +++ b/lib/vendor/mengya-mail-api/src/mengya_mail_api/templates.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import os +from pathlib import Path + +DEFAULT_TEMPLATE_VARS = { + "name": "朋友", + "sender": "树萌芽", +} + +TEMPLATES: dict[str, dict[str, str]] = { + "birthday": { + "title": "生日祝福", + "subject": "生日快乐,{name}!", + "text": ( + "亲爱的{name}:\n\n" + "祝你生日快乐,愿新的一岁平安顺遂,心想事成!\n\n" + "{sender}" + ), + "html_file": "birthday.html", + }, + "new_year": { + "title": "元旦祝福", + "subject": "元旦快乐,{name}!", + "text": ( + "亲爱的{name}:\n\n" + "新年伊始,愿你元旦快乐,万事顺遂,心想事成!\n\n" + "{sender}" + ), + "html_file": "new_year.html", + }, +} + + +class TemplateError(ValueError): + pass + + +def template_dir() -> Path: + env_dir = os.getenv("MENGYA_MAIL_TEMPLATE_DIR") + if env_dir: + return Path(env_dir).expanduser() + return Path(__file__).resolve().parents[2] / "template" + + +def read_template_file(filename: str) -> str: + path = template_dir() / filename + if not path.is_file(): + raise TemplateError(f"Template file not found: {path}") + return path.read_text(encoding="utf-8") + + +def render_template(template_key: str, variables: dict[str, str] | None = None) -> tuple[str, str, str | None]: + template = TEMPLATES.get(template_key) + if not template: + raise TemplateError(f"Unknown template: {template_key}") + + merged = {**DEFAULT_TEMPLATE_VARS, **(variables or {})} + try: + subject = template["subject"].format(**merged) + text_body = template["text"].format(**merged) + html_body = None + html_file = template.get("html_file") + if html_file: + html_body = read_template_file(html_file).format(**merged) + except KeyError as exc: + missing = exc.args[0] if exc.args else "unknown" + raise TemplateError(f"Missing template variable: {missing}") from exc + + return subject, text_body, html_body + + +def list_templates() -> list[dict[str, str]]: + return [ + {"key": key, "title": value.get("title", key)} + for key, value in TEMPLATES.items() + ] diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 0000000..114ce4e --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,41 @@ +# SproutClaw Cron MCP Server + +通过 [Model Context Protocol](https://modelcontextprotocol.io/) 让 AI Agent 管理本项目的定时任务。 + +## 安装 + +```bash +pip install -r requirements.txt +# 或 +pip install fastmcp +``` + +## 在 Cursor 中使用 + +项目已包含 `.cursor/mcp.json`。打开本仓库后 Cursor 会自动加载 `sproutclaw-cron` MCP 服务器。 + +手动测试(stdio): + +```bash +python mcp-server/server.py +``` + +## 环境变量 + +| 变量 | 说明 | +|---|---| +| `SPROUTCLAW_CRON_ROOT` | 定时任务项目根目录;默认自动推断为 `mcp-server/` 的上级目录 | + +## 工具一览 + +| 工具 | 说明 | +|---|---| +| `cron_list_tasks` | 列出全部任务 | +| `cron_get_task` | 任务详情 | +| `cron_enable_task` / `cron_disable_task` / `cron_toggle_task` | 开关 | +| `cron_run_task` | 立即执行 | +| `cron_get_log` | 读日志 | +| `cron_update_schedule` | 改 cron 表达式 / 描述 / 标签 | +| `cron_sync_cron` | 同步 cron.d / schtasks | +| `cron_create_task` | 从模板创建新任务 | +| `cron_list_templates` | 可用模板 | diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml new file mode 100644 index 0000000..391503d --- /dev/null +++ b/mcp-server/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "sproutclaw-cron-mcp" +version = "0.1.0" +description = "MCP server for SproutClaw Cron task management" +requires-python = ">=3.10" +dependencies = [ + "fastmcp>=3.0", +] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" diff --git a/mcp-server/requirements.txt b/mcp-server/requirements.txt new file mode 100644 index 0000000..2f111f9 --- /dev/null +++ b/mcp-server/requirements.txt @@ -0,0 +1 @@ +fastmcp>=3.0 diff --git a/mcp-server/server.py b/mcp-server/server.py new file mode 100644 index 0000000..34462de --- /dev/null +++ b/mcp-server/server.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""SproutClaw Cron MCP 服务器 — 通过 stdio 暴露定时任务管理工具。""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import sys +from pathlib import Path +from typing import Literal + +from fastmcp import FastMCP + +MCP_DIR = Path(__file__).resolve().parent +CRON_ROOT = Path(os.environ.get("SPROUTCLAW_CRON_ROOT", MCP_DIR.parent)).resolve() +LIB_DIR = CRON_ROOT / "lib" + +if str(LIB_DIR) not in sys.path: + sys.path.insert(0, str(LIB_DIR)) + +from manager import ( # noqa: E402 + build_task_info, + get_context, + iter_task_ids, + list_tasks, + read_log_tail, + run_task, + set_task_state, + sync_cron_d, + toggle_task, + update_task_schedule, +) +from runner import task_is_enabled # noqa: E402 + +TEMPLATE_DIRS: dict[str, str] = { + "python": "_template", + "javascript": "_template-javascript", + "bash": "_template-bash", + "powershell": "_template-powershell", +} + +TASK_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$") +DEFAULT_CRON_ROOTS = ( + "/shumengya/project/agent/sproutclaw-cron", + str(CRON_ROOT), + str(CRON_ROOT).replace("\\", "/"), +) + +mcp = FastMCP( + name="sproutclaw-cron", + instructions=( + "SproutClaw 定时任务系统。先 cron_list_tasks 了解任务;" + "新建任务用 cron_create_task(默认禁用);" + "改调度用 cron_update_schedule;" + "试跑用 cron_run_task 后 cron_get_log 查日志。" + f" 项目根目录: {CRON_ROOT}" + ), +) + + +def _root() -> Path: + return CRON_ROOT + + +def _task_dict(task_id: str) -> dict[str, object]: + return build_task_info(task_id, _root()).to_dict() + + +def _json(data: object) -> str: + return json.dumps(data, ensure_ascii=False, indent=2) + + +def _rewrite_schedule_cron(schedule_file: Path, template_name: str, task_id: str) -> None: + text = schedule_file.read_text(encoding="utf-8") + text = text.replace(template_name, task_id) + cron_root_posix = _root().as_posix() + for old in DEFAULT_CRON_ROOTS: + if old and old != cron_root_posix: + text = text.replace(old, cron_root_posix) + text = re.sub( + r"(/usr/bin/python3\s+)\S+/cronctl\.py", + rf"\g<1>{cron_root_posix}/cronctl.py", + text, + ) + schedule_file.write_text(text, encoding="utf-8") + + +@mcp.tool(annotations={"readOnlyHint": True}) +def cron_list_tasks() -> str: + """列出所有定时任务(含 .disabled/ 下已关闭的任务)及状态、调度、runtime。""" + tasks = [info.to_dict() for info in list_tasks(_root())] + return _json({"count": len(tasks), "tasks": tasks}) + + +@mcp.tool(annotations={"readOnlyHint": True}) +def cron_get_task(task_id: str) -> str: + """获取单个任务的详细信息(状态、cron 表达式、描述、日志大小、标签等)。""" + return _json(_task_dict(task_id)) + + +@mcp.tool +def cron_enable_task(task_id: str) -> str: + """启用任务:移出 .disabled/ 并同步系统 cron / Windows 计划任务。""" + info, message = set_task_state(task_id, True, _root()) + return _json({"task": info.to_dict(), "message": message}) + + +@mcp.tool +def cron_disable_task(task_id: str) -> str: + """禁用任务:移入 .disabled/(cron 条目可保留,到点自动跳过)。""" + info, message = set_task_state(task_id, False, _root()) + return _json({"task": info.to_dict(), "message": message}) + + +@mcp.tool +def cron_toggle_task(task_id: str) -> str: + """切换任务启用/禁用状态。""" + info, message = toggle_task(task_id, _root()) + return _json({"task": info.to_dict(), "message": message}) + + +@mcp.tool +def cron_run_task(task_id: str) -> str: + """立即执行一次任务(同步等待完成),返回 exit code。禁用的任务会直接跳过。""" + exit_code = run_task(task_id, _root()) + return _json({"task_id": task_id, "exit_code": exit_code}) + + +@mcp.tool(annotations={"readOnlyHint": True}) +def cron_get_log(task_id: str, lines: int = 200) -> str: + """读取任务日志末尾内容(默认 200 行,最多 2000)。""" + lines = max(1, min(lines, 2000)) + content = read_log_tail(task_id, lines, _root()) + return _json({"task_id": task_id, "lines": lines, "content": content}) + + +@mcp.tool +def cron_update_schedule( + task_id: str, + schedule: str, + description: str = "", + tags: list[str] | None = None, +) -> str: + """更新 schedule.cron 中的 cron 五段表达式、描述注释与 task.json 标签,并同步 cron.d。""" + info, message = update_task_schedule( + task_id, + description=description, + schedule=schedule, + tags=tags, + root=_root(), + sync=True, + ) + return _json({"task": info.to_dict(), "message": message}) + + +@mcp.tool +def cron_sync_cron(task_id: str) -> str: + """将 schedule.cron 同步到 /etc/cron.d/(Linux)或 schtasks(Windows),不改开关状态。""" + ctx = get_context(task_id, _root()) + message = sync_cron_d(ctx, task_is_enabled(ctx)) + return _json({"task_id": task_id, "message": message or "已同步"}) + + +@mcp.tool +def cron_create_task( + task_id: str, + runtime: Literal["python", "javascript", "bash", "powershell"] = "python", + enable: bool = False, +) -> str: + """从语言模板复制创建新任务。命名约定 <主机名>-<功能描述>。默认创建后保持禁用。""" + if not TASK_ID_RE.match(task_id): + raise ValueError("task_id 仅允许字母数字、点、连字符与下划线,且不能以特殊符号开头") + + template_name = TEMPLATE_DIRS[runtime] + template_dir = _root() / template_name + if not template_dir.is_dir(): + raise FileNotFoundError(f"模板不存在: {template_name}") + + if task_id in iter_task_ids(_root()): + raise ValueError(f"任务已存在: {task_id}") + + target = _root() / task_id + shutil.copytree(template_dir, target) + schedule_file = target / "schedule.cron" + if schedule_file.is_file(): + _rewrite_schedule_cron(schedule_file, template_name, task_id) + + if runtime == "bash": + run_sh = target / "run.sh" + if run_sh.is_file(): + run_sh.chmod(run_sh.stat().st_mode | 0o111) + + message = "已创建" + if not enable: + info, msg = set_task_state(task_id, False, _root()) + message = msg or "已创建并禁用" + return _json({"task": info.to_dict(), "message": message, "template": template_name}) + + info, msg = set_task_state(task_id, True, _root()) + return _json({"task": info.to_dict(), "message": msg or message, "template": template_name}) + + +@mcp.tool(annotations={"readOnlyHint": True}) +def cron_list_templates() -> str: + """列出可用于 cron_create_task 的语言模板。""" + templates = [] + for runtime, dirname in TEMPLATE_DIRS.items(): + path = _root() / dirname + templates.append( + { + "runtime": runtime, + "template_dir": dirname, + "exists": path.is_dir(), + "entry": { + "python": "run.py", + "javascript": "run.js", + "bash": "run.sh", + "powershell": "run.ps1", + }[runtime], + } + ) + return _json({"templates": templates}) + + +if __name__ == "__main__": + mcp.run() diff --git a/smallmengya-gitea-repo-sync/repos.json b/smallmengya-gitea-repo-sync/repos.json deleted file mode 100644 index 23f955a..0000000 --- a/smallmengya-gitea-repo-sync/repos.json +++ /dev/null @@ -1,21 +0,0 @@ -[ - {"repo": "shumengya/ai-translate", "local": "/shumengya/project/cloudflare/ai-translate"}, - {"repo": "shumengya/cf-doh", "local": "/shumengya/project/cloudflare/cf-doh"}, - {"repo": "shumengya/cf-favicon", "local": "/shumengya/project/cloudflare/cf-favicon"}, - {"repo": "shumengya/cf-ip-geo", "local": "/shumengya/project/cloudflare/cf-ip-geo"}, - {"repo": "shumengya/mengya-nav", "local": "/shumengya/project/cloudflare/mengya-nav"}, - {"repo": "shumengya/InfoGenie", "local": "/shumengya/project/frontend-backend/InfoGenie"}, - {"repo": "shumengya/mengyaconnect", "local": "/shumengya/project/frontend-backend/mengyaconnect"}, - {"repo": "shumengya/mengyadriftbottle", "local": "/shumengya/project/frontend-backend/mengyadriftbottle"}, - {"repo": "shumengya/mengyakeyvault", "local": "/shumengya/project/frontend-backend/mengyakeyvault"}, - {"repo": "shumengya/mengyalinkfly", "local": "/shumengya/project/frontend-backend/mengyalinkfly"}, - {"repo": "shumengya/mengyamonitor", "local": "/shumengya/project/frontend-backend/mengyamonitor"}, - {"repo": "shumengya/mengyanote", "local": "/shumengya/project/frontend-backend/mengyanote"}, - {"repo": "shumengya/mengpost", "local": "/shumengya/project/frontend-backend/mengpost"}, - {"repo": "shumengya/mengyaping", "local": "/shumengya/project/frontend-backend/mengyaping"}, - {"repo": "shumengya/mengyaprofile", "local": "/shumengya/project/frontend-backend/mengyaprofile"}, - {"repo": "shumengya/mengyastore", "local": "/shumengya/project/frontend-backend/mengyastore"}, - {"repo": "shumengya/SproutGate", "local": "/shumengya/project/frontend-backend/SproutGate"}, - {"repo": "shumengya/SmyWorkCollect", "local": "/shumengya/project/frontend-backend/SproutWorkCollect"}, - {"repo": "shumengya/random-background-api","local": "/shumengya/project/frontend/random-background-api"} -] diff --git a/smallmengya-gitea-repo-sync/run.py b/smallmengya-gitea-repo-sync/run.py deleted file mode 100644 index 3ec0cd5..0000000 --- a/smallmengya-gitea-repo-sync/run.py +++ /dev/null @@ -1,202 +0,0 @@ -""" -Gitea 仓库同步:配置见本目录 repos.json。 - -同步逻辑:先 fetch 检查远端是否有新提交,有则 fast-forward merge,无则跳过, -不会无谓拉取;仓库依次串行处理,每条间隔 SYNC_DELAY_SECONDS 秒。 - -env 变量: - BASE_DIR (已废弃,local 字段现在必填) - SYNC_DELAY_SECONDS 每个仓库处理间隔秒数(默认 2) - GIT_SSH_COMMAND git SSH 参数 -""" - -from __future__ import annotations - -import sys as _sys, pathlib as _pl -_sys.path.insert(0, str(_pl.Path(__file__).resolve().parents[1] / "lib")) -del _sys, _pl - -import json -import os -import subprocess -import time -from datetime import datetime -from pathlib import Path - -from shumengya_cron.notify import TaskResult, send_task_summary -from shumengya_cron.runner import ( - TaskContext, - acquire_cron_lock, - append_raw_to_log, - task_is_disabled, - task_logging, -) - - -def _load_repos(path: Path) -> list[tuple[str, Path]]: - """加载 repos.json,local 字段必填,缺失的条目直接跳过。""" - if not path.is_file(): - return [] - data = json.loads(path.read_text(encoding="utf-8")) - result: list[tuple[str, Path]] = [] - for item in data: - owner_repo = item.get("repo", "").strip() - local_s = item.get("local", "").strip() - if not owner_repo or not local_s: - continue - if "/" not in owner_repo: - owner_repo = f"shumengya/{owner_repo}" - result.append((owner_repo, Path(local_s))) - return result - - -def _git(args: list[str], *, cwd: Path | None = None) -> tuple[int, str]: - r = subprocess.run( - ["git", *args], - cwd=str(cwd) if cwd else None, - capture_output=True, - text=True, - env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, - ) - return r.returncode, ((r.stdout or "") + (r.stderr or "")).strip() - - -def run(ctx: TaskContext) -> int: - if task_is_disabled(ctx): - return 0 - - os.environ.setdefault("HOME", "/root") - os.environ.setdefault( - "GIT_SSH_COMMAND", - "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new", - ) - - repos_file = ctx.task_dir / "repos.json" - sync_delay = int(os.environ.get("SYNC_DELAY_SECONDS", "2")) - start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - result = TaskResult() - - def sync_one(owner_repo: str, dir_path: Path, log) -> None: - parts = owner_repo.split("/") - owner, repo = parts[0], parts[-1] - remote_url = f"ssh://git@git.shumengya.top:8022/{owner}/{repo}.git" - - # ── 仓库不存在:直接 clone ─────────────────────────── - if not (dir_path / ".git").is_dir(): - if dir_path.exists(): - log(f"[{owner_repo}] 路径已存在但不是 git 仓库,跳过:{dir_path}") - result.add_skip(f"{owner_repo}:not_git") - return - dir_path.parent.mkdir(parents=True, exist_ok=True) - log(f"[{owner_repo}] clone -> {dir_path}") - rc, out = _git(["clone", remote_url, str(dir_path)]) - if out: - append_raw_to_log(ctx.log_file, out) - if rc == 0: - log(f"[{owner_repo}] clone OK") - result.add_ok(f"{owner_repo}:clone") - else: - log(f"[{owner_repo}] clone 失败,请检查 SSH/权限/网络。") - result.add_fail(f"{owner_repo}:clone_failed") - return - - # ── 仓库已存在:fetch → 检查差异 → merge ───────────── - - # 确认当前分支 - rc, branch = _git(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd=dir_path) - branch = branch.splitlines()[0].strip() if branch else "" - if rc != 0 or not branch: - log(f"[{owner_repo}] detached HEAD,跳过。") - result.add_skip(f"{owner_repo}:detached") - return - - # 找远端名并更新 URL - rc, remote_name = _git(["config", "--get", f"branch.{branch}.remote"], cwd=dir_path) - remote_name = remote_name.splitlines()[0].strip() if remote_name else "" - if not remote_name: - rc2, rn = _git(["remote"], cwd=dir_path) - remote_name = rn.splitlines()[0].strip() if rc2 == 0 and rn else "" - if not remote_name: - log(f"[{owner_repo}] 未找到远端名,跳过。") - result.add_fail(f"{owner_repo}:no_remote") - return - - _git(["remote", "set-url", remote_name, remote_url], cwd=dir_path) - - # 工作区必须干净 - rc, dirty = _git(["status", "--porcelain"], cwd=dir_path) - if dirty: - log(f"[{owner_repo}] 工作区有改动,跳过(避免覆盖本地改动)。") - result.add_skip(f"{owner_repo}:dirty") - return - - # fetch 远端最新 - rc, fetch_out = _git(["fetch", "--prune", remote_name, branch], cwd=dir_path) - if fetch_out: - append_raw_to_log(ctx.log_file, fetch_out) - if rc != 0: - log(f"[{owner_repo}] fetch 失败,跳过。") - result.add_fail(f"{owner_repo}:fetch_failed") - return - - # 检查是否有新提交 - rc, count = _git(["rev-list", "HEAD..FETCH_HEAD", "--count"], cwd=dir_path) - if rc == 0 and count.strip() == "0": - log(f"[{owner_repo}] 已是最新,跳过。") - result.add_skip(f"{owner_repo}:already_latest") - return - - # 有更新,fast-forward merge - log(f"[{owner_repo}] 发现 {count.strip()} 个新提交,合并中…") - rc, merge_out = _git(["merge", "--ff-only", "FETCH_HEAD"], cwd=dir_path) - if merge_out: - append_raw_to_log(ctx.log_file, merge_out) - if rc == 0: - log(f"[{owner_repo}] OK") - result.add_ok(f"{owner_repo}:updated") - else: - log(f"[{owner_repo}] merge 失败,请手动检查:git -C {dir_path} merge --ff-only FETCH_HEAD") - result.add_fail(f"{owner_repo}:merge_failed") - - with task_logging(ctx) as log: - with acquire_cron_lock( - ctx.lock_file, - busy_message=os.environ.get("CRON_LOCK_BUSY_MSG", "已有同步任务在运行,跳过本次。"), - log=log, - ) as locked: - if not locked: - return 0 - - log("start") - log(f"repos_file={repos_file}") - log(f"sync_delay_seconds={sync_delay}") - - if not repos_file.is_file(): - log(f"仓库列表文件不存在:{repos_file}") - result.add_fail("repos_file:missing") - send_task_summary(ctx, result, start_time, log=log) - return 1 - - repo_items = _load_repos(repos_file) - log(f"共 {len(repo_items)} 个仓库,依次处理") - - for idx, (owner_repo, dir_path) in enumerate(repo_items): - sync_one(owner_repo, dir_path, log) - if sync_delay > 0 and idx < len(repo_items) - 1: - time.sleep(sync_delay) - - log("end") - send_task_summary(ctx, result, start_time, log=log) - - return 0 if result.success else 1 - - -def main() -> int: - import pathlib - task_id = pathlib.Path(__file__).parent.name - ctx = TaskContext.from_task_id(task_id) - return run(ctx) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/smallmengya-gitea-repo-sync/schedule.cron b/smallmengya-gitea-repo-sync/schedule.cron deleted file mode 100644 index f7dd3a7..0000000 --- a/smallmengya-gitea-repo-sync/schedule.cron +++ /dev/null @@ -1,10 +0,0 @@ -SHELL=/bin/bash -PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -HOME=/root -CRON_MAIL_ENABLED=1 - -# 每天 00:00 按 repos.json 同步 Gitea 仓库到本地,仅在有新提交时 fast-forward 合并 -# 将本文件复制到 /etc/cron.d/smallmengya-gitea-repo-sync 即可生效 -# 开关:bash /shumengya/project/agent/sproutclaw-cron/smallmengya-gitea-repo-sync/switch.sh on|off|toggle -# 统一管理:python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py status|enable|disable|toggle smallmengya-gitea-repo-sync|all -0 0 * * * root /usr/bin/python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run smallmengya-gitea-repo-sync >/dev/null 2>&1 diff --git a/smallmengya-gitea-repo-sync/switch.sh b/smallmengya-gitea-repo-sync/switch.sh deleted file mode 100755 index f5e4224..0000000 --- a/smallmengya-gitea-repo-sync/switch.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -task_id="$(basename "$(dirname "$0")")" -action="${1:-toggle}" -shift || true - -exec python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py "$action" "$task_id" "$@" diff --git a/start-webui.bat b/start-webui.bat new file mode 100644 index 0000000..9fae12b --- /dev/null +++ b/start-webui.bat @@ -0,0 +1,32 @@ +@echo off +setlocal EnableExtensions + +cd /d "%~dp0" + +set "BACKEND=%~dp0webui\backend" +set "VENV=%BACKEND%\.venv" +set "HOST=%SPROUTCLAW_CRON_WEB_HOST%" +set "PORT=%SPROUTCLAW_CRON_WEB_PORT%" + +if not defined HOST set "HOST=0.0.0.0" +if not defined PORT set "PORT=8765" + +if not exist "%VENV%\Scripts\python.exe" ( + echo [webui] creating venv... + python -m venv "%VENV%" + if errorlevel 1 ( + echo [webui] venv failed. Install Python and add it to PATH. + exit /b 1 + ) + echo [webui] installing deps... + "%VENV%\Scripts\python.exe" -m pip install -r "%BACKEND%\requirements.txt" + if errorlevel 1 exit /b 1 +) + +set "URL=http://127.0.0.1:%PORT%/" +echo [webui] starting: %URL% +start "" "%URL%" + +"%VENV%\Scripts\uvicorn.exe" main:app --app-dir "%BACKEND%" --host %HOST% --port %PORT% --reload --reload-dir "%BACKEND%" --reload-dir "%~dp0lib" + +endlocal diff --git a/webui/backend/main.py b/webui/backend/main.py index 5b8968d..2831595 100644 --- a/webui/backend/main.py +++ b/webui/backend/main.py @@ -17,16 +17,17 @@ FRONTEND_DIR = Path(__file__).resolve().parents[1] / "frontend" sys.path.insert(0, str(CRON_ROOT / "lib")) -from shumengya_cron.manager import ( # noqa: E402 +from manager import ( # noqa: E402 build_task_info, list_tasks, read_log_tail, set_task_state, sync_cron_d, toggle_task, + update_task_schedule, ) -from shumengya_cron.manager import get_context # noqa: E402 -from shumengya_cron.runner import task_is_enabled # noqa: E402 +from manager import get_context # noqa: E402 +from runner import task_is_enabled # noqa: E402 app = FastAPI(title="SproutClaw Cron", version="1.0.0") @@ -45,6 +46,18 @@ class RunResponse(BaseModel): pid: int | None = None +class ScheduleUpdate(BaseModel): + description: str = "" + schedule: str + tags: list[str] = [] + + +class ScheduleUpdateResponse(BaseModel): + ok: bool = True + message: str = "" + task: dict[str, object] + + def _task_not_found(exc: FileNotFoundError) -> HTTPException: return HTTPException(status_code=404, detail=str(exc)) @@ -62,6 +75,13 @@ def health() -> dict[str, str]: return {"status": "ok"} +@app.get("/api/runtimes") +def api_runtimes() -> list[dict[str, object]]: + from runtime_probe import probe_all_runtimes + + return probe_all_runtimes() + + @app.get("/api/tasks") def api_list_tasks() -> list[dict[str, object]]: return [task.to_dict() for task in list_tasks(CRON_ROOT)] @@ -112,6 +132,27 @@ def api_sync_cron(task_id: str) -> MessageResponse: raise _task_not_found(exc) from exc +@app.patch("/api/tasks/{task_id}/schedule", response_model=ScheduleUpdateResponse) +def api_update_schedule(task_id: str, body: ScheduleUpdate) -> ScheduleUpdateResponse: + try: + info, message = update_task_schedule( + task_id, + description=body.description, + schedule=body.schedule, + tags=body.tags, + root=CRON_ROOT, + sync=True, + ) + return ScheduleUpdateResponse( + message=message or "已保存", + task=info.to_dict(), + ) + except FileNotFoundError as exc: + raise _task_not_found(exc) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/tasks/{task_id}/run", response_model=RunResponse) def api_run_task(task_id: str) -> RunResponse: try: diff --git a/webui/frontend/app.js b/webui/frontend/app.js index ef84c82..6714e9c 100644 --- a/webui/frontend/app.js +++ b/webui/frontend/app.js @@ -2,6 +2,7 @@ const API = "/api"; const REFRESH_INTERVAL = 15000; const els = { + runtimes: document.getElementById("runtimes"), taskBody: document.getElementById("task-body"), taskCards: document.getElementById("task-cards"), stats: document.getElementById("stats"), @@ -13,12 +14,25 @@ const els = { logTitle: document.getElementById("log-title"), logSubtitle: document.getElementById("log-subtitle"), logContent: document.getElementById("log-content"), - sidebar: document.getElementById("sidebar"), - overlay: document.getElementById("overlay"), + editDialog: document.getElementById("edit-dialog"), + editForm: document.getElementById("edit-form"), + editTitle: document.getElementById("edit-title"), + editDescription: document.getElementById("edit-description"), + editMinute: document.getElementById("edit-minute"), + editHour: document.getElementById("edit-hour"), + editDom: document.getElementById("edit-dom"), + editMonth: document.getElementById("edit-month"), + editDow: document.getElementById("edit-dow"), + editPreview: document.getElementById("edit-preview"), + cronPresets: document.getElementById("cron-presets"), + editTagListEl: document.getElementById("edit-tags"), + editTagInput: document.getElementById("edit-tag-input"), }; let allTasks = []; let currentLogTaskId = null; +let currentEditTaskId = null; +let editTagList = []; let refreshTimer = null; const escapeHtml = (str) => @@ -61,19 +75,23 @@ async function api(path, options = {}) { }); const data = await res.json().catch(() => ({})); if (!res.ok) { - throw new Error(data.detail || data.message || `请求失败 (${res.status})`); + const detail = data.detail; + const msg = Array.isArray(detail) + ? detail.map((d) => d.msg || d).join("; ") + : detail || data.message || `请求失败 (${res.status})`; + throw new Error(msg); } return data; } function statusMarkup(task) { if (task.running) { - return '运行中'; + return '运行中'; } if (task.enabled) { - return '已开启'; + return '已开启'; } - return '已关闭'; + return '已关闭'; } function scheduleMarkup(task) { @@ -97,39 +115,173 @@ function actionButtons(task) {
+
`; } +const CRON_FIELD_KEYS = ["editMinute", "editHour", "editDom"]; + +function parseCron(expr) { + const parts = (expr || "0 8 * * *").trim().split(/\s+/); + return { + minute: parts[0] ?? "0", + hour: parts[1] ?? "8", + dom: parts[2] ?? "*", + month: parts[3] ?? "*", + dow: parts[4] ?? "*", + }; +} + +const CRON_DOW_OPTIONS = [ + ["*", "每"], + ["0", "周日"], + ["1", "周一"], + ["2", "周二"], + ["3", "周三"], + ["4", "周四"], + ["5", "周五"], + ["6", "周六"], +]; + +const CRON_MONTH_OPTIONS = [ + ["*", "每"], + ...Array.from({ length: 12 }, (_, i) => [String(i + 1), `${i + 1}月`]), +]; + +function resetSelectOptions(selectEl, options, value) { + selectEl.innerHTML = options.map( + ([val, label]) => `` + ).join(""); + if ([...selectEl.options].some((opt) => opt.value === value)) { + selectEl.value = value; + return; + } + const opt = document.createElement("option"); + opt.value = value; + opt.textContent = value; + opt.selected = true; + selectEl.appendChild(opt); +} + +function resetDowSelect(value) { + resetSelectOptions(els.editDow, CRON_DOW_OPTIONS, value); +} + +function resetMonthSelect(value) { + resetSelectOptions(els.editMonth, CRON_MONTH_OPTIONS, value); +} + +function fillCronForm(expr) { + const { minute, hour, dom, month, dow } = parseCron(expr); + els.editMinute.value = minute; + els.editHour.value = hour; + els.editDom.value = dom; + resetMonthSelect(month); + resetDowSelect(dow); +} + +function buildScheduleFromForm() { + const values = CRON_FIELD_KEYS.map((key) => { + const raw = els[key].value.trim(); + return raw === "" ? "*" : raw; + }); + values.push(els.editMonth.value.trim() || "*"); + values.push(els.editDow.value.trim() || "*"); + return values.join(" "); +} + +function updateEditPreview() { + try { + els.editPreview.textContent = buildScheduleFromForm() || "—"; + } catch { + els.editPreview.textContent = "—"; + } +} + +const MAX_TASK_TAGS = 4; + +const RUNTIME_LABELS = { + python: "Python", + javascript: "JavaScript", + bash: "Bash", + powershell: "PowerShell", +}; + +function runtimeLabel(task) { + return RUNTIME_LABELS[task.runtime] || task.runtime || "Python"; +} + +function displayTags(task) { + const tags = Array.isArray(task.tags) ? task.tags.filter(Boolean) : []; + if (tags.length) return tags.slice(0, MAX_TASK_TAGS); + return [runtimeLabel(task)]; +} + +function tagsMarkup(task) { + return displayTags(task) + .map((tag) => `${escapeHtml(tag)}`) + .join(""); +} + function nameMarkup(task) { return ` -
${escapeHtml(task.task_id)}
+
+ ${escapeHtml(task.task_id)} + ${tagsMarkup(task)} +
${task.description ? `
${escapeHtml(task.description)}
` : ""} `; } +function renderRuntimes(items) { + els.runtimes.innerHTML = items + .map((item) => { + const version = item.available + ? escapeHtml(item.version || "—") + : ''; + const stateClass = item.available ? "is-ok" : "is-missing"; + return ` +
+ ${escapeHtml(item.name)} + ${version} +
+ `; + }) + .join(""); +} + +async function loadRuntimes() { + try { + const items = await api("/runtimes"); + renderRuntimes(items); + } catch { + els.runtimes.innerHTML = '
运行时检测失败
'; + } +} + function renderStats(tasks) { const enabled = tasks.filter((t) => t.enabled).length; const running = tasks.filter((t) => t.running).length; const off = tasks.length - enabled; els.stats.innerHTML = ` -
- 任务总数 - ${tasks.length} +
+ 任务总数 + ${tasks.length}
-
- 已开启 - ${enabled} +
+ 已开启 + ${enabled}
-
- 已关闭 - ${off} +
+ 已关闭 + ${off}
-
- 运行中 - ${running} +
+ 运行中 + ${running}
`; els.summaryText.textContent = running @@ -167,18 +319,18 @@ function renderTasks(tasks) {
${nameMarkup(task)}
${statusMarkup(task)}
-
-
+
+
调度 ${scheduleMarkup(task)}
-
+
日志 - ${formatBytes(task.log_size)} - ${task.log_updated ? escapeHtml(task.log_updated) : "无记录"} +
${formatBytes(task.log_size)}
+
${task.log_updated ? escapeHtml(task.log_updated) : "无记录"}
- ${actionButtons(task)} +
${actionButtons(task)}
` ) @@ -188,10 +340,14 @@ function renderTasks(tasks) { function applyFilter() { const q = els.filter.value.trim().toLowerCase(); const filtered = q - ? allTasks.filter((t) => - t.task_id.toLowerCase().includes(q) || - (t.description || "").toLowerCase().includes(q) - ) + ? allTasks.filter((t) => { + const tagText = displayTags(t).join(" ").toLowerCase(); + return ( + t.task_id.toLowerCase().includes(q) || + (t.description || "").toLowerCase().includes(q) || + tagText.includes(q) + ); + }) : allTasks; renderTasks(filtered); } @@ -203,6 +359,7 @@ async function loadTasks() { renderStats(tasks); applyFilter(); els.lastRefresh.textContent = `已刷新 ${nowText()}`; + await loadRuntimes(); } catch (err) { els.taskBody.innerHTML = `${escapeHtml(err.message)}`; els.taskCards.innerHTML = `
${escapeHtml(err.message)}
`; @@ -217,6 +374,10 @@ async function handleAction(action, taskId) { await openLog(taskId); return; } + if (action === "edit") { + openEdit(taskId); + return; + } if (action === "run") { const res = await api(`/tasks/${encodeURIComponent(taskId)}/run`, { method: "POST" }); showToast(res.ok === false ? res.message : `已启动 ${taskId}`); @@ -256,6 +417,95 @@ async function refreshLog() { } } +function renderEditTags() { + if (!els.editTagListEl) return; + els.editTagListEl.innerHTML = editTagList + .map( + (tag, index) => ` + + ${escapeHtml(tag)} + + + ` + ) + .join(""); + const atLimit = editTagList.length >= MAX_TASK_TAGS; + if (els.editTagInput) els.editTagInput.disabled = atLimit; + const addBtn = document.getElementById("btn-tag-add"); + if (addBtn) addBtn.disabled = atLimit; +} + +function addEditTag() { + if (!els.editTagInput) return; + const value = els.editTagInput.value.trim(); + if (!value) return; + if (editTagList.length >= MAX_TASK_TAGS) { + showToast(`最多 ${MAX_TASK_TAGS} 个标签`, true); + return; + } + if (editTagList.includes(value)) { + showToast("标签已存在", true); + return; + } + editTagList.push(value); + els.editTagInput.value = ""; + renderEditTags(); +} + +function removeEditTag(index) { + if (index < 0 || index >= editTagList.length) return; + editTagList.splice(index, 1); + renderEditTags(); +} + +function openEdit(taskId) { + const task = allTasks.find((t) => t.task_id === taskId); + if (!task) { + showToast("任务不存在", true); + return; + } + + currentEditTaskId = taskId; + els.editTitle.textContent = taskId; + els.editDescription.value = task.description || ""; + editTagList = [...displayTags(task)]; + if (els.editTagInput) els.editTagInput.value = ""; + renderEditTags(); + fillCronForm(task.schedule || "0 8 * * *"); + updateEditPreview(); + els.editDialog.showModal(); +} + +async function saveEdit(event) { + event.preventDefault(); + if (!currentEditTaskId) return; + + const schedule = buildScheduleFromForm(); + if (!schedule || schedule.split(/\s+/).length !== 5) { + showToast("请填写完整的分、时、日、月、周", true); + return; + } + + try { + els.btnEditSave.disabled = true; + const res = await api(`/tasks/${encodeURIComponent(currentEditTaskId)}/schedule`, { + method: "PATCH", + body: JSON.stringify({ + description: els.editDescription.value.trim(), + schedule, + tags: editTagList, + }), + }); + els.editDialog.close(); + showToast(res.message || "已保存"); + await loadTasks(); + } catch (err) { + showToast(err.message, true); + } finally { + els.btnEditSave.disabled = false; + } +} + function bindActions(root) { root.addEventListener("click", (event) => { const btn = event.target.closest("[data-action]"); @@ -264,31 +514,53 @@ function bindActions(root) { }); } -function closeSidebar() { - els.sidebar.classList.remove("open"); - els.overlay.hidden = true; -} - -document.getElementById("btn-refresh").addEventListener("click", loadTasks); document.getElementById("btn-refresh-top").addEventListener("click", loadTasks); document.getElementById("btn-log-refresh").addEventListener("click", refreshLog); document.getElementById("btn-log-close").addEventListener("click", () => els.logDialog.close()); -els.filter.addEventListener("input", applyFilter); - -document.getElementById("menu-toggle").addEventListener("click", () => { - els.sidebar.classList.add("open"); - els.overlay.hidden = false; +document.getElementById("btn-edit-close").addEventListener("click", () => els.editDialog.close()); +document.getElementById("btn-edit-cancel").addEventListener("click", () => els.editDialog.close()); +els.editForm.addEventListener("submit", saveEdit); +els.editForm.addEventListener("click", (event) => { + if (event.target.closest("#btn-tag-add")) { + event.preventDefault(); + addEditTag(); + return; + } + const removeBtn = event.target.closest(".tag-remove"); + if (removeBtn) { + event.preventDefault(); + removeEditTag(Number(removeBtn.dataset.index)); + } }); -els.overlay.addEventListener("click", closeSidebar); +els.editForm.addEventListener("keydown", (event) => { + if (event.target !== els.editTagInput || event.key !== "Enter") return; + event.preventDefault(); + addEditTag(); +}); +CRON_FIELD_KEYS.forEach((key) => els[key].addEventListener("input", updateEditPreview)); +els.editMonth.addEventListener("change", updateEditPreview); +els.editDow.addEventListener("change", updateEditPreview); +els.cronPresets.addEventListener("click", (event) => { + const btn = event.target.closest("[data-preset]"); + if (!btn) return; + fillCronForm(btn.dataset.preset); + updateEditPreview(); +}); +els.btnEditSave = document.getElementById("btn-edit-save"); +els.filter.addEventListener("input", applyFilter); els.logDialog.addEventListener("click", (event) => { if (event.target === els.logDialog) els.logDialog.close(); }); +els.editDialog.addEventListener("click", (event) => { + if (event.target === els.editDialog) els.editDialog.close(); +}); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { if (els.logDialog.open) els.logDialog.close(); - else if (els.sidebar.classList.contains("open")) closeSidebar(); + else if (els.editDialog.open) els.editDialog.close(); + return; } if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { event.preventDefault(); diff --git a/webui/frontend/index.html b/webui/frontend/index.html index b42baca..f25802b 100644 --- a/webui/frontend/index.html +++ b/webui/frontend/index.html @@ -8,54 +8,34 @@
- - - -
-
-

定时任务管理

+

SproutClaw Cron

+

定时任务管理

加载中…

- +
-
+
+ +
-
-

任务清单

-
- -
+
@@ -78,6 +58,109 @@ + +
+
+
+ 编辑任务 +

任务

+
+ +
+
+ +
+ 标签 +

最多 4 个,含运行时标签(如 Python、JavaScript)

+
+
+ + +
+
+
+ 执行调度 +
+ + + + + +
+
+ + + + + + + + + + + + +
+
    +
  • 0 8 * * * 每天 08:00
  • +
  • 0 8 * * 1 每周一 08:00
  • +
  • 0 8 1 * * 每月 1 日 08:00
  • +
  • 0 0 1 1 * 每年 1 月 1 日 00:00
  • +
  • 0 9 * * 1-5 周一到周五 09:00
  • +
  • */15 * * * * 每 15 分钟
  • +
+

Linux cron 五段:分 时 日 月 周* 任意;日 1–31,月 1–12,周 0=周日 … 6=周六。日/周同时指定时取交集。

+

当前表达式:

+
+
+
+ + +
+ +
+
@@ -95,6 +178,6 @@ - + diff --git a/webui/frontend/styles.css b/webui/frontend/styles.css index c15a8ab..84d0621 100644 --- a/webui/frontend/styles.css +++ b/webui/frontend/styles.css @@ -1,38 +1,36 @@ -/* SproutClaw Cron · 文档式后台管理面板 */ +/* SproutClaw Cron · 管理面板 */ :root { - /* 配色:暖纸 + 墨色 + 萌芽绿点缀 */ - --paper: #faf9f7; + --paper: #f4f6f5; --paper-raised: #ffffff; - --ink: #1c1a17; - --ink-soft: #6b6660; - --ink-faint: #9a948c; - --rule: #e7e2da; - --rule-strong: #d8d2c8; + --ink: #1a1f1c; + --ink-soft: #5c6560; + --ink-faint: #8a938d; + --rule: #e4e8e6; + --rule-strong: #d0d6d2; --accent: #2d6a4f; - --accent-hover: #245340; - --accent-soft: #e6efe9; - --accent-line: #2d6a4f; + --accent-hover: #245a42; + --accent-soft: #e8f3ed; + --accent-line: #40916c; - --status-on: #2d6a4f; - --status-on-bg: #e6efe9; - --status-off: #9a948c; - --status-off-bg: #efece6; + --status-on: #1b6b4a; + --status-on-bg: #e8f3ed; + --status-off: #6b7280; + --status-off-bg: #f1f3f2; --status-run: #b45309; - --status-run-bg: #fbf3e6; + --status-run-bg: #fef3e2; - --danger: #9b2c2c; - --danger-bg: #f7eded; - --danger-line: #e6c9c9; + --danger: #b42318; + --danger-bg: #fef3f2; + --danger-line: #fecdca; - --shadow: 0 1px 2px rgba(28, 26, 23, 0.04); + --shadow-sm: 0 1px 2px rgba(26, 31, 28, 0.05); + --shadow-md: 0 4px 16px rgba(26, 31, 28, 0.07); - --radius: 6px; - --sidebar-w: 232px; - --doc-max: 1080px; + --radius: 10px; + --radius-sm: 6px; --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, "PingFang SC", "Microsoft YaHei", sans-serif; - --font-serif: "Iowan Old Style", "Palatino Linotype", Palatino, "Source Serif Pro", Georgia, "Songti SC", serif; --font-mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace; } @@ -43,11 +41,10 @@ html, body { padding: 0; min-height: 100%; font-family: var(--font-sans); - font-size: 15px; + font-size: 14px; color: var(--ink); background: var(--paper); -webkit-font-smoothing: antialiased; - text-rendering: optimizeLegibility; } body { overflow-x: hidden; } @@ -61,395 +58,532 @@ table, caption, th, td { font: inherit; } code, pre, kbd, samp { font-family: var(--font-mono); } -/* ===== 顶部萌芽绿细条(签名) ===== */ .layout::before { content: ""; position: fixed; top: 0; left: 0; right: 0; height: 3px; - background: var(--accent-line); + background: linear-gradient(90deg, var(--accent), var(--accent-line)); z-index: 40; } -.layout { min-height: 100vh; display: flex; padding-top: 3px; } - -/* ===== 侧栏 ===== */ -.sidebar { - width: var(--sidebar-w); - flex-shrink: 0; - background: var(--paper-raised); - border-right: 1px solid var(--rule); - padding: 1.5rem 1rem 1.25rem; - display: flex; - flex-direction: column; - position: sticky; - top: 3px; - height: calc(100vh - 3px); - z-index: 20; -} - -.brand { - display: flex; - gap: 0.7rem; - align-items: center; - padding: 0 0.25rem 1.1rem; - border-bottom: 1px solid var(--rule); - margin-bottom: 1.1rem; -} - -.brand-text h1 { - margin: 0; - font-family: var(--font-serif); - font-size: 1.05rem; - font-weight: 600; - line-height: 1.15; - letter-spacing: 0.01em; -} -.brand-text p { - margin: 0.2rem 0 0; - font-size: 0.74rem; - color: var(--ink-soft); - letter-spacing: 0.02em; -} - -.sidebar-nav { display: flex; flex-direction: column; gap: 0.15rem; } - -.nav-item { - border: none; - background: transparent; - text-align: left; - padding: 0.55rem 0.6rem; - border-radius: var(--radius); - color: var(--ink-soft); - font-size: 0.88rem; - display: flex; - align-items: center; - gap: 0.5rem; - transition: background 0.12s, color 0.12s; -} -.nav-item:hover { background: var(--paper); color: var(--ink); } -.nav-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; } -.nav-dot { - width: 6px; height: 6px; border-radius: 50%; - background: currentColor; - opacity: 0.55; -} -.nav-item.active .nav-dot { opacity: 1; } - -.sidebar-foot { margin-top: auto; padding-top: 1rem; border-top: 1px solid var(--rule); } -.auto-note { - margin: 0.5rem 0 0; - font-size: 0.72rem; - color: var(--ink-faint); - text-align: center; -} +.layout { min-height: 100vh; padding-top: 3px; } /* ===== 主区 ===== */ -.main { flex: 1; min-width: 0; } +.main { min-width: 0; display: flex; flex-direction: column; min-height: calc(100vh - 3px); } .topbar { display: flex; - align-items: flex-start; + align-items: center; gap: 1rem; - padding: 1.5rem 2rem 1.25rem; - background: var(--paper); + padding: 1.125rem 1.75rem; + background: var(--paper-raised); border-bottom: 1px solid var(--rule); + box-shadow: var(--shadow-sm); } -.menu-toggle { display: none; } .masthead { flex: 1; min-width: 0; } -.kicker { - display: block; - font-size: 0.7rem; - letter-spacing: 0.16em; + +.brand-kicker { + margin: 0 0 0.125rem; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); - font-weight: 600; - margin-bottom: 0.3rem; } -.masthead h2 { + +.masthead h1 { margin: 0; - font-family: var(--font-serif); - font-size: 1.6rem; - font-weight: 600; - line-height: 1.2; - letter-spacing: 0.005em; + font-size: 1.375rem; + font-weight: 700; + line-height: 1.25; + letter-spacing: -0.02em; } .masthead-meta { - margin: 0.45rem 0 0; - font-size: 0.84rem; + margin: 0.25rem 0 0; + font-size: 0.8125rem; color: var(--ink-soft); } -/* ===== 文档主体 ===== */ -.document { - max-width: var(--doc-max); - margin: 0 auto; - padding: 1.75rem 2rem 3rem; +.kicker { + display: block; + font-size: 0.6875rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); + font-weight: 600; + margin-bottom: 0.25rem; } -.doc-meta { - display: flex; - flex-wrap: wrap; - gap: 0.5rem 2rem; - padding-bottom: 1.25rem; - border-bottom: 1px solid var(--rule); - margin-bottom: 1.5rem; +/* ===== 内容区 ===== */ +.document { + flex: 1; + padding: 1.5rem 1.75rem 2.5rem; } -.meta-item { display: flex; align-items: baseline; gap: 0.45rem; min-width: 0; } -.meta-item .label { - font-size: 0.72rem; - letter-spacing: 0.1em; + +/* 本机运行时 */ +.runtime-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.875rem; + margin-bottom: 1rem; +} + +.runtime-card { + background: var(--paper-raised); + border: 1px solid var(--rule); + border-radius: var(--radius); + padding: 0.75rem 1rem; + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} +.runtime-card.is-ok { border-color: #c5dfd0; } +.runtime-card.is-missing { border-color: #e8dcd8; background: #fdfbfb; } + +.runtime-name { + font-size: 0.8125rem; + font-weight: 600; + color: var(--ink-soft); +} +.runtime-version { + font-size: 0.9375rem; + font-weight: 600; + color: var(--ink); + word-break: break-all; + line-height: 1.35; +} +.runtime-card.is-ok .runtime-version { color: var(--accent); } +.runtime-missing { + color: var(--ink-faint); + font-weight: 500; +} + +/* 统计卡片 */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.875rem; + margin-bottom: 1.25rem; +} + +.stat-card { + background: var(--paper-raised); + border: 1px solid var(--rule); + border-radius: var(--radius); + padding: 0.875rem 1rem; + box-shadow: var(--shadow-sm); +} +.stat-label { + display: block; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-faint); + margin-bottom: 0.375rem; } -.meta-item .value { - font-family: var(--font-serif); - font-size: 1.35rem; - font-weight: 600; +.stat-value { + display: block; + font-size: 1.625rem; + font-weight: 700; line-height: 1; + letter-spacing: -0.03em; + color: var(--ink); } -.meta-item.is-accent .value { color: var(--accent); } -.meta-item.is-run .value { color: var(--status-run); } +.stat-card.stat-accent .stat-value { color: var(--accent); } +.stat-card.stat-run .stat-value { color: var(--status-run); } +/* 工具栏 */ .toolbar { display: flex; align-items: center; gap: 1rem; - margin-bottom: 1.25rem; - flex-wrap: wrap; + margin-bottom: 1rem; } .search { flex: 1; - min-width: 200px; + max-width: 420px; display: flex; align-items: center; gap: 0.5rem; - padding: 0.45rem 0.7rem; + padding: 0.5rem 0.75rem; background: var(--paper-raised); border: 1px solid var(--rule); - border-radius: var(--radius); - transition: border-color 0.12s, box-shadow 0.12s; + border-radius: var(--radius-sm); + transition: border-color 0.15s, box-shadow 0.15s; } -.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.search:focus-within { + border-color: var(--accent-line); + box-shadow: 0 0 0 3px var(--accent-soft); +} +.search-icon { color: var(--ink-faint); flex-shrink: 0; } .search input { flex: 1; border: none; outline: none; background: transparent; font: inherit; - font-size: 0.88rem; - color: var(--ink); + font-size: 0.875rem; min-width: 0; } .search input::placeholder { color: var(--ink-faint); } .last-refresh { - font-size: 0.76rem; + margin-left: auto; + font-size: 0.75rem; color: var(--ink-faint); white-space: nowrap; } -/* ===== 文档分节 ===== */ -.doc-section { margin-top: 0.5rem; } -.section-head { - display: flex; - align-items: baseline; - gap: 0.75rem; - margin-bottom: 0.85rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--rule); -} -.section-head h3 { - margin: 0; - font-family: var(--font-serif); - font-size: 1.1rem; - font-weight: 600; -} -.section-hint { - font-size: 0.78rem; - color: var(--ink-faint); +/* ===== 表格卡片 ===== */ +.doc-section { margin-top: 0; } + +.table-card { + background: var(--paper-raised); + border: 1px solid var(--rule); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); + overflow: hidden; } -/* ===== 任务表格(桌面) ===== */ -.table-wrap { overflow-x: auto; } .task-table { width: 100%; border-collapse: collapse; - font-size: 0.88rem; + table-layout: fixed; + font-size: 0.875rem; } + .task-table th { - padding: 0.55rem 0.75rem; + padding: 0.625rem 1rem; text-align: left; - font-size: 0.68rem; - letter-spacing: 0.12em; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-faint); - font-weight: 600; - border-bottom: 1px solid var(--rule-strong); + background: #fafbfa; + border-bottom: 1px solid var(--rule); white-space: nowrap; } -.task-table td { - padding: 0.85rem 0.75rem; - border-bottom: 1px solid var(--rule); - vertical-align: top; -} -.task-table tbody tr { transition: background 0.1s; } -.task-table tbody tr:hover { background: var(--paper-raised); } -.task-table tbody tr:last-child td { border-bottom: none; } -.col-status { width: 88px; } -.col-actions { width: 1%; white-space: nowrap; } -.col-schedule { width: 170px; } -.col-log { width: 150px; } +.task-table td { + padding: 0.875rem 1rem; + border-bottom: 1px solid var(--rule); + vertical-align: middle; +} + +.task-table tbody tr:last-child td { border-bottom: none; } +.task-table tbody tr { transition: background 0.12s; } +.task-table tbody tr:hover { background: #fafcfb; } + +.col-status { width: 96px; } +.col-name { width: auto; } +.col-schedule { width: 148px; } +.col-log { width: 140px; } +.col-actions { width: 340px; } .task-name { font-weight: 600; - font-size: 0.92rem; + font-size: 0.9375rem; word-break: break-word; + line-height: 1.35; +} +.task-name-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} +.runtime-badge { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.45rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + color: var(--accent); + background: var(--accent-soft); + border: 1px solid #c5dfd0; + flex-shrink: 0; + white-space: nowrap; +} +.task-tag { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.15rem 0.45rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + color: var(--accent); + background: var(--accent-soft); + border: 1px solid #c5dfd0; + flex-shrink: 0; + white-space: nowrap; +} +.task-tag.is-editable { + padding-right: 0.25rem; +} +.tag-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.125rem; + height: 1.125rem; + padding: 0; + border: none; + border-radius: 999px; + background: transparent; + color: #64748b; + font-size: 0.875rem; + line-height: 1; + cursor: pointer; +} +.tag-remove:hover { + background: #e2e8f0; + color: #334155; +} +.tag-editor { + border: none; + padding: 0; + margin: 0 0 1rem; +} +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + min-height: 1.75rem; + margin-bottom: 0.5rem; +} +.tag-add-row { + display: flex; + gap: 0.5rem; + align-items: center; +} +.tag-add-row input { + flex: 1; + min-width: 0; } .task-desc { margin-top: 0.2rem; - font-size: 0.8rem; + font-size: 0.8125rem; color: var(--ink-soft); + line-height: 1.4; word-break: break-word; - overflow-wrap: anywhere; } -.status { +/* 状态徽章 */ +.badge { display: inline-flex; align-items: center; - gap: 0.4rem; - font-size: 0.8rem; - font-weight: 500; + gap: 0.375rem; + padding: 0.25rem 0.625rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; } -.status .dot { - width: 7px; height: 7px; +.badge .dot { + width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } -.status.on { color: var(--status-on); } -.status.on .dot { background: var(--status-on); } -.status.off { color: var(--status-off); } -.status.off .dot { background: var(--status-off); } -.status.run { color: var(--status-run); } -.status.run .dot { +.badge-on { + color: var(--status-on); + background: var(--status-on-bg); +} +.badge-on .dot { background: var(--status-on); } +.badge-off { + color: var(--status-off); + background: var(--status-off-bg); +} +.badge-off .dot { background: var(--status-off); } +.badge-run { + color: var(--status-run); + background: var(--status-run-bg); +} +.badge-run .dot { background: var(--status-run); - box-shadow: 0 0 0 0 var(--status-run-bg); animation: pulse 1.6s ease-out infinite; } @keyframes pulse { - 0% { box-shadow: 0 0 0 0 rgba(180, 83, 9, 0.45); } - 70% { box-shadow: 0 0 0 5px rgba(180, 83, 9, 0); } - 100% { box-shadow: 0 0 0 0 rgba(180, 83, 9, 0); } + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } } -.mono { font-family: var(--font-mono); font-size: 0.8rem; color: var(--ink-soft); } +.mono { font-family: var(--font-mono); font-size: 0.8125rem; color: var(--ink-soft); } + .schedule-code { + display: inline-block; font-family: var(--font-mono); - font-size: 0.78rem; + font-size: 0.75rem; color: var(--ink); - background: var(--paper-raised); + background: var(--paper); border: 1px solid var(--rule); border-radius: 4px; - padding: 0.1rem 0.35rem; + padding: 0.2rem 0.45rem; + white-space: nowrap; } -.log-meta { font-size: 0.78rem; color: var(--ink-soft); line-height: 1.5; } -.log-meta .muted { color: var(--ink-faint); } +.log-meta { font-size: 0.8125rem; color: var(--ink-soft); line-height: 1.45; } +.log-meta .muted { display: block; font-size: 0.75rem; color: var(--ink-faint); margin-top: 0.125rem; } -.actions { display: flex; flex-wrap: wrap; gap: 0.3rem; } +/* 操作按钮组 — 横向排列 */ +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; +} +.actions .btn { + padding: 0.35rem 0.625rem; + font-size: 0.75rem; + white-space: nowrap; +} /* ===== 按钮 ===== */ .btn { border: 1px solid var(--rule); background: var(--paper-raised); color: var(--ink); - border-radius: var(--radius); - padding: 0.4rem 0.7rem; - font-size: 0.8rem; + border-radius: var(--radius-sm); + padding: 0.4375rem 0.875rem; + font-size: 0.8125rem; + font-weight: 500; line-height: 1.2; - transition: background 0.12s, border-color 0.12s, color 0.12s; + transition: background 0.12s, border-color 0.12s, color 0.12s, box-shadow 0.12s; } .btn:hover { background: var(--paper); border-color: var(--rule-strong); } .btn:disabled { opacity: 0.45; cursor: not-allowed; } -.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +.btn:focus-visible { outline: 2px solid var(--accent-line); outline-offset: 1px; } -.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } -.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.btn-primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} -.btn-success { color: var(--accent); border-color: var(--accent-soft); background: var(--accent-soft); } -.btn-success:hover { background: #dce8e1; border-color: #d2e2da; } +.btn-success { + color: var(--accent); + border-color: #b7d4c4; + background: var(--accent-soft); +} +.btn-success:hover { background: #dceee4; } -.btn-danger { color: var(--danger); border-color: var(--danger-line); background: var(--danger-bg); } -.btn-danger:hover { background: #f1e3e3; } +.btn-danger { + color: var(--danger); + border-color: var(--danger-line); + background: var(--danger-bg); +} +.btn-danger:hover { background: #fde8e6; } .btn-ghost { border-color: transparent; background: transparent; } .btn-ghost:hover { background: var(--paper); border-color: var(--rule); } -.btn-block { width: 100%; } .empty { text-align: center; color: var(--ink-faint); - padding: 2.25rem 1rem !important; - font-size: 0.88rem; + padding: 2.5rem 1rem !important; + font-size: 0.875rem; } -/* ===== 移动端任务条目 ===== */ -.task-cards { display: none; } -.task-card { - padding: 1rem 0; - border-bottom: 1px solid var(--rule); +/* ===== 移动端卡片 ===== */ +.task-cards { + display: none; + flex-direction: column; + gap: 0.75rem; } -.task-card:first-child { padding-top: 0.25rem; } -.task-card:last-child { border-bottom: none; padding-bottom: 0.25rem; } + +.task-card { + background: var(--paper-raised); + border: 1px solid var(--rule); + border-radius: var(--radius); + padding: 1rem; + box-shadow: var(--shadow-sm); +} + .task-card-head { display: flex; justify-content: space-between; - gap: 0.75rem; + gap: 0.625rem; align-items: flex-start; } .task-card-head > .name { min-width: 0; flex: 1; } -.task-card-body { - margin: 0.65rem 0; +.task-card-head .badge { flex-shrink: 0; } + +.task-card-meta { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.5rem 1rem; - font-size: 0.82rem; + grid-template-columns: 1fr 1fr; + gap: 0.625rem; + margin-top: 0.875rem; + padding-top: 0.875rem; + border-top: 1px solid var(--rule); } -.task-card-body .field-label { + +.meta-block { + flex: 1 1 calc(50% - 0.375rem); + min-width: 0; + font-size: 0.8125rem; +} + +.field-label { display: block; - font-size: 0.68rem; - letter-spacing: 0.1em; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-faint); - margin-bottom: 0.15rem; + margin-bottom: 0.25rem; } -.task-card .actions { margin-top: 0.75rem; } -.task-card .actions .btn { flex: 1 1 calc(50% - 0.3rem); min-width: 0; text-align: center; } -/* ===== 抽屉遮罩 ===== */ -.overlay { - position: fixed; - inset: 0; - background: rgba(28, 26, 23, 0.32); - z-index: 15; +.task-card-actions { + margin-top: 0.625rem; + padding-top: 0.625rem; + border-top: 1px solid var(--rule); } -.overlay[hidden] { display: none; } -/* ===== 日志弹层 ===== */ +.task-card-actions .actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.375rem; +} + +.task-card-actions .btn { + min-height: 34px; + padding: 0.3rem 0.25rem; + font-size: 0.75rem; + font-weight: 500; + text-align: center; + border-radius: 6px; +} + +/* ===== 弹层 / 表单 ===== */ +.panel-dialog, .log-dialog { - width: min(960px, calc(100vw - 2rem)); - max-height: calc(100vh - 2rem); border: 1px solid var(--rule-strong); - border-radius: 8px; + border-radius: var(--radius); padding: 0; background: var(--paper-raised); - box-shadow: 0 12px 40px rgba(28, 26, 23, 0.18); + box-shadow: var(--shadow-md); color: var(--ink); } -.log-dialog::backdrop { background: rgba(28, 26, 23, 0.42); } +.panel-dialog::backdrop, +.log-dialog::backdrop { background: rgba(26, 31, 28, 0.45); } +.panel-dialog { + width: min(600px, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); +} + +.panel-dialog-head, .log-dialog-head { display: flex; justify-content: space-between; @@ -458,30 +592,205 @@ code, pre, kbd, samp { font-family: var(--font-mono); } padding: 1rem 1.25rem; border-bottom: 1px solid var(--rule); } + +.panel-dialog-title .kicker, .log-dialog-title .kicker { margin-bottom: 0.2rem; } + +.panel-dialog-title h3 { + margin: 0; + font-size: 1rem; + font-weight: 700; + word-break: break-all; +} + +.panel-dialog-body { + padding: 1rem 1.25rem 0.25rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.panel-dialog-foot { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding: 1rem 1.25rem 1.25rem; +} + +.edit-form { margin: 0; } + +.form-field { + display: flex; + flex-direction: column; + gap: 0.375rem; +} +.form-label { + font-size: 0.8125rem; + font-weight: 600; +} +.form-field input[type="text"], +.form-field input[type="time"], +.form-field textarea { + width: 100%; + border: 1px solid var(--rule); + border-radius: var(--radius-sm); + padding: 0.5625rem 0.75rem; + font: inherit; + font-size: 0.875rem; + background: var(--paper-raised); +} +.form-field textarea { + resize: vertical; + min-height: 72px; + line-height: 1.45; +} +.form-field input:focus, +.form-field textarea:focus { + outline: none; + border-color: var(--accent-line); + box-shadow: 0 0 0 3px var(--accent-soft); +} +.form-hint { + font-size: 0.75rem; + color: var(--ink-faint); +} +.form-preview { + margin: 0; + font-size: 0.8125rem; + color: var(--ink-soft); +} +.form-preview code { + font-family: var(--font-mono); + background: var(--paper); + border: 1px solid var(--rule); + border-radius: 4px; + padding: 0.1rem 0.35rem; +} + +.cron-editor { + margin: 0; + padding: 0; + border: none; + min-width: 0; +} +.cron-editor .form-label { + padding: 0; + margin-bottom: 0.5rem; +} + +.cron-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.5rem; +} + +.cron-field { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} +.cron-field-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--ink-faint); + text-align: center; +} + +.cron-field input, +.cron-field select { + width: 100%; + border: 1px solid var(--rule); + border-radius: var(--radius-sm); + padding: 0.5rem 0.375rem; + font: inherit; + font-size: 0.875rem; + text-align: center; + background: var(--paper-raised); +} +.cron-field select { text-align-last: center; cursor: pointer; } +.cron-field input:focus, +.cron-field select:focus { + outline: none; + border-color: var(--accent-line); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.cron-presets { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: 0.75rem; +} +.btn-preset { + padding: 0.3rem 0.55rem !important; + font-size: 0.75rem !important; +} + +.cron-examples { + margin: 0.625rem 0 0; + padding: 0.625rem 0.75rem; + list-style: none; + background: var(--paper); + border: 1px solid var(--rule); + border-radius: var(--radius-sm); + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.35rem 0.75rem; + font-size: 0.75rem; + color: var(--ink-soft); +} +.cron-examples li { + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.cron-examples code { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--ink); + margin-right: 0.25rem; +} + +.form-hint code { + font-family: var(--font-mono); + font-size: 0.75rem; + background: var(--paper); + border: 1px solid var(--rule); + border-radius: 3px; + padding: 0 0.2rem; +} + +.log-dialog { + width: min(960px, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); +} + .log-dialog-title h3 { margin: 0; font-family: var(--font-mono); - font-size: 0.95rem; + font-size: 0.9375rem; font-weight: 600; } -.log-dialog-title p { margin: 0.25rem 0 0; font-size: 0.8rem; color: var(--ink-soft); } -.log-dialog-actions { display: flex; gap: 0.4rem; flex-shrink: 0; } +.log-dialog-title p { margin: 0.25rem 0 0; font-size: 0.8125rem; color: var(--ink-soft); } +.log-dialog-actions { display: flex; gap: 0.375rem; flex-shrink: 0; } .log-content { margin: 0; - padding: 1.1rem 1.25rem; + padding: 1rem 1.25rem; max-height: 62vh; overflow: auto; font-family: var(--font-mono); - font-size: 0.78rem; + font-size: 0.8125rem; line-height: 1.6; white-space: pre-wrap; word-break: break-word; - background: #1b1a17; - color: #e7e2da; + background: #141816; + color: #e2e8e4; + border-radius: 0 0 var(--radius) var(--radius); } -.log-content:empty::before { content: "(暂无日志)"; color: #6b6660; } +.log-content:empty::before { content: "(暂无日志)"; color: #6b7280; } /* ===== Toast ===== */ .toast { @@ -491,76 +800,195 @@ code, pre, kbd, samp { font-family: var(--font-mono); } transform: translateX(-50%); background: var(--ink); color: #fff; - padding: 0.6rem 1.1rem; + padding: 0.625rem 1.125rem; border-radius: 999px; - font-size: 0.84rem; + font-size: 0.8125rem; z-index: 60; - box-shadow: 0 6px 24px rgba(28, 26, 23, 0.25); + box-shadow: var(--shadow-md); } .toast.is-error { background: var(--danger); } /* ===== 响应式 ===== */ -@media (max-width: 920px) { - .topbar { padding: 1.1rem 1.25rem 1rem; } - .document { padding: 1.25rem 1.25rem 2.5rem; } - .masthead h2 { font-size: 1.35rem; } - .meta-item .value { font-size: 1.2rem; } +@media (max-width: 960px) { + .stats-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .col-actions { width: 240px; } +} + +@media (max-width: 820px) { + .topbar { padding: 1rem 1.25rem; } + .document { padding: 1.25rem 1.25rem 2rem; } + .table-card { display: none; } + .task-cards { display: flex; } + .search { max-width: none; } } @media (max-width: 720px) { - .sidebar { - position: fixed; - left: 0; top: 3px; bottom: 0; - height: calc(100vh - 3px); - transform: translateX(-100%); - transition: transform 0.22s ease; - box-shadow: 0 0 40px rgba(28, 26, 23, 0.18); + .topbar { + padding: 0.875rem 1rem; + gap: 0.75rem; + align-items: flex-start; } - .sidebar.open { transform: translateX(0); } - .overlay:not([hidden]) { display: block; } - - .menu-toggle { - display: inline-flex; - align-items: center; - justify-content: center; - height: 38px; - padding: 0 0.9rem; - border: 1px solid var(--rule); - border-radius: var(--radius); - background: var(--paper-raised); - font-size: 0.84rem; + .masthead h1 { + font-size: 1.125rem; + } + .masthead-meta { + font-size: 0.75rem; + } + #btn-refresh-top { flex-shrink: 0; + min-height: 40px; + padding-inline: 0.875rem; } - .topbar { padding: 0.9rem 0.9rem 0.85rem; gap: 0.6rem; } - .masthead h2 { font-size: 1.2rem; } - #btn-refresh-top { flex-shrink: 0; padding-inline: 0.6rem; } + .document { + padding: 0.875rem 1rem 1.5rem; + } - .document { padding: 1rem 0.9rem 2rem; } + /* 统计:单行四列紧凑 */ + .stats-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.5rem; + margin-bottom: 1rem; + } + .runtime-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; + margin-bottom: 0.875rem; + } + .runtime-version { font-size: 0.8125rem; } - /* 统计折叠为单行紧凑元数据 */ - .doc-meta { gap: 0.4rem 1.25rem; padding-bottom: 1rem; margin-bottom: 1.1rem; } - .meta-item .value { font-size: 1.05rem; } - .meta-item .label { font-size: 0.66rem; } + .stat-card { + padding: 0.625rem 0.5rem; + text-align: center; + border-radius: var(--radius-sm); + } + .stat-label { + font-size: 0.625rem; + margin-bottom: 0.25rem; + letter-spacing: 0.04em; + } + .stat-value { + font-size: 1.25rem; + } - .toolbar { gap: 0.6rem; } - .last-refresh { font-size: 0.72rem; } + .toolbar { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + margin-bottom: 0.875rem; + } + .search { + padding: 0.625rem 0.75rem; + } + .search input { font-size: 16px; /* 防止 iOS 缩放 */ } + .last-refresh { + margin-left: 0; + text-align: right; + font-size: 0.6875rem; + } - /* 桌面表格隐藏,启用移动条目 */ - .table-wrap { display: none; } - .task-cards { display: block; } + .task-card { + padding: 0.875rem; + border-radius: var(--radius-sm); + } + .task-card .task-name { font-size: 0.9375rem; } + .task-card .task-desc { + font-size: 0.8125rem; + margin-top: 0.25rem; + line-height: 1.45; + } - .log-dialog-head { flex-direction: column; gap: 0.6rem; } - .log-dialog-actions { width: 100%; } - .log-dialog-actions .btn { flex: 1; } - .log-content { max-height: 55vh; padding: 0.9rem 1rem; } + .task-card-meta { + grid-template-columns: 1fr 1fr; + gap: 0.625rem; + } + + .task-card-actions .actions { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.375rem; + } + .task-card-actions .btn { + min-height: 32px; + padding: 0.25rem 0.2rem; + font-size: 0.6875rem; + } + + .cron-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .cron-examples { + grid-template-columns: 1fr; + } + + .panel-dialog, + .log-dialog { + width: 100vw; + max-width: 100vw; + height: 100dvh; + max-height: 100dvh; + margin: 0; + border: none; + border-radius: 0; + display: flex; + flex-direction: column; + } + .log-dialog-head { + flex-direction: row; + align-items: center; + padding: 0.875rem 1rem; + position: sticky; + top: 0; + background: var(--paper-raised); + z-index: 1; + } + .log-dialog-title h3 { + font-size: 0.875rem; + word-break: break-all; + } + .log-dialog-title p { font-size: 0.75rem; } + .log-dialog-actions { width: auto; } + .log-dialog-actions .btn { + flex: none; + min-height: 36px; + padding-inline: 0.75rem; + } + .log-content { + max-height: none; + flex: 1; + border-radius: 0; + font-size: 0.75rem; + } + + .toast { + left: 1rem; + right: 1rem; + bottom: 1rem; + transform: none; + text-align: center; + border-radius: var(--radius-sm); + } } -@media (max-width: 380px) { - .task-card-body { grid-template-columns: 1fr; } +@media (max-width: 400px) { + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .stat-card { padding: 0.75rem; text-align: left; } + .stat-value { font-size: 1.375rem; } + + .cron-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .task-card-meta { + grid-template-columns: 1fr; + } +} + +@media (min-width: 821px) { + .task-cards { display: none; } } -/* 减少动效偏好 */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation: none !important; } } diff --git a/webui/start.sh b/webui/start.sh index b1a966e..7bcb9fd 100644 --- a/webui/start.sh +++ b/webui/start.sh @@ -13,4 +13,5 @@ fi HOST="${SPROUTCLAW_CRON_WEB_HOST:-0.0.0.0}" PORT="${SPROUTCLAW_CRON_WEB_PORT:-8765}" -exec "$VENV/bin/uvicorn" main:app --app-dir "$BACKEND" --host "$HOST" --port "$PORT" +exec "$VENV/bin/uvicorn" main:app --app-dir "$BACKEND" --host "$HOST" --port "$PORT" \ + --reload --reload-dir "$BACKEND" --reload-dir "$ROOT/lib"