Compare commits

...

53 Commits

Author SHA1 Message Date
admin afd37f961a style(ui-control): remove focus ring, make focus/open states match hover
Build and Deploy / Build and Push Docker Image (push) Successful in 2m10s
- Remove box-shadow focus ring that looked jarring
- Use same background and border color for hover/focus/open states
- More consistent visual feedback without glowing effect
2026-08-08 16:48:04 +08:00
admin a45cdda3aa style(select): make dropdown menu more compact to match sm-size trigger
Build and Deploy / Build and Push Docker Image (push) Successful in 1m49s
- Menu padding: 8px → 6px
- Menu gap: 6px → 4px
- Item min-height: var(--ui-height-sm) → calc(var(--ui-height-sm) - 4px)
- Item padding: 0.5rem 0.75rem → 0.375rem 0.5rem
2026-08-08 16:07:58 +08:00
admin e425015cb1 style(NodeSwitcher): use sm size for more compact appearance
Build and Deploy / Build and Push Docker Image (push) Successful in 2m3s
- Change from md (36px/14px) to sm (28px/12px in default, 24px/11px in compact)
- Icon size 12px to match font size
2026-08-08 15:45:46 +08:00
admin eb85a8536d style(NodeSwitcher): unify style with Select component for consistency
Build and Deploy / Build and Push Docker Image (push) Successful in 4m19s
- Use ui-control and ui-select-trigger classes
- Change height to var(--ui-height-md) (36px)
- Change font-size to var(--ui-font-md) (14px)
- Remove max-width constraint, let width adapt to content
- Icon size from 12px to 14px
- Menu width now matches button width exactly
- Add arrow icon rotation on open
2026-08-08 15:25:18 +08:00
admin e4b9602d0e fix(select): prevent hover state from changing active item background color
Build and Deploy / Build and Push Docker Image (push) Successful in 1m57s
- Exclude .is-active from hover selector
- Add explicit :hover rule for .is-active to maintain selected color
2026-08-08 05:08:33 +08:00
admin 2902919ae5 style(select): improve select item hover and active state visual hierarchy
Build and Deploy / Build and Push Docker Image (push) Successful in 2m1s
- Use --bg-hover for hover state (more visible)
- Add font-weight: 500 to active/selected state for better distinction
2026-08-08 04:29:19 +08:00
admin 8ef0ff8507 fix(NodeSwitcher): move all hooks before conditional return to comply with React hooks rules
Build and Deploy / Build and Push Docker Image (push) Successful in 2m1s
- All hooks (useState, useCallback, useEffect) must be called before any conditional return
- Add document check in updateRoleState
- Merge SSR and isMaster checks into single return at the end
2026-08-08 04:15:55 +08:00
admin 36d39e4a47 fix(NodeSwitcher): return null during SSR to avoid hydration mismatch
Build and Deploy / Build and Push Docker Image (push) Successful in 1m57s
2026-08-08 03:37:15 +08:00
admin 901de3e340 fix(ssr): avoid accessing window in EventBusDriver constructor during SSR
Build and Deploy / Build and Push Docker Image (push) Successful in 1m53s
- Add window check in EventBusDriver constructor
- Fix original-api.ts localStorage access
- Prevent SSR hydration errors
2026-08-08 03:23:13 +08:00
admin 455f46caa0 fix(ssr): avoid accessing localStorage during SSR initialization
Build and Deploy / Build and Push Docker Image (push) Successful in 1m53s
- Initialize activeInterconnectNodeId with empty string
- Read from localStorage only in client-side
- Defer selected state initialization in NodeSwitcher to useEffect
2026-08-08 03:15:51 +08:00
admin 8be7e8ad2f fix(NodeSwitcher): avoid SSR hydration error by deferring portal creation
Build and Deploy / Build and Push Docker Image (push) Successful in 1m59s
2026-08-08 03:11:47 +08:00
admin e2063dbeb8 fix(NodeSwitcher): use ui-select-menu/item classes for consistent styling
Build and Deploy / Build and Push Docker Image (push) Successful in 1m57s
- Use createPortal to render menu in body
- Add ChevronDown icon
- Add position calculation for menu placement
- Fix ref usage with useRef instead of useState
2026-08-08 02:50:10 +08:00
admin ce9d1b4f1c chore(ci): remove debug steps after token verification successful
Build and Deploy / Build and Push Docker Image (push) Successful in 1m59s
2026-08-08 02:45:04 +08:00
admin 7430457f35 trigger(ci): re-run to verify TOKEN secret
Build and Deploy / Build and Push Docker Image (push) Successful in 4m4s
2026-08-08 02:21:31 +08:00
admin 2cda95ddbd fix(ci): pass TOKEN via environment variable to docker login step
Build and Deploy / Build and Push Docker Image (push) Failing after 6s
2026-08-08 01:58:25 +08:00
admin 5db4364615 fix(ci): remove existing repo directory before git clone
Build and Deploy / Build and Push Docker Image (push) Failing after 7s
2026-08-08 01:34:14 +08:00
admin ea5f712f8c debug(ci): add token debug step to verify secret is passed correctly
Build and Deploy / Build and Push Docker Image (push) Failing after 0s
2026-08-08 01:15:22 +08:00
admin bf7e87b582 refactor(ci): use manual git clone instead of actions/checkout to avoid node dependency
Build and Deploy / Build and Push Docker Image (push) Failing after 9s
- Install Node.js manually using curl
- Use git clone instead of actions/checkout@v4
- Follow license project CI approach
2026-08-08 00:54:51 +08:00
admin 4d38d5df3c fix(ci): explicitly set PATH environment variable for node container
Build and Deploy / Build Frontend (push) Successful in 1m0s
Build and Deploy / Build and Push Docker Image (push) Failing after 1s
2026-08-07 21:49:59 +08:00
admin 870ee1ce85 fix(ci): remove default working-directory to avoid chdir error before checkout
Build and Deploy / Build Frontend (push) Successful in 1m2s
Build and Deploy / Build and Push Docker Image (push) Failing after 0s
2026-08-07 21:48:02 +08:00
admin 4c10a0032f debug(ci): add step to verify Node.js in PATH before checkout
Build and Deploy / Build Frontend (push) Failing after 2s
Build and Deploy / Build and Push Docker Image (push) Has been skipped
2026-08-07 21:45:28 +08:00
admin 42cb37b5e3 refactor(ci): use container-based approach like license project
Build and Deploy / Build Frontend (push) Successful in 2m2s
Build and Deploy / Build and Push Docker Image (push) Failing after 1s
- Split into build and docker jobs
- Use node:22 container for frontend build
- Add pnpm cache for faster builds
- Support multiple package managers for Docker CLI installation
2026-08-07 21:28:59 +08:00
admin 2bf410c825 fix(ci): install Node.js directly in runner container with apt-get
Build and Deploy / build-and-push (push) Failing after 0s
2026-08-06 15:40:56 +08:00
admin f3c89d35a7 fix(ci): use node:22-bookworm container to ensure Node.js availability
Build and Deploy / build-and-push (push) Failing after 22s
2026-08-06 15:38:42 +08:00
admin 9589572b4c fix(ci): install Node.js before checkout to fix 'node not found' error
Build and Deploy / build-and-push (push) Failing after 0s
2026-08-06 15:34:18 +08:00
admin ec38778951 trigger(ci): re-run CI pipeline
Build and Deploy / build-and-push (push) Failing after 49s
2026-08-06 15:19:09 +08:00
admin 727c1dd8cb revert(ci): restore working CI config from 060a8f1
Build and Deploy / build-and-push (push) Failing after 2s
2026-08-06 14:44:03 +08:00
admin f68c092520 fix(ci): setup Node.js before pnpm to avoid 'node not found' error
Build and Deploy / build-and-push (push) Failing after 2s
2026-08-06 14:41:24 +08:00
admin 2cc584713f fix(NodeSwitcher): use custom dropdown instead of native select for consistent styling
Build and Deploy / build-and-push (push) Failing after 1m1s
2026-08-06 14:37:43 +08:00
admin 060a8f132d revert: remove SetTrustedProxies modification
Build and Deploy / build-and-push (push) Successful in 52s
2026-07-27 03:23:51 +08:00
admin 19b3fb60f7 fix(router): trust all proxies to fix MCP 'invalid Host header' error
Build and Deploy / build-and-push (push) Successful in 53s
- Set TrustedProxies to nil to allow reverse proxy requests
- Add explicit routes for /mcp without trailing slash
2026-07-27 03:10:24 +08:00
admin 2c9b44aeca fix(nginx): add proxy config for /mcp and /open2api endpoints
Build and Deploy / build-and-push (push) Successful in 53s
2026-07-27 03:04:20 +08:00
admin bcf7cbbeea refactor(mcp): remove standalone HTTP mode, keep only built-in endpoint and stdio
Build and Deploy / build-and-push (push) Successful in 54s
- MCP Server is now only available via built-in /mcp endpoint or stdio
- Remove --http flag and standalone HTTP server code
- Simplify documentation and frontend settings
2026-07-27 02:52:46 +08:00
admin f3a44a901e refactor(mcp): consolidate MCP implementation into internal package
Build and Deploy / build-and-push (push) Successful in 54s
- Move MCP tools registration to internal/mcp
- cmd/mcp now only contains CLI entry logic
- Support env vars TASKPOOL_URL/TASKPOOL_TOKEN for stdio mode
- Remove duplicate client.go from cmd/mcp
2026-07-27 02:43:01 +08:00
admin 066383de83 feat(mcp): integrate MCP Server into backend service as built-in endpoint
Build and Deploy / build-and-push (push) Successful in 54s
- Add MCP Controller and register /mcp route
- MCP Server now uses OpenAPI Token for authentication
- Update frontend settings to show built-in MCP endpoint config
- Update docs to reflect integrated MCP endpoint
2026-07-27 02:38:09 +08:00
admin f004cfc937 style(ui): unify settings input fields with consistent label style
Build and Deploy / build-and-push (push) Successful in 52s
2026-07-27 02:27:12 +08:00
admin 334152417b feat(ui): show both stdio and HTTP mode MCP config in settings
Build and Deploy / build-and-push (push) Successful in 54s
2026-07-27 02:25:48 +08:00
admin 6709c4ed77 feat(ui): improve settings form labels and descriptions for clarity
Build and Deploy / build-and-push (push) Successful in 53s
2026-07-27 02:22:35 +08:00
admin d104442e6b feat(mcp): add HTTP mode for remote Agent access (Hermes/OpenClaw URL support)
Build and Deploy / build-and-push (push) Successful in 52s
2026-07-27 02:20:30 +08:00
admin a9024ff6f8 feat(ui): add size system (sm/md/lg) + compact mode + remove collapsed dot
Build and Deploy / build-and-push (push) Successful in 53s
2026-07-26 20:14:42 +08:00
admin 29319ee2d3 docs: add MCP agent skill document
Build and Deploy / build-and-push (push) Successful in 1m15s
2026-07-26 19:30:57 +08:00
admin a0a90f558c feat(mcp): add official MCP Server for AI Agent integration 2026-07-26 19:30:43 +08:00
admin 7f54256ac0 fix(ui): narrow collapsed sidebar to 52px
Build and Deploy / build-and-push (push) Successful in 52s
2026-07-26 16:44:03 +08:00
admin eb8df6de0f fix(ui): remove sidebar collapse footer and unify header control heights
Build and Deploy / build-and-push (push) Successful in 52s
2026-07-26 16:27:24 +08:00
admin 88ca62b6c0 feat(ui): collapsible sidebar and move logout into top user menu
Build and Deploy / build-and-push (push) Successful in 53s
2026-07-26 16:07:37 +08:00
admin 90e0552f7b fix(docker): keep /sbin in PATH so apk works during image build
Build and Deploy / build-and-push (push) Successful in 59s
2026-07-26 15:55:13 +08:00
admin 7043c166ec feat(docker): install mise in standalone image for language runtime
Build and Deploy / build-and-push (push) Failing after 12s
2026-07-26 15:44:50 +08:00
admin 5fe8639342 fix(mysql): use loc=Local to avoid unknown time zone Asia/Shanghai
Build and Deploy / build-and-push (push) Successful in 53s
2026-07-26 15:40:28 +08:00
admin 472b956203 fix(docker): surface backend crash logs in docker logs and print DB target
Build and Deploy / build-and-push (push) Successful in 52s
2026-07-26 15:20:56 +08:00
admin afe7833539 fix(docker): do not hardcode sqlite; allow env to override config.ini
Build and Deploy / build-and-push (push) Successful in 53s
2026-07-26 15:14:19 +08:00
admin c06a8a7f7d docs: update README for React frontend and standalone Docker deploy
Build and Deploy / build-and-push (push) Successful in 51s
2026-07-26 14:55:45 +08:00
admin 3b77837323 fix(docker): start backend with 'taskpool server' under supervisord
Build and Deploy / build-and-push (push) Successful in 52s
2026-07-26 14:49:15 +08:00
admin 24e809557e fix(ci): sync pnpm-lock with @tailwindcss/vite and rename image to taskpool
Build and Deploy / build-and-push (push) Successful in 1m17s
2026-07-26 14:36:57 +08:00
43 changed files with 2272 additions and 326 deletions
+62 -24
View File
@@ -12,39 +12,77 @@ on:
env: env:
REGISTRY: git.viaeon.com REGISTRY: git.viaeon.com
IMAGE_NAME: admin/taskpool-react IMAGE_NAME: admin/taskpool
jobs: jobs:
build-and-push: docker:
runs-on: ubuntu-latest name: Build and Push Docker Image
if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu
if: github.event_name == 'push'
env:
RUNNER_TOOL_CACHE: /toolcache
steps: steps:
- name: Checkout - name: Install Docker CLI
uses: actions/checkout@v4 run: |
if ! command -v docker &> /dev/null; then
if command -v apk &> /dev/null; then
apk add --no-cache docker-cli
elif command -v apt-get &> /dev/null; then
apt-get update && apt-get install -y docker.io
else
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-24.0.7.tgz | tar xz -C /tmp
mv /tmp/docker/docker /usr/local/bin/
chmod +x /usr/local/bin/docker
fi
fi
docker --version
- name: Install Node.js
run: |
export PATH="/toolcache/bin:$PATH"
if ! command -v node &> /dev/null; then
if command -v apk &> /dev/null; then
apk add --no-cache nodejs npm
else
curl -fsSL https://nodejs.org/dist/v22.0.0/node-v22.0.0-linux-x64.tar.xz | tar -xJ -C /tmp
cp -r /tmp/node-v22.0.0-linux-x64/{bin,lib,share} /toolcache/
fi
fi
node --version
- name: Checkout repository
run: |
cd /tmp
rm -rf repo
git clone --depth=1 https://admin:${{ secrets.TOKEN }}@git.viaeon.com/admin/TaskPool.git repo
cd repo
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 run: |
with: export PATH="/toolcache/bin:$PATH"
version: 9 npm install -g pnpm@9
pnpm --version
- name: Setup Node.js - name: Install dependencies and build
uses: actions/setup-node@v4 run: |
with: export PATH="/toolcache/bin:$PATH"
node-version: '22' cd /tmp/repo/web
pnpm install --frozen-lockfile
- name: Install dependencies pnpm build
working-directory: web
run: pnpm install --frozen-lockfile
- name: Build
working-directory: web
run: pnpm build
- name: Login to Registry - name: Login to Registry
run: echo "${{ secrets.TOKEN }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin env:
TOKEN: ${{ secrets.TOKEN }}
- name: Build and push Docker image
run: | run: |
echo "$TOKEN" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
- name: Build Docker image
run: |
cd /tmp/repo
docker build -f Dockerfile.standalone -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} . docker build -f Dockerfile.standalone -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} .
- name: Push Docker image
run: |
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
+24 -4
View File
@@ -18,7 +18,22 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o /taskpool .
# Production stage # Production stage
FROM alpine:3.19 FROM alpine:3.19
RUN apk add --no-cache nginx supervisor
# 注意:PATH 必须保留 /sbin:/usr/sbin,否则 alpine 的 apk 找不到
ENV TZ=Asia/Shanghai \
MISE_DATA_DIR=/app/envs/mise \
MISE_CONFIG_DIR=/app/envs/mise \
MISE_HIDE_UPDATE_WARNING=1 \
PATH="/app/envs/mise/shims:/app/envs/mise/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
RUN apk add --no-cache \
nginx supervisor bash curl wget ca-certificates tzdata git rsync openssh-client \
&& cp /usr/share/zoneinfo/${TZ} /etc/localtime \
&& echo "${TZ}" > /etc/timezone \
&& curl https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh \
&& chmod +x /usr/local/bin/mise \
&& mkdir -p /app/envs/mise \
&& mise --version
# Copy frontend # Copy frontend
COPY --from=frontend-builder /app/web/dist /usr/share/nginx/html COPY --from=frontend-builder /app/web/dist /usr/share/nginx/html
@@ -29,12 +44,17 @@ COPY --from=backend-builder /taskpool /app/taskpool
# Copy nginx config (modified for localhost) # Copy nginx config (modified for localhost)
COPY nginx-standalone.conf /etc/nginx/http.d/default.conf COPY nginx-standalone.conf /etc/nginx/http.d/default.conf
# Copy supervisor config # Copy supervisor config + backend wrapper (logs go to docker logs)
COPY supervisord.conf /etc/supervisord.conf COPY supervisord.conf /etc/supervisord.conf
COPY docker/start-backend.sh /app/start-backend.sh
# Create data directories # Create data directories and ship example config (used when no volume config)
RUN mkdir -p /app/data /app/configs /app/envs /app/logs COPY configs/config.example.ini /app/configs/config.example.ini
RUN mkdir -p /app/data /app/configs /app/envs /app/logs /app/data/scripts \
&& chmod +x /app/taskpool /app/start-backend.sh \
&& ln -sf /app/taskpool /usr/local/bin/taskpool
WORKDIR /app
EXPOSE 3000 8052 EXPOSE 3000 8052
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
+110 -70
View File
@@ -62,6 +62,7 @@
- **远程执行:** 支持远程agent执行任务,展示执行结果 - **远程执行:** 支持远程agent执行任务,展示执行结果
- **多语言支持:** 深度集成 Mise,支持几乎所有主流编程语言的动态安装、多版本切换及依赖管理 - **多语言支持:** 深度集成 Mise,支持几乎所有主流编程语言的动态安装、多版本切换及依赖管理
- **内建助手库:** **(New)** 为 Python/Node.js 提供零配置助手库,简单 import 即可实现一键推信,无需手动管理 API Token 和 URL - **内建助手库:** **(New)** 为 Python/Node.js 提供零配置助手库,简单 import 即可实现一键推信,无需手动管理 API Token 和 URL
- **MCP Server** **(New)** 内置 MCP Server`taskpool mcp`),Hermes / OpenClaw / Cursor 等 AI Agent 可完整接管任务、脚本、日志与环境变量
## 功能特性 ## 功能特性
@@ -173,33 +174,78 @@
## 快速部署 ## 快速部署
项目提供多种基础镜像,可根据具体环境选择: ### 镜像说明
| 标签 (Tag) | 基础镜像 | 说明 | | 镜像 | 说明 |
| :--- | :--- | :--- | | :--- | :--- |
| `latest` | Debian 12 | **默认推荐**集成 Python 3.13 与 Node.js 23,开箱即用 | | `git.viaeon.com/admin/taskpool:latest` | **本仓库 CI 构建(推荐**React 前端 + Go 后端的 **standalone** 一体镜像(nginx + supervisord |
| `latest-debian13` | Debian 13 | 尝鲜版本,基于 Debian Trixie | | `ghcr.io/engigu/taskpool:latest` | 上游公共镜像(Debian + Mise 完整运行时),适合需要完整语言环境的场景 |
| `latest-minimal` | Debian 13 | **最小化版**:不预置语言环境,由用户通过面板自主按需安装 |
> **提示**:下方部署示例默认使用 `latest` 标签,如需换用 Debian 13 版,只需将 `latest` 替换为 `latest-debian13` 即可。 **本仓库 standalone 镜像端口:**
> **警告**:**架构升级破坏性变更** | 端口 | 说明 |
> | :--- | :--- |
> 本版本(2026.02.13+)对底层运行时环境进行了彻底重构,弃用了原有的静态 Python/Node 环境,转为使用 **Mise** 进行动态版本管理。 | `3000` | Web 面板(nginx 静态资源 + `/api` 反代) |
> | `8052` | Go API / WebSocket 后端 |
> 1. **不再提供 Alpine 镜像**:由于 glibc 兼容性问题,Mise 无法在 Alpine 上完美运行,因此暂时取消 Alpine 镜像支持。
> 2. **环境数据不兼容**:如果您是从旧版本升级上来,原有的 Python/Node 环境数据将无法迁移。升级后您需要:
> - 清空或备份原有的 `envs/` 挂载目录
> - 启动新容器,让系统自动初始化新的 Mise 环境
> - 在面板中重新安装所需的语言和依赖
> 启动后端进程时必须使用:`taskpool server`(无参数只会打印 help 并退出)。
<details> <details>
<summary><b>方式一:环境变量部署(推荐)</b></summary> <summary><b>方式一:本仓库 standalone 镜像(推荐)</b></summary>
通过环境变量指定配置,简单灵活,适合容器编排场景。 ```bash
docker run -d \
--name taskpool \
-p 3000:3000 \
-p 8052:8052 \
-v $(pwd)/data:/app/data \
-v $(pwd)/configs:/app/configs \
-v $(pwd)/envs:/app/envs \
-e TZ=Asia/Shanghai \
-e BH_SERVER_PORT=8052 \
-e BH_SERVER_HOST=0.0.0.0 \
-e BH_DB_TYPE=sqlite \
-e BH_DB_PATH=/app/data/taskpool.db \
-e BH_DB_TABLE_PREFIX=taskpool_ \
-e TASKPOOL_SECRET_KEY=your_secret_key_here \
--restart unless-stopped \
git.viaeon.com/admin/taskpool:latest
```
**使用 SQLite(默认)** **Docker Compose**
```yaml
services:
app:
image: git.viaeon.com/admin/taskpool:latest
container_name: taskpool
ports:
- "3000:3000"
- "8052:8052"
volumes:
- ./data:/app/data
- ./configs:/app/configs
- ./envs:/app/envs
environment:
- TZ=Asia/Shanghai
- BH_SERVER_PORT=8052
- BH_SERVER_HOST=0.0.0.0
- BH_DB_TYPE=sqlite
- BH_DB_PATH=/app/data/taskpool.db
- BH_DB_TABLE_PREFIX=taskpool_
- TASKPOOL_SECRET_KEY=your_secret_key_here
restart: unless-stopped
```
访问:
- 面板:http://localhost:3000
- APIhttp://localhost:8052
</details>
<details>
<summary><b>方式二:环境变量部署(上游完整镜像 / SQLite</b></summary>
```bash ```bash
docker run -d \ docker run -d \
@@ -218,12 +264,6 @@ docker run -d \
ghcr.io/engigu/taskpool:latest ghcr.io/engigu/taskpool:latest
``` ```
> **提示**:如需通过反向代理部署在子路径(如 `/taskpool`),添加环境变量:
> ```bash
> -e BH_SERVER_URL_PREFIX=/taskpool
> ```
> 配置后访问地址为 `http://your-domain.com/taskpool/`,详见下方「URL 前缀配置」说明。
**Docker ComposeSQLite):** **Docker ComposeSQLite):**
```yaml ```yaml
@@ -244,12 +284,6 @@ services:
- BH_DB_PATH=/app/data/taskpool.db - BH_DB_PATH=/app/data/taskpool.db
- BH_DB_TABLE_PREFIX=taskpool_ - BH_DB_TABLE_PREFIX=taskpool_
- TASKPOOL_SECRET_KEY=your_secret_key_here - TASKPOOL_SECRET_KEY=your_secret_key_here
# - BH_SERVER_URL_PREFIX=/taskpool # 可选:配置 URL 前缀用于反向代理
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
restart: unless-stopped restart: unless-stopped
``` ```
@@ -276,41 +310,6 @@ docker run -d \
ghcr.io/engigu/taskpool:latest ghcr.io/engigu/taskpool:latest
``` ```
> **提示**:如需配置 URL 前缀,添加 `-e BH_SERVER_URL_PREFIX=/taskpool`
**Docker ComposeMySQL):**
```yaml
services:
taskpool:
image: ghcr.io/engigu/taskpool:latest
container_name: taskpool
ports:
- "8052:8052"
volumes:
- ./data:/app/data
- ./envs:/app/envs
environment:
- TZ=Asia/Shanghai
- BH_SERVER_PORT=8052
- BH_SERVER_HOST=0.0.0.0
# - BH_SERVER_URL_PREFIX=/taskpool # 可选:配置 URL 前缀
- BH_DB_TYPE=mysql
- BH_DB_HOST=mysql-server
- BH_DB_PORT=3306
- BH_DB_USER=root
- BH_DB_PASSWORD=your_password
- BH_DB_NAME=taskpool
- BH_DB_TABLE_PREFIX=taskpool_
- TASKPOOL_SECRET_KEY=your_secret_key_here
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
restart: unless-stopped
```
</details> </details>
<details> <details>
@@ -600,12 +599,26 @@ nginx -t && nginx -s reload
### 访问面板 ### 访问面板
启动后访问:http://localhost:8052 | 部署方式 | 访问地址 |
| :--- | :--- |
| 本仓库 standalone 镜像 | http://localhost:3000 API: http://localhost:8052 |
| 上游完整镜像 / 仅后端 | http://localhost:8052 |
**默认账号:** 用户名 `admin`,密码见启动日志(首次启动会自动生成 12 位随机密码并打印在日志中) **默认账号:** 用户名 `admin`,密码见启动日志(首次启动会自动生成 12 位随机密码并打印在日志中)
> **注意**:出于安全性考虑,系统不再使用固定默认密码。请在容器启动日志中搜索 `管理员账号创建成功` 找到您的随机密码,并登录后及时修改。 > **注意**:出于安全性考虑,系统不再使用固定默认密码。请在容器启动日志中搜索 `管理员账号创建成功` 找到您的随机密码,并登录后及时修改。
**排查 backend 反复重启:**
```bash
# 正确启动命令
taskpool server
# 容器内日志(standalone / supervisord
docker exec -it taskpool cat /app/logs/backend.err.log
docker exec -it taskpool cat /app/logs/backend.out.log
```
<details> <details>
<summary><b>命令行工具 (CLI)</b></summary> <summary><b>命令行工具 (CLI)</b></summary>
@@ -671,7 +684,7 @@ host = localhost
port = 3306 port = 3306
user = root user = root
password = password =
dbname = bh_panel dbname = taskpool
table_prefix = taskpool_ table_prefix = taskpool_
``` ```
@@ -690,10 +703,10 @@ table_prefix = taskpool_
| `BH_DB_PORT` | database.port | 数据库端口 | 3306 | | `BH_DB_PORT` | database.port | 数据库端口 | 3306 |
| `BH_DB_USER` | database.user | 数据库用户 | root | | `BH_DB_USER` | database.user | 数据库用户 | root |
| `BH_DB_PASSWORD` | database.password | 数据库密码 | - | | `BH_DB_PASSWORD` | database.password | 数据库密码 | - |
| `BH_DB_NAME` | database.dbname | 数据库名称 | bh_panel | | `BH_DB_NAME` | database.dbname | 数据库名称 | taskpool |
| `BH_DB_PATH` | database.path | SQLite 文件路径 | ./data/taskpool.db | | `BH_DB_PATH` | database.path | SQLite 文件路径 | ./data/taskpool.db |
| `BH_DB_TABLE_PREFIX` | database.table_prefix | 表前缀 | taskpool_ | | `BH_DB_TABLE_PREFIX` | database.table_prefix | 表前缀 | taskpool_ |
| `TASKPOOL_SECRET_KEY` | - | 系统加密秘钥,用于机密功能(**注:仅支持环境变量设置,不支持配置文件**) | - | | `TASKPOOL_SECRET_KEY` | - | 系统加密秘钥,用于机密功能(**注:仅支持环境变量设置,不支持配置文件**;兼容旧变量 `BAIHU_SECRET_KEY` | - |
### URL 前缀配置 ### URL 前缀配置
@@ -755,6 +768,33 @@ location /taskpool/ {
</details> </details>
## 本地开发(本仓库)
```bash
# 后端
go build -o bin/taskpool.exe .
./bin/taskpool.exe server
# 前端
cd web
pnpm install
pnpm dev # 默认 :3000,代理 API 到 :8052
```
常用命令:
```bash
taskpool server
taskpool resetpwd admin
taskpool version
```
数据库品牌迁移(可选,旧 `baihu_` 表前缀 → `taskpool_`):
```bash
go run scripts/migrate-db-brand.go -db data/baihu.db -out data/taskpool.db
```
## 免责声明 ⚠️ ## 免责声明 ⚠️
任务池(TaskPool)仅作为一个轻量级的任务托管与调度平台,本项目及相关代码**不提供、不内置任何具有实际业务逻辑的第三方脚本**。 任务池(TaskPool)仅作为一个轻量级的任务托管与调度平台,本项目及相关代码**不提供、不内置任何具有实际业务逻辑的第三方脚本**。
+2
View File
@@ -3,6 +3,7 @@ package cmd
import ( import (
"github.com/engigu/taskpool/cmd/builtininstall" "github.com/engigu/taskpool/cmd/builtininstall"
"github.com/engigu/taskpool/cmd/depinstall" "github.com/engigu/taskpool/cmd/depinstall"
mcpcmd "github.com/engigu/taskpool/cmd/mcp"
"github.com/engigu/taskpool/cmd/reposync" "github.com/engigu/taskpool/cmd/reposync"
"github.com/engigu/taskpool/cmd/resetpwd" "github.com/engigu/taskpool/cmd/resetpwd"
"github.com/engigu/taskpool/cmd/restore" "github.com/engigu/taskpool/cmd/restore"
@@ -22,6 +23,7 @@ var Handlers = map[string]CommandHandler{
"restore": restore.Run, "restore": restore.Run,
"builtininstall": builtininstall.Run, "builtininstall": builtininstall.Run,
"task": task.Run, "task": task.Run,
"mcp": mcpcmd.Run,
"webui": webui.Run, "webui": webui.Run,
"version": version.Run, "version": version.Run,
"-v": version.Run, "-v": version.Run,
+93
View File
@@ -0,0 +1,93 @@
package mcp
import (
"fmt"
"os"
"strings"
"github.com/engigu/taskpool/internal/mcp"
imc "github.com/engigu/taskpool/internal/mcp"
)
// Run 启动 TaskPool MCP Serverstdio 模式)
// 环境变量(可选,默认使用本地服务器配置):
// TASKPOOL_URL 面板地址
// TASKPOOL_TOKEN OpenAPI Token
func Run(args []string) {
for _, a := range args {
if a == "-h" || a == "--help" {
printHelp()
return
}
}
// 使用内部 OpenAPI 客户端(自动获取服务器地址和 Token)
client := imc.GetOpenAPIClient()
fmt.Fprintf(os.Stderr, "[taskpool-mcp] stdio mode, url=%s token=%s\n", client.BaseURL, maskToken(client.Token))
s := mcp.GetServer()
// 设置外部客户端(用于连接远程服务器)
if client.Token != "" {
mcp.SetExternalClient(client)
}
if err := mcp.ServeStdio(s); err != nil {
fmt.Fprintf(os.Stderr, "[taskpool-mcp] error: %v\n", err)
os.Exit(1)
}
}
func maskToken(t string) string {
t = strings.TrimSpace(t)
if t == "" {
return "(empty)"
}
if len(t) <= 8 {
return "****"
}
return t[:4] + "****" + t[len(t)-4:]
}
func printHelp() {
fmt.Fprintf(os.Stderr, `
TaskPool MCP Server
MCP Server 已内置在后端服务中:
HTTP 端点: http://your-server:8052/mcp
此命令仅用于 stdio 模式(Claude Desktop / Cursor 等)。
用法:
taskpool mcp
环境变量(可选):
TASKPOOL_URL 面板地址(默认使用本机)
TASKPOOL_TOKEN OpenAPI Token(默认从系统设置读取)
Agent 配置:
HTTP 模式(Hermes / OpenClaw:
{
"mcpServers": {
"taskpool": {
"url": "http://your-server:8052/mcp",
"headers": {
"Authorization": "Bearer 你的Token"
}
}
}
}
stdio 模式(Claude Desktop / Cursor:
{
"mcpServers": {
"taskpool": {
"command": "taskpool",
"args": ["mcp"]
}
}
}
前置条件:
系统设置 → 启用 OpenAPI → 生成 Token
`)
}
+3 -11
View File
@@ -1,17 +1,9 @@
services: services:
# Frontend - React Web UI # Standalone all-in-one image (nginx + backend via supervisord)
frontend: app:
image: git.viaeon.com/admin/taskpool-react:latest
ports:
- "3000:80"
depends_on:
- backend
restart: unless-stopped
# Backend - Go API Server
backend:
image: git.viaeon.com/admin/taskpool:latest image: git.viaeon.com/admin/taskpool:latest
ports: ports:
- "3000:3000"
- "8052:8052" - "8052:8052"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
set -eu
cd /app
echo "[backend-wrapper] starting taskpool server"
echo "[backend-wrapper] cwd=$(pwd)"
echo "[backend-wrapper] BH_SERVER_HOST=${BH_SERVER_HOST:-}"
echo "[backend-wrapper] BH_SERVER_PORT=${BH_SERVER_PORT:-}"
echo "[backend-wrapper] BH_DB_TYPE=${BH_DB_TYPE:-}"
echo "[backend-wrapper] BH_DB_HOST=${BH_DB_HOST:-}"
echo "[backend-wrapper] BH_DB_PORT=${BH_DB_PORT:-}"
echo "[backend-wrapper] BH_DB_USER=${BH_DB_USER:-}"
echo "[backend-wrapper] BH_DB_NAME=${BH_DB_NAME:-}"
echo "[backend-wrapper] BH_DB_PATH=${BH_DB_PATH:-}"
echo "[backend-wrapper] config.ini exists=$([ -f /app/configs/config.ini ] && echo yes || echo no)"
# 基础目录
mkdir -p /app/data /app/configs /app/envs /app/logs /app/data/scripts /app/envs/mise
# Mise 环境(挂载 ./envs 后仍可工作)
export MISE_DATA_DIR="${MISE_DATA_DIR:-/app/envs/mise}"
export MISE_CONFIG_DIR="${MISE_CONFIG_DIR:-/app/envs/mise}"
export MISE_HIDE_UPDATE_WARNING=1
export PATH="/app/envs/mise/shims:/app/envs/mise/bin:/usr/local/bin:$PATH"
if command -v mise >/dev/null 2>&1; then
echo "[backend-wrapper] mise version: $(mise --version 2>/dev/null | head -n 1)"
else
echo "[backend-wrapper] WARNING: mise not found in PATH"
fi
# 直接前台运行,日志打到容器 stdout/stderr
exec /app/taskpool server
+4 -2
View File
@@ -15,7 +15,8 @@ export default defineConfig({
nav: [ nav: [
{ text: '快速开始', link: '/guide/introduction' }, { text: '快速开始', link: '/guide/introduction' },
{ text: '部署指南', link: '/guide/deployment' }, { text: '部署指南', link: '/guide/deployment' },
{ text: 'API 文档', link: '/guide/api' } { text: 'API 文档', link: '/guide/api' },
{ text: 'MCP', link: '/guide/mcp' }
], ],
sidebar: [ sidebar: [
@@ -25,7 +26,8 @@ export default defineConfig({
{ text: '项目介绍', link: '/guide/introduction' }, { text: '项目介绍', link: '/guide/introduction' },
{ text: '部署说明', link: '/guide/deployment' }, { text: '部署说明', link: '/guide/deployment' },
{ text: '开始使用', link: '/guide/getting-started' }, { text: '开始使用', link: '/guide/getting-started' },
{ text: 'API 文档', link: '/guide/api' } { text: 'API 文档', link: '/guide/api' },
{ text: 'MCP Server', link: '/guide/mcp' }
] ]
}, },
{ {
+129
View File
@@ -0,0 +1,129 @@
# MCP ServerHermes / OpenClaw
TaskPool 提供官方 **MCP Server**,让 Hermes、OpenClaw、Cursor 等 AI Agent **完整接管**任务池:任务、脚本、环境变量、执行与日志。
> OpenAPI ≠ MCP
> - **OpenAPI**`/open2api/v1` REST 接口(设置页「启用 OpenAPI」)
> - **MCP Server**`/mcp` HTTP 端点,把 OpenAPI 封装成 Agent 可调用的 Tools
## 前置条件
1. 启动后端服务(Docker 或 `taskpool server`
2. 系统设置 → **启用 OpenAPI****生成 Token**
3. Agent 能访问面板地址(如 `http://your-server:8052`
## 内置 MCP 端点
MCP Server 已**内置在后端服务中**,无需单独进程。启用 OpenAPI 后,MCP 端点自动可用:
```
http://your-server:8052/mcp
```
## Agent 配置
### HTTP 模式(推荐)
远程 Agent 直接访问内置端点,无需本地安装。
```json
{
"mcpServers": {
"taskpool": {
"url": "http://your-server:8052/mcp",
"headers": {
"Authorization": "Bearer 你的OpenAPI_Token"
}
}
}
}
```
适用于:Hermes、OpenClaw 等支持 HTTP URL 的 Agent。
### stdio 模式
本地 Agent 需要安装 `taskpool` 二进制。
```json
{
"mcpServers": {
"taskpool": {
"command": "taskpool",
"args": ["mcp"]
}
}
}
```
适用于:Claude Desktop、Cursor 等只支持 stdio 的本地 Agent。
## 工具清单(Tools
| 分类 | Tool | 说明 |
|------|------|------|
| 连通 | `ping_api` | 校验 URL + Token |
| 任务 | `list_tasks` | 分页列表/筛选 |
| 任务 | `get_task` | 详情 |
| 任务 | `create_task` | 创建 |
| 任务 | `update_task` | 更新 |
| 任务 | `delete_task` | 删除 |
| 任务 | `list_task_tags` | 标签 |
| 执行 | `run_task` | 立即执行 |
| 执行 | `stop_task` | 按 log_id 停止 |
| 执行 | `get_last_results` | 最近结果 |
| 日志 | `list_logs` | 日志列表 |
| 日志 | `get_log` | 日志详情(含输出) |
| 脚本 | `list_scripts` / `get_script` / `create_script` / `update_script` / `delete_script` | 脚本 CRUD |
| 环境变量 | `list_envs` / `list_all_envs` / `get_env` / `create_env` / `update_env` / `delete_env` | 环境变量 CRUD |
## Prompts
- `ops_overview`:巡检任务与失败日志
- `run_and_check`:执行指定任务并检查日志(参数 `task_id`
## Skill(可选)
仓库内提供 Agent Skill 文档,便于模型按固定流程操作:
- [`skills/taskpool/SKILL.md`](../../skills/taskpool/SKILL.md)
Skill 是说明书;**真正执行依赖 MCP Tools**。两者一起用效果最好。
## 安全建议
1. Token 等同管理员 OpenAPI 权限,勿提交到 Git
2. 删除任务/脚本/环境变量前应人工确认
3. 不要对不可信网络暴露面板与 Token
4. 生产建议独立 Token,并定期轮换
## 典型对话
- 「列出所有启用的任务」→ `list_tasks`
- 「跑一下备份任务并看日志」→ `list_tasks``run_task``list_logs``get_log`
- 「创建一个每天凌晨执行的清理任务」→ `create_task`
- 「最近失败的任务是什么原因」→ `list_logs(status=failed)``get_log`
## 与项目能力的对应
参考 [项目介绍](./introduction.md) 中的能力边界:
| 面板能力 | MCP 是否覆盖 |
|----------|--------------|
| 任务调度 / 手动执行 | ✅ |
| 脚本管理 | ✅ |
| 环境变量 | ✅ |
| 执行日志 | ✅ |
| 在线终端 | ❌(WebSocket,不适合 MCP 工具化) |
| 语言环境 Mise 安装 | ❌(可后续扩展) |
| 消息推送配置 | ❌(可后续扩展) |
| 远程 Agent 节点 | 间接(任务上指定 agent 后可执行) |
## 故障排查
| 现象 | 处理 |
|------|------|
| `OpenAPI Token 未配置` | 设置页启用 OpenAPI 并生成 Token |
| `无效的 OpenAPI 令牌` | 设置页重新生成 Token |
| 连接失败 | 检查服务器地址、防火墙、容器网络 |
| Agent 看不到 tools | 确认配置正确,Token 有效 |
+5 -1
View File
@@ -14,6 +14,8 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/hashicorp/yamux v0.1.2 github.com/hashicorp/yamux v0.1.2
github.com/klauspost/compress v1.19.0
github.com/mark3labs/mcp-go v0.57.0
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/rs/xid v1.6.0 github.com/rs/xid v1.6.0
github.com/shirou/gopsutil/v3 v3.24.5 github.com/shirou/gopsutil/v3 v3.24.5
@@ -81,6 +83,7 @@ require (
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gohugoio/hashstructure v0.6.0 // indirect github.com/gohugoio/hashstructure v0.6.0 // indirect
github.com/gohugoio/hugo v0.163.3 // indirect github.com/gohugoio/hugo v0.163.3 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/hairyhenderson/go-codeowners v0.7.1 // indirect github.com/hairyhenderson/go-codeowners v0.7.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -91,7 +94,6 @@ require (
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect github.com/joho/godotenv v1.5.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.19.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
@@ -110,6 +112,7 @@ require (
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect github.com/quic-go/quic-go v0.60.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/shoenig/go-m1cpu v0.2.1 // indirect github.com/shoenig/go-m1cpu v0.2.1 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
@@ -122,6 +125,7 @@ require (
github.com/tklauser/numcpus v0.12.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
+8
View File
@@ -141,6 +141,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0= github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -245,6 +247,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
@@ -307,6 +311,8 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc= github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0= github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA= github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
github.com/mark3labs/mcp-go v0.57.0 h1:jzWKyCzdWnwnZt05cvcQQ+ngiUl2RnixXJa7Kj4qP1E=
github.com/mark3labs/mcp-go v0.57.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
@@ -452,6 +458,8 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+10
View File
@@ -133,7 +133,17 @@ func (a *App) initDatabase() {
SSLMode: a.Config.Database.SSLMode, SSLMode: a.Config.Database.SSLMode,
} }
// 启动期打印有效 DB 配置(不含密码),便于 Docker 排查
if dbCfg.Type == "sqlite" {
logger.Infof("[Database] 使用 sqlite path=%s", dbCfg.Path)
} else {
logger.Infof("[Database] 使用 %s host=%s port=%d user=%s dbname=%s",
dbCfg.Type, dbCfg.Host, dbCfg.Port, dbCfg.User, dbCfg.DBName)
}
if err := database.Init(dbCfg); err != nil { if err := database.Init(dbCfg); err != nil {
logger.Errorf("Failed to init database: %v", err)
logger.Errorf("排查建议: 1) 确认 BH_DB_* 环境变量已注入 2) MySQL 是否允许 host.docker.internal 访问 3) configs/config.ini 是否覆盖了错误配置")
logger.Fatalf("Failed to init database: %v", err) logger.Fatalf("Failed to init database: %v", err)
} }
+4
View File
@@ -32,6 +32,10 @@ var Commands = []CommandInfo{
Name: "task", Name: "task",
Description: "系统级任务的列表查询、触发运行、启停控制及状态查看", Description: "系统级任务的列表查询、触发运行、启停控制及状态查看",
}, },
{
Name: "mcp",
Description: "启动 MCP Server,供 Hermes / OpenClaw 等 AI Agent 接管任务池",
},
{ {
Name: "depinstall", Name: "depinstall",
Description: "一键补全指定任务执行日志中的缺失依赖包", Description: "一键补全指定任务执行日志中的缺失依赖包",
+19
View File
@@ -0,0 +1,19 @@
package controllers
import (
"github.com/engigu/taskpool/internal/mcp"
"github.com/gin-gonic/gin"
)
type MCPController struct{}
func NewMCPController() *MCPController {
return &MCPController{}
}
// HandleMCP 处理 MCP 请求(StreamableHTTP
func (c *MCPController) HandleMCP(ctx *gin.Context) {
// 获取 MCP HTTP Handler 并处理请求
handler := mcp.GetHTTPHandler()
handler.ServeHTTP(ctx.Writer, ctx.Request)
}
+3 -1
View File
@@ -107,7 +107,9 @@ func buildDSN(cfg *Config) (string, error) {
if cfg.DSN != "" { if cfg.DSN != "" {
return cfg.DSN, nil return cfg.DSN, nil
} }
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Asia%%2FShanghai", // 使用 loc=Local,避免 MySQL 未加载时区表时出现 unknown time zone Asia/Shanghai
// 应用启动时已将 time.Local 设为 Asia/Shanghai (CST)
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName) cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)
if cfg.SSLMode != "" { if cfg.SSLMode != "" {
dsn += "&tls=" + cfg.SSLMode dsn += "&tls=" + cfg.SSLMode
+210
View File
@@ -0,0 +1,210 @@
package mcp
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/engigu/taskpool/internal/constant"
"github.com/engigu/taskpool/internal/models/vo"
"github.com/engigu/taskpool/internal/services"
)
var (
openAPIClient *Client
openAPIClientOnce sync.Once
)
// GetOpenAPIClient 获取 OpenAPI 客户端(单例)
// 优先使用环境变量 TASKPOOL_URL 和 TASKPOOL_TOKEN
// 否则使用本地服务器地址和系统设置中的 OpenAPI Token
func GetOpenAPIClient() *Client {
openAPIClientOnce.Do(func() {
// 优先从环境变量读取(支持 stdio 模式连接远程服务器)
envURL := strings.TrimSpace(os.Getenv("TASKPOOL_URL"))
envToken := strings.TrimSpace(os.Getenv("TASKPOOL_TOKEN"))
if envURL == "" {
envURL = strings.TrimSpace(os.Getenv("BAIHU_URL"))
}
if envToken == "" {
envToken = strings.TrimSpace(os.Getenv("BAIHU_TOKEN"))
}
var baseURL, token string
if envURL != "" && envToken != "" {
// 使用环境变量配置
baseURL = envURL
token = envToken
} else {
// 使用本地服务器配置
cfg := services.GetConfig()
baseURL = fmt.Sprintf("http://127.0.0.1:%d", cfg.Server.Port)
if cfg.Server.URLPrefix != "" {
baseURL += cfg.Server.URLPrefix
}
settingsSvc := services.NewSettingsService()
token = getOpenAPIToken(settingsSvc)
}
openAPIClient = NewClient(baseURL, token)
})
return openAPIClient
}
// RefreshClient 刷新客户端(当 OpenAPI Token 变更时调用)
func RefreshClient() {
settingsSvc := services.NewSettingsService()
token := getOpenAPIToken(settingsSvc)
if openAPIClient != nil {
openAPIClient.Token = token
}
}
func getOpenAPIToken(settingsSvc *services.SettingsService) string {
siteConfig := settingsSvc.GetSection(constant.SectionSite)
tokenJson, ok := siteConfig[constant.KeyOpenapiToken]
if !ok || tokenJson == "" {
return ""
}
var tokenConfig vo.TokenConfig
if err := json.Unmarshal([]byte(tokenJson), &tokenConfig); err != nil {
return ""
}
if !tokenConfig.Enabled {
return ""
}
return tokenConfig.Token
}
// Client 调用内部 OpenAPI
type Client struct {
BaseURL string
Token string
HTTPClient *http.Client
}
func NewClient(baseURL, token string) *Client {
return &Client{
BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
Token: strings.TrimSpace(token),
HTTPClient: &http.Client{
Timeout: 60 * time.Second,
},
}
}
func (c *Client) openBase() string {
return c.BaseURL + "/open2api/v1"
}
func (c *Client) do(method, path string, query map[string]string, body any) (json.RawMessage, error) {
if c.BaseURL == "" {
return nil, fmt.Errorf("服务器地址未配置")
}
if c.Token == "" {
return nil, fmt.Errorf("OpenAPI Token 未配置,请在系统设置中启用 OpenAPI 并生成 Token")
}
u, err := url.Parse(c.openBase() + path)
if err != nil {
return nil, err
}
if len(query) > 0 {
q := u.Query()
for k, v := range query {
if strings.TrimSpace(v) != "" {
q.Set(k, v)
}
}
u.RawQuery = q.Encode()
}
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(b)
}
req, err := http.NewRequest(method, u.String(), reader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var envelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(raw), 500))
}
return raw, nil
}
if envelope.Code != 200 {
msg := envelope.Msg
if msg == "" {
msg = truncate(string(raw), 500)
}
return nil, fmt.Errorf("[%d] %s", envelope.Code, msg)
}
if len(envelope.Data) > 0 && string(envelope.Data) != "null" {
return envelope.Data, nil
}
result := map[string]any{"code": envelope.Code, "msg": envelope.Msg}
b, _ := json.Marshal(result)
return b, nil
}
func (c *Client) Get(path string, query map[string]string) (json.RawMessage, error) {
return c.do(http.MethodGet, path, query, nil)
}
func (c *Client) Post(path string, body any) (json.RawMessage, error) {
return c.do(http.MethodPost, path, nil, body)
}
func (c *Client) Put(path string, body any) (json.RawMessage, error) {
return c.do(http.MethodPut, path, nil, body)
}
func (c *Client) Delete(path string) (json.RawMessage, error) {
return c.do(http.MethodDelete, path, nil, nil)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+588
View File
@@ -0,0 +1,588 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
serverName = "taskpool"
serverVersion = "1.0.0"
)
var (
mcpServer *server.MCPServer
mcpHTTPServer *server.StreamableHTTPServer
externalClient *Client // 外部客户端(用于 stdio 模式连接远程服务器)
once sync.Once
)
// GetServer 获取 MCP Server 实例(单例)
func GetServer() *server.MCPServer {
once.Do(func() {
mcpServer = server.NewMCPServer(
serverName,
serverVersion,
server.WithToolCapabilities(true),
server.WithResourceCapabilities(false, false),
server.WithPromptCapabilities(true),
server.WithInstructions(instructions),
server.WithRecovery(),
)
registerTools(mcpServer)
registerPrompts(mcpServer)
})
return mcpServer
}
// SetExternalClient 设置外部 OpenAPI 客户端(用于 stdio 模式)
func SetExternalClient(c *Client) {
externalClient = c
}
// getClient 获取当前使用的客户端
func getClient() *Client {
if externalClient != nil {
return externalClient
}
return GetOpenAPIClient()
}
// GetHTTPHandler 获取 MCP HTTP Handler(用于集成到 Gin
func GetHTTPHandler() http.Handler {
s := GetServer()
if mcpHTTPServer == nil {
mcpHTTPServer = server.NewStreamableHTTPServer(s,
server.WithEndpointPath(""),
)
}
return mcpHTTPServer
}
// ServeStdio 启动 stdio 模式的 MCP Server
func ServeStdio(s *server.MCPServer) error {
return server.ServeStdio(s)
}
const instructions = `你是 TaskPool(任务池)运维助手。通过 MCP tools 管理任务调度面板。
核心能力:
- 任务:列表/详情/创建/更新/删除/启停执行
- 脚本:列表/详情/创建/更新/删除
- 环境变量:列表/详情/创建/更新/删除(secret 值为脱敏)
- 日志:列表/详情、最近执行结果
使用原则:
1. 先 list_tasks / get_task 确认目标,再 run_task
2. 删除类操作前向用户确认
3. 不要编造任务 IDID 以 list/get 返回为准
4. 执行后用 list_logs 或 get_log 查看结果
5. 创建任务时 command 必填(普通任务);schedule 使用标准 cron`
func registerTools(s *server.MCPServer) {
// ---- tasks ----
s.AddTool(mcp.NewTool("list_tasks",
mcp.WithDescription("分页列出任务,可按名称/标签/启用状态筛选"),
mcp.WithString("name", mcp.Description("按名称模糊筛选")),
mcp.WithString("tags", mcp.Description("按标签筛选")),
mcp.WithString("enabled", mcp.Description("true/false,是否启用")),
mcp.WithNumber("page", mcp.Description("页码,默认 1")),
mcp.WithNumber("page_size", mcp.Description("每页数量,默认 20")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
q := map[string]string{
"name": strArg(req, "name"),
"tags": strArg(req, "tags"),
"enabled": strArg(req, "enabled"),
"page": numArgStr(req, "page", "1"),
"page_size": numArgStr(req, "page_size", "20"),
}
raw, err := GetOpenAPIClient().Get("/tasks", q)
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("get_task",
mcp.WithDescription("获取单个任务详情"),
mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := req.RequireString("id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
raw, err := GetOpenAPIClient().Get("/tasks/"+id, nil)
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("create_task",
mcp.WithDescription("创建任务。普通任务必须提供 command;可选 schedule(cron)、tags、timeout、work_dir、remark、enabled 等"),
mcp.WithString("name", mcp.Required(), mcp.Description("任务名称")),
mcp.WithString("command", mcp.Description("执行命令,普通任务必填")),
mcp.WithString("schedule", mcp.Description("Cron 表达式,如 0 0 * * *")),
mcp.WithString("remark", mcp.Description("备注")),
mcp.WithString("tags", mcp.Description("标签,逗号分隔")),
mcp.WithString("work_dir", mcp.Description("工作目录")),
mcp.WithNumber("timeout", mcp.Description("超时秒数")),
mcp.WithString("type", mcp.Description("任务类型,默认 common;仓库同步为 repo")),
mcp.WithString("config", mcp.Description("JSON 字符串配置(repo 任务用)")),
mcp.WithString("envs", mcp.Description("环境变量 JSON 字符串")),
mcp.WithString("trigger_type", mcp.Description("触发类型,如 cron")),
mcp.WithNumber("retry_count", mcp.Description("重试次数")),
mcp.WithNumber("retry_interval", mcp.Description("重试间隔秒")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
body := map[string]any{}
setStr(body, "name", strArg(req, "name"))
setStr(body, "command", strArg(req, "command"))
setStr(body, "schedule", strArg(req, "schedule"))
setStr(body, "remark", strArg(req, "remark"))
setStr(body, "tags", strArg(req, "tags"))
setStr(body, "work_dir", strArg(req, "work_dir"))
setStr(body, "type", strArg(req, "type"))
setStr(body, "config", strArg(req, "config"))
setStr(body, "envs", strArg(req, "envs"))
setStr(body, "trigger_type", strArg(req, "trigger_type"))
if n, ok := numArg(req, "timeout"); ok {
body["timeout"] = n
}
if n, ok := numArg(req, "retry_count"); ok {
body["retry_count"] = n
}
if n, ok := numArg(req, "retry_interval"); ok {
body["retry_interval"] = n
}
if body["name"] == nil || body["name"] == "" {
return mcp.NewToolResultError("name 必填"), nil
}
raw, err := GetOpenAPIClient().Post("/tasks", body)
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("update_task",
mcp.WithDescription("更新任务字段(只传需要改的字段)"),
mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID")),
mcp.WithString("name", mcp.Description("任务名称")),
mcp.WithString("command", mcp.Description("命令")),
mcp.WithString("schedule", mcp.Description("Cron")),
mcp.WithString("remark", mcp.Description("备注")),
mcp.WithString("tags", mcp.Description("标签")),
mcp.WithString("work_dir", mcp.Description("工作目录")),
mcp.WithString("enabled", mcp.Description("true/false")),
mcp.WithNumber("timeout", mcp.Description("超时秒数")),
mcp.WithString("envs", mcp.Description("环境变量 JSON 字符串")),
mcp.WithString("type", mcp.Description("任务类型")),
mcp.WithString("config", mcp.Description("配置 JSON 字符串")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := req.RequireString("id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
body := map[string]any{}
setStr(body, "name", strArg(req, "name"))
setStr(body, "command", strArg(req, "command"))
setStr(body, "schedule", strArg(req, "schedule"))
setStr(body, "remark", strArg(req, "remark"))
setStr(body, "tags", strArg(req, "tags"))
setStr(body, "work_dir", strArg(req, "work_dir"))
setStr(body, "envs", strArg(req, "envs"))
setStr(body, "type", strArg(req, "type"))
setStr(body, "config", strArg(req, "config"))
if e := strArg(req, "enabled"); e != "" {
body["enabled"] = strings.EqualFold(e, "true") || e == "1"
}
if n, ok := numArg(req, "timeout"); ok {
body["timeout"] = n
}
raw, err := GetOpenAPIClient().Put("/tasks/"+id, body)
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("delete_task",
mcp.WithDescription("删除任务(危险操作,执行前请确认)"),
mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := req.RequireString("id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
raw, err := GetOpenAPIClient().Delete("/tasks/" + id)
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("list_task_tags",
mcp.WithDescription("获取所有任务标签"),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
raw, err := GetOpenAPIClient().Get("/tasks/tags", nil)
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("run_task",
mcp.WithDescription("立即执行指定任务。可选传入临时环境变量 envs_json"),
mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID")),
mcp.WithString("envs_json", mcp.Description("临时环境变量 JSON 对象字符串,如 {\"FOO\":\"bar\"}")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := req.RequireString("id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
var body any
if ej := strArg(req, "envs_json"); ej != "" {
var envs map[string]string
if err := json.Unmarshal([]byte(ej), &envs); err != nil {
return mcp.NewToolResultError("envs_json 不是合法 JSON 对象: " + err.Error()), nil
}
body = map[string]any{"envs": envs}
} else {
body = map[string]any{}
}
raw, err := GetOpenAPIClient().Post("/execute/task/"+id, body)
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("stop_task",
mcp.WithDescription("停止正在运行的任务(需要执行日志 log_id)"),
mcp.WithString("log_id", mcp.Required(), mcp.Description("运行日志 ID")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
logID, err := req.RequireString("log_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
raw, err := GetOpenAPIClient().Post("/tasks/stop/"+logID, map[string]any{})
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("get_last_results",
mcp.WithDescription("获取最近若干次执行结果摘要"),
mcp.WithNumber("count", mcp.Description("数量,默认 10")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
q := map[string]string{"count": numArgStr(req, "count", "10")}
raw, err := GetOpenAPIClient().Get("/execute/results", q)
return toolResult(raw, err)
})
// ---- logs ----
s.AddTool(mcp.NewTool("list_logs",
mcp.WithDescription("分页查询任务执行日志"),
mcp.WithString("task_id", mcp.Description("任务 ID")),
mcp.WithString("task_name", mcp.Description("任务名称模糊")),
mcp.WithString("status", mcp.Description("状态筛选")),
mcp.WithNumber("page", mcp.Description("页码,默认 1")),
mcp.WithNumber("page_size", mcp.Description("每页数量,默认 20")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
q := map[string]string{
"task_id": strArg(req, "task_id"),
"task_name": strArg(req, "task_name"),
"status": strArg(req, "status"),
"page": numArgStr(req, "page", "1"),
"page_size": numArgStr(req, "page_size", "20"),
}
raw, err := GetOpenAPIClient().Get("/logs", q)
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("get_log",
mcp.WithDescription("获取单条执行日志详情(含输出)"),
mcp.WithString("id", mcp.Required(), mcp.Description("日志 ID")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := req.RequireString("id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
raw, err := GetOpenAPIClient().Get("/logs/"+id, nil)
return toolResult(raw, err)
})
// ---- scripts ----
s.AddTool(mcp.NewTool("list_scripts",
mcp.WithDescription("列出脚本(不含内容)"),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
raw, err := GetOpenAPIClient().Get("/scripts", nil)
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("get_script",
mcp.WithDescription("获取脚本详情(含内容)"),
mcp.WithString("id", mcp.Required(), mcp.Description("脚本 ID")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := req.RequireString("id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
raw, err := GetOpenAPIClient().Get("/scripts/"+id, nil)
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("create_script",
mcp.WithDescription("创建脚本"),
mcp.WithString("name", mcp.Required(), mcp.Description("脚本名称/路径")),
mcp.WithString("content", mcp.Required(), mcp.Description("脚本内容")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name, err := req.RequireString("name")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
content, err := req.RequireString("content")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
raw, err := GetOpenAPIClient().Post("/scripts", map[string]any{"name": name, "content": content})
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("update_script",
mcp.WithDescription("更新脚本"),
mcp.WithString("id", mcp.Required(), mcp.Description("脚本 ID")),
mcp.WithString("name", mcp.Description("名称")),
mcp.WithString("content", mcp.Description("内容")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := req.RequireString("id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
body := map[string]any{}
setStr(body, "name", strArg(req, "name"))
setStr(body, "content", strArg(req, "content"))
raw, err := GetOpenAPIClient().Put("/scripts/"+id, body)
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("delete_script",
mcp.WithDescription("删除脚本(危险操作)"),
mcp.WithString("id", mcp.Required(), mcp.Description("脚本 ID")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := req.RequireString("id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
raw, err := GetOpenAPIClient().Delete("/scripts/" + id)
return toolResult(raw, err)
})
// ---- env ----
s.AddTool(mcp.NewTool("list_envs",
mcp.WithDescription("分页列出环境变量(secret 值已脱敏)"),
mcp.WithString("name", mcp.Description("按名称筛选")),
mcp.WithString("type", mcp.Description("类型筛选")),
mcp.WithString("tags", mcp.Description("标签筛选")),
mcp.WithNumber("page", mcp.Description("页码")),
mcp.WithNumber("page_size", mcp.Description("每页数量")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
q := map[string]string{
"name": strArg(req, "name"),
"type": strArg(req, "type"),
"tags": strArg(req, "tags"),
"page": numArgStr(req, "page", "1"),
"page_size": numArgStr(req, "page_size", "20"),
}
raw, err := GetOpenAPIClient().Get("/env", q)
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("list_all_envs",
mcp.WithDescription("获取全部环境变量(不分页,secret 脱敏)"),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
raw, err := GetOpenAPIClient().Get("/env/all", nil)
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("get_env",
mcp.WithDescription("获取环境变量详情"),
mcp.WithString("id", mcp.Required(), mcp.Description("环境变量 ID")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := req.RequireString("id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
raw, err := GetOpenAPIClient().Get("/env/"+id, nil)
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("create_env",
mcp.WithDescription("创建环境变量"),
mcp.WithString("name", mcp.Required(), mcp.Description("变量名")),
mcp.WithString("value", mcp.Required(), mcp.Description("变量值")),
mcp.WithString("type", mcp.Description("类型,如 text/secret")),
mcp.WithString("remark", mcp.Description("备注")),
mcp.WithString("tags", mcp.Description("标签")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name, err := req.RequireString("name")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
value, err := req.RequireString("value")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
body := map[string]any{"name": name, "value": value}
setStr(body, "type", strArg(req, "type"))
setStr(body, "remark", strArg(req, "remark"))
setStr(body, "tags", strArg(req, "tags"))
raw, err := GetOpenAPIClient().Post("/env", body)
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("update_env",
mcp.WithDescription("更新环境变量"),
mcp.WithString("id", mcp.Required(), mcp.Description("环境变量 ID")),
mcp.WithString("name", mcp.Description("变量名")),
mcp.WithString("value", mcp.Description("变量值")),
mcp.WithString("type", mcp.Description("类型")),
mcp.WithString("remark", mcp.Description("备注")),
mcp.WithString("tags", mcp.Description("标签")),
mcp.WithString("enabled", mcp.Description("true/false")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := req.RequireString("id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
body := map[string]any{}
setStr(body, "name", strArg(req, "name"))
setStr(body, "value", strArg(req, "value"))
setStr(body, "type", strArg(req, "type"))
setStr(body, "remark", strArg(req, "remark"))
setStr(body, "tags", strArg(req, "tags"))
if e := strArg(req, "enabled"); e != "" {
body["enabled"] = strings.EqualFold(e, "true") || e == "1"
}
raw, err := GetOpenAPIClient().Put("/env/"+id, body)
return toolResult(raw, err)
})
s.AddTool(mcp.NewTool("delete_env",
mcp.WithDescription("删除环境变量(危险操作)"),
mcp.WithString("id", mcp.Required(), mcp.Description("环境变量 ID")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := req.RequireString("id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
raw, err := GetOpenAPIClient().Delete("/env/" + id)
return toolResult(raw, err)
})
// ---- meta ----
s.AddTool(mcp.NewTool("ping_api",
mcp.WithDescription("检查 OpenAPI 连通性与鉴权是否有效(通过 list_tasks page_size=1"),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
raw, err := GetOpenAPIClient().Get("/tasks", map[string]string{"page": "1", "page_size": "1"})
if err != nil {
return mcp.NewToolResultError("连通失败: " + err.Error()), nil
}
return mcp.NewToolResultText("OpenAPI 连通正常\n" + prettyRaw(raw)), nil
})
}
func registerPrompts(s *server.MCPServer) {
s.AddPrompt(mcp.NewPrompt("ops_overview",
mcp.WithPromptDescription("巡检任务池:列出任务与最近失败日志"),
), func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
return mcp.NewGetPromptResult(
"TaskPool 巡检",
[]mcp.PromptMessage{
mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(
"请使用 TaskPool MCP1) list_tasks 查看任务概况 2) list_logs 筛选失败状态 3) 对重要失败 get_log 总结原因并给出是否建议 run_task 重跑。",
)),
},
), nil
})
s.AddPrompt(mcp.NewPrompt("run_and_check",
mcp.WithPromptDescription("执行指定任务并检查日志"),
mcp.WithArgument("task_id", mcp.RequiredArgument(), mcp.ArgumentDescription("任务 ID")),
), func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
taskID := ""
if request.Params.Arguments != nil {
taskID = request.Params.Arguments["task_id"]
}
text := fmt.Sprintf("请对 TaskPool 任务 %s1) get_task 确认 2) run_task 执行 3) list_logs(task_id) 取最新日志 4) get_log 汇总输出与状态。", taskID)
return mcp.NewGetPromptResult(
"执行并检查",
[]mcp.PromptMessage{
mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(text)),
},
), nil
})
}
func toolResult(raw json.RawMessage, err error) (*mcp.CallToolResult, error) {
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(prettyRaw(raw)), nil
}
func strArg(req mcp.CallToolRequest, key string) string {
v, err := req.RequireString(key)
if err != nil {
if args, ok := req.Params.Arguments.(map[string]any); ok {
if x, ok := args[key]; ok {
return strings.TrimSpace(fmt.Sprint(x))
}
}
return ""
}
return strings.TrimSpace(v)
}
func numArg(req mcp.CallToolRequest, key string) (float64, bool) {
if args, ok := req.Params.Arguments.(map[string]any); ok {
if x, ok := args[key]; ok {
switch t := x.(type) {
case float64:
return t, true
case int:
return float64(t), true
case string:
if t == "" {
return 0, false
}
f, err := strconv.ParseFloat(t, 64)
if err != nil {
return 0, false
}
return f, true
}
}
}
return 0, false
}
func numArgStr(req mcp.CallToolRequest, key, def string) string {
if n, ok := numArg(req, key); ok {
return strconv.FormatInt(int64(n), 10)
}
if s := strArg(req, key); s != "" {
return s
}
return def
}
func setStr(m map[string]any, k, v string) {
if strings.TrimSpace(v) != "" {
m[k] = v
}
}
func prettyJSON(v any) string {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
func prettyRaw(raw json.RawMessage) string {
if len(raw) == 0 {
return "null"
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return string(raw)
}
return prettyJSON(v)
}
+29
View File
@@ -0,0 +1,29 @@
package router
import (
"github.com/engigu/taskpool/internal/middleware"
"github.com/gin-gonic/gin"
)
// initMCPRoutes 初始化 MCP Server 路由
// 使用 OpenAPI Token 进行鉴权
func initMCPRoutes(root *gin.RouterGroup, c *Controllers) {
// MCP 路由组 (使用 Bearer Token 鉴权,复用 OpenAPI 中间件)
mcp := root.Group("/mcp")
mcp.Use(middleware.OpenapiRequired())
{
// 所有 MCP 请求都通过同一个 handler 处理
// StreamableHTTP 协议支持 POST 和 GET
// 使用 /*action 匹配 /mcp 和 /mcp/ 以及 /mcp/xxx
mcp.POST("/*action", c.MCP.HandleMCP)
mcp.GET("/*action", c.MCP.HandleMCP)
}
// 同时处理 /mcp 根路径(无尾部斜杠)
mcpRoot := root.Group("/mcp")
mcpRoot.Use(middleware.OpenapiRequired())
{
mcpRoot.POST("", c.MCP.HandleMCP)
mcpRoot.GET("", c.MCP.HandleMCP)
}
}
+1
View File
@@ -72,6 +72,7 @@ func RegisterControllers() *Controllers {
Interconnect: controllers.NewInterconnectController(interconnectService), Interconnect: controllers.NewInterconnectController(interconnectService),
Data: controllers.NewDataController(taskController, envController), Data: controllers.NewDataController(taskController, envController),
Install: controllers.NewInstallController(), Install: controllers.NewInstallController(),
MCP: controllers.NewMCPController(),
} }
} }
+4
View File
@@ -35,6 +35,7 @@ type Controllers struct {
Interconnect *controllers.InterconnectController Interconnect *controllers.InterconnectController
Data *controllers.DataController Data *controllers.DataController
Install *controllers.InstallController Install *controllers.InstallController
MCP *controllers.MCPController
} }
func Setup(c *Controllers) *gin.Engine { func Setup(c *Controllers) *gin.Engine {
@@ -79,6 +80,9 @@ func Setup(c *Controllers) *gin.Engine {
initAgentAPIRoutes(root, c) initAgentAPIRoutes(root, c)
initOpenAPIV1Routes(root, c) initOpenAPIV1Routes(root, c)
// 5. [ location /mcp ] MCP Server 路由(供 AI Agent 使用)
initMCPRoutes(root, c)
// ========================================================================= // =========================================================================
// [ location / ] 全局 404 兜底与 SPA 渲染 // [ location / ] 全局 404 兜底与 SPA 渲染
// 对应 Nginx: try_files $uri $uri/ /index.html; // 对应 Nginx: try_files $uri $uri/ /index.html;
+4 -5
View File
@@ -116,9 +116,8 @@ func LoadConfig(path string) (*AppConfig, error) {
}, },
} }
// 检查配置文件是否存在 // 检查配置文件是否存在:文件先加载,再用环境变量覆盖(compose 注入优先)
if _, err := os.Stat(path); err == nil { if _, err := os.Stat(path); err == nil {
// 配置文件存在,从文件加载
logger.Infof("[Config] 从文件加载配置: %s", path) logger.Infof("[Config] 从文件加载配置: %s", path)
cfg, err := ini.Load(path) cfg, err := ini.Load(path)
if err != nil { if err != nil {
@@ -128,10 +127,10 @@ func LoadConfig(path string) (*AppConfig, error) {
return nil, err return nil, err
} }
} else { } else {
// 配置文件不存在,使用环境变量 logger.Info("[Config] 配置文件不存在,使用默认值 + 环境变量")
logger.Info("[Config] 配置文件不存在,从环境变量加载")
applyEnvOverrides()
} }
// 环境变量始终可覆盖(便于 Docker Compose 注入 MySQL/Redis 等)
applyEnvOverrides()
// 设置默认数据库路径 // 设置默认数据库路径
if Config.Database.Path == "" { if Config.Database.Path == "" {
+2 -1
View File
@@ -52,7 +52,8 @@ func main() {
} }
if handler, ok := cmd.Handlers[commandName]; ok { if handler, ok := cmd.Handlers[commandName]; ok {
if commandName != "-v" && commandName != "-V" && commandName != "version" { // mcp 是独立 stdio 进程,禁止初始化 DB/日志,避免污染协议流
if commandName != "-v" && commandName != "-V" && commandName != "version" && commandName != "mcp" {
bootstrap.InitBasicForCmd() // 专为命令行工具定制启动基础环境,屏蔽后台启动刷屏日志 bootstrap.InitBasicForCmd() // 专为命令行工具定制启动基础环境,屏蔽后台启动刷屏日志
} }
handler(os.Args[2:]) handler(os.Args[2:])
+26 -5
View File
@@ -9,11 +9,6 @@ server {
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
gzip_min_length 1000; gzip_min_length 1000;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# API proxy - 指向同一容器内的后端 # API proxy - 指向同一容器内的后端
location /api/ { location /api/ {
proxy_pass http://127.0.0.1:8052; proxy_pass http://127.0.0.1:8052;
@@ -24,6 +19,32 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
} }
# MCP Server proxy - AI Agent 接入
location /mcp {
proxy_pass http://127.0.0.1:8052;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 支持 SSE 流式响应
proxy_buffering off;
proxy_cache off;
}
# OpenAPI proxy
location /open2api/ {
proxy_pass http://127.0.0.1:8052;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets # Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y; expires 1y;
+72
View File
@@ -0,0 +1,72 @@
# TaskPool Agent Skill
你正在通过 **TaskPool MCP Server** 操作「任务池」自动化调度面板。
真正执行依赖 MCP Tools;本 Skill 只规定操作流程与安全边界。
## 何时使用
用户提到:任务、定时任务、脚本、环境变量、执行日志、重跑失败任务、巡检 TaskPool/任务池 时,优先使用 `taskpool` MCP。
## 连接检查
1. 先调用 `ping_api`
2. 失败则提示检查:
- 面板是否启用 OpenAPI
- `TASKPOOL_URL` / `TASKPOOL_TOKEN` 是否正确
- 后端是否在运行
## 标准流程
### 巡检
1. `list_tasks`(可加 `enabled=true`
2. `list_logs` 查看最近失败(如有 status 参数则筛选 failed
3. 对关键失败调用 `get_log` 总结原因
4. 仅在用户同意后 `run_task` 重跑
### 执行已有任务
1. `list_tasks` 或用户给出的 id → `get_task` 确认
2. `run_task`id 必填)
3. `list_logs(task_id=...)` 取最新
4. `get_log` 汇报状态与关键输出
### 创建定时任务
1. 向用户确认:名称、命令、cron、工作目录、超时
2. `create_task`name 必填;普通任务 command 必填)
3. `get_task` 回读确认
### 改任务
1. `get_task`
2. `update_task` 只传要改的字段
3. 回读确认
### 脚本 / 环境变量
- 脚本:`list_scripts``get_script` → 必要时 create/update
- 环境变量:`list_envs` / `get_env`;secret 值通常脱敏,不要臆造明文
## 安全规则
1. **删除**`delete_task` / `delete_script` / `delete_env`)前必须明确征得用户同意
2. 不要编造任务/日志 ID
3. 不要把 Token、secret 环境变量原文写入回复
4. 不要用 MCP 执行「任意系统破坏性命令」;只操作面板内已建模的任务
## 能力边界
| 可做 | 不可做(当前) |
|------|----------------|
| 任务 CRUD、执行、停跑 | 在线终端交互 |
| 脚本 CRUD | Mise 语言安装 UI 全流程 |
| 环境变量 CRUD | 消息渠道复杂配置向导 |
| 日志查询 | 直接操作数据库 |
## 话术示例
- 「帮我看看任务池里有哪些启用任务」
- 「把备份任务跑一遍并告诉我结果」
- 「最近失败的任务是什么原因,要不要重跑?」
- 「建一个每天 3 点执行的清理任务」
+15 -7
View File
@@ -1,21 +1,29 @@
[supervisord] [supervisord]
nodaemon=true nodaemon=true
logfile=/app/logs/supervisord.log logfile=/dev/null
pidfile=/var/run/supervisord.pid pidfile=/var/run/supervisord.pid
user=root user=root
loglevel=info
[program:backend] [program:backend]
command=/app/taskpool command=/bin/sh /app/start-backend.sh
directory=/app directory=/app
autostart=true autostart=true
autorestart=true autorestart=true
stderr_logfile=/app/logs/backend.err.log startsecs=2
stdout_logfile=/app/logs/backend.out.log startretries=20
environment=BH_SERVER_PORT="8052",BH_SERVER_HOST="0.0.0.0" # 关键:把 backend 日志打到 docker logs,而不是只写文件
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
# 不在此写 environment=,完整继承 compose/container 环境变量
[program:nginx] [program:nginx]
command=nginx -g "daemon off;" command=nginx -g "daemon off;"
autostart=true autostart=true
autorestart=true autorestart=true
stderr_logfile=/app/logs/nginx.err.log stdout_logfile=/dev/stdout
stdout_logfile=/app/logs/nginx.out.log stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
+235 -15
View File
@@ -45,6 +45,9 @@ importers:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
devDependencies: devDependencies:
'@tailwindcss/vite':
specifier: ^4.3.3
version: 4.3.3(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0))
'@types/node': '@types/node':
specifier: ^22.0.0 specifier: ^22.0.0
version: 22.20.1 version: 22.20.1
@@ -56,7 +59,7 @@ importers:
version: 19.2.3(@types/react@19.2.17) version: 19.2.3(@types/react@19.2.17)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^6.0.3 specifier: ^6.0.3
version: 6.0.3(vite@8.1.5(@types/node@22.20.1)) version: 6.0.3(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0))
autoprefixer: autoprefixer:
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.5.4(postcss@8.5.19) version: 10.5.4(postcss@8.5.19)
@@ -71,7 +74,7 @@ importers:
version: 5.9.3 version: 5.9.3
vite: vite:
specifier: ^8.1.5 specifier: ^8.1.5
version: 8.1.5(@types/node@22.20.1) version: 8.1.5(@types/node@22.20.1)(jiti@2.7.0)
packages: packages:
@@ -88,6 +91,22 @@ packages:
'@emnapi/wasi-threads@1.2.2': '@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@monaco-editor/loader@1.7.0': '@monaco-editor/loader@1.7.0':
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
@@ -142,42 +161,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.1.5': '@rolldown/binding-linux-arm64-musl@1.1.5':
resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.1.5': '@rolldown/binding-linux-ppc64-gnu@1.1.5':
resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.1.5': '@rolldown/binding-linux-s390x-gnu@1.1.5':
resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.1.5': '@rolldown/binding-linux-x64-gnu@1.1.5':
resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.1.5': '@rolldown/binding-linux-x64-musl@1.1.5':
resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.1.5': '@rolldown/binding-openharmony-arm64@1.1.5':
resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==}
@@ -205,6 +218,96 @@ packages:
'@rolldown/pluginutils@1.0.1': '@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@tailwindcss/node@4.3.3':
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
'@tailwindcss/oxide-android-arm64@4.3.3':
resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.3.3':
resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.3.3':
resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.3.3':
resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
engines: {node: '>= 20'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-arm64-musl@4.3.3':
resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-x64-gnu@4.3.3':
resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-linux-x64-musl@4.3.3':
resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-wasm32-wasi@4.3.3':
resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
- '@napi-rs/wasm-runtime'
- '@emnapi/core'
- '@emnapi/runtime'
- '@tybys/wasm-util'
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.3.3':
resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.3.3':
resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
engines: {node: '>= 20'}
'@tailwindcss/vite@4.3.3':
resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==}
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
'@tanstack/history@1.162.0': '@tanstack/history@1.162.0':
resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==}
engines: {node: '>=20.19'} engines: {node: '>=20.19'}
@@ -403,6 +506,10 @@ packages:
electron-to-chromium@1.5.393: electron-to-chromium@1.5.393:
resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==}
enhanced-resolve@5.24.3:
resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==}
engines: {node: '>=10.13.0'}
escalade@3.2.0: escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -445,6 +552,9 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin] os: [darwin]
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
internmap@2.0.3: internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -453,6 +563,10 @@ packages:
resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==} resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==}
engines: {node: '>=18'} engines: {node: '>=18'}
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -491,28 +605,24 @@ packages:
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0: lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0: lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0: lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0: lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -542,6 +652,9 @@ packages:
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@14.0.0: marked@14.0.0:
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@@ -652,6 +765,10 @@ packages:
tailwindcss@4.3.3: tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
tapable@2.3.3:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
tiny-invariant@1.3.3: tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -765,6 +882,25 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@monaco-editor/loader@1.7.0': '@monaco-editor/loader@1.7.0':
dependencies: dependencies:
state-local: 1.0.7 state-local: 1.0.7
@@ -836,6 +972,74 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {} '@rolldown/pluginutils@1.0.1': {}
'@tailwindcss/node@4.3.3':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.24.3
jiti: 2.7.0
lightningcss: 1.32.0
magic-string: 0.30.21
source-map-js: 1.2.1
tailwindcss: 4.3.3
'@tailwindcss/oxide-android-arm64@4.3.3':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.3.3':
optional: true
'@tailwindcss/oxide-darwin-x64@4.3.3':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.3.3':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.3.3':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.3.3':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.3.3':
optional: true
'@tailwindcss/oxide-wasm32-wasi@4.3.3':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.3.3':
optional: true
'@tailwindcss/oxide@4.3.3':
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.3.3
'@tailwindcss/oxide-darwin-arm64': 4.3.3
'@tailwindcss/oxide-darwin-x64': 4.3.3
'@tailwindcss/oxide-freebsd-x64': 4.3.3
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
'@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
'@tailwindcss/oxide-linux-arm64-musl': 4.3.3
'@tailwindcss/oxide-linux-x64-gnu': 4.3.3
'@tailwindcss/oxide-linux-x64-musl': 4.3.3
'@tailwindcss/oxide-wasm32-wasi': 4.3.3
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
'@tailwindcss/oxide-win32-x64-msvc': 4.3.3
'@tailwindcss/vite@4.3.3(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0))':
dependencies:
'@tailwindcss/node': 4.3.3
'@tailwindcss/oxide': 4.3.3
tailwindcss: 4.3.3
vite: 8.1.5(@types/node@22.20.1)(jiti@2.7.0)
'@tanstack/history@1.162.0': {} '@tanstack/history@1.162.0': {}
'@tanstack/query-core@5.101.2': {} '@tanstack/query-core@5.101.2': {}
@@ -922,10 +1126,10 @@ snapshots:
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':
optional: true optional: true
'@vitejs/plugin-react@6.0.3(vite@8.1.5(@types/node@22.20.1))': '@vitejs/plugin-react@6.0.3(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0))':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.1 '@rolldown/pluginutils': 1.0.1
vite: 8.1.5(@types/node@22.20.1) vite: 8.1.5(@types/node@22.20.1)(jiti@2.7.0)
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
dependencies: dependencies:
@@ -1013,6 +1217,11 @@ snapshots:
electron-to-chromium@1.5.393: {} electron-to-chromium@1.5.393: {}
enhanced-resolve@5.24.3:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.3
escalade@3.2.0: {} escalade@3.2.0: {}
eventemitter3@4.0.7: {} eventemitter3@4.0.7: {}
@@ -1037,10 +1246,14 @@ snapshots:
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
graceful-fs@4.2.11: {}
internmap@2.0.3: {} internmap@2.0.3: {}
isbot@5.2.1: {} isbot@5.2.1: {}
jiti@2.7.0: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
@@ -1102,6 +1315,10 @@ snapshots:
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@14.0.0: {} marked@14.0.0: {}
monaco-editor@0.55.1: monaco-editor@0.55.1:
@@ -1219,6 +1436,8 @@ snapshots:
tailwindcss@4.3.3: {} tailwindcss@4.3.3: {}
tapable@2.3.3: {}
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
tinyglobby@0.2.17: tinyglobby@0.2.17:
@@ -1259,7 +1478,7 @@ snapshots:
d3-time: 3.1.0 d3-time: 3.1.0
d3-timer: 3.0.1 d3-timer: 3.0.1
vite@8.1.5(@types/node@22.20.1): vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0):
dependencies: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
picomatch: 4.0.5 picomatch: 4.0.5
@@ -1269,6 +1488,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/node': 22.20.1 '@types/node': 22.20.1
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.7.0
zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)):
optionalDependencies: optionalDependencies:
+10 -2
View File
@@ -65,8 +65,16 @@ export interface MonitorStats {
}[] }[]
} }
} }
export let activeInterconnectNodeId = localStorage.getItem('activeInterconnectNodeId') || ''
export let activeInterconnectNodeName = localStorage.getItem('activeInterconnectNodeName') || '' // 初始化时使用空字符串,避免 SSR 时访问 localStorage
export let activeInterconnectNodeId = ''
export let activeInterconnectNodeName = ''
// 在客户端初始化时从 localStorage 读取
if (typeof window !== 'undefined') {
activeInterconnectNodeId = localStorage.getItem('activeInterconnectNodeId') || ''
activeInterconnectNodeName = localStorage.getItem('activeInterconnectNodeName') || ''
}
export function setActiveInterconnectNodeId(id: string, name?: string) { export function setActiveInterconnectNodeId(id: string, name?: string) {
activeInterconnectNodeId = id activeInterconnectNodeId = id
+10 -2
View File
@@ -65,8 +65,16 @@ export interface MonitorStats {
}[] }[]
} }
} }
export let activeInterconnectNodeId = localStorage.getItem('activeInterconnectNodeId') || ''
export let activeInterconnectNodeName = localStorage.getItem('activeInterconnectNodeName') || '' // 初始化时使用空字符串,避免 SSR 时访问 localStorage
export let activeInterconnectNodeId = ''
export let activeInterconnectNodeName = ''
// 在客户端初始化时从 localStorage 读取
if (typeof window !== 'undefined') {
activeInterconnectNodeId = localStorage.getItem('activeInterconnectNodeId') || ''
activeInterconnectNodeName = localStorage.getItem('activeInterconnectNodeName') || ''
}
export function setActiveInterconnectNodeId(id: string, name?: string) { export function setActiveInterconnectNodeId(id: string, name?: string) {
activeInterconnectNodeId = id activeInterconnectNodeId = id
+180 -82
View File
@@ -1,8 +1,10 @@
import { Outlet, useLocation, useNavigate } from '@tanstack/react-router' import { Outlet, useLocation, useNavigate } from '@tanstack/react-router'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { import {
Activity, Activity,
Bell, Bell,
ChevronLeft,
ChevronRight,
FileCode, FileCode,
Globe, Globe,
KeyRound, KeyRound,
@@ -12,23 +14,26 @@ import {
Menu, Menu,
Moon, Moon,
Network, Network,
PanelLeftClose,
PanelLeftOpen,
ScrollText, ScrollText,
Server, Server,
Settings, Settings,
Sun, Sun,
Terminal, Terminal,
UserRound,
Variable, Variable,
X, X,
} from 'lucide-react' } from 'lucide-react'
import { useTheme } from '@/context/ThemeContext' import { useTheme } from '@/context/ThemeContext'
import { api } from '@/api' import { api } from '@/api'
import { usePublicSite, useSentence } from '@/api/hooks' import { usePublicSite, useSentence, useUser } from '@/api/hooks'
import { NodeSwitcher } from '@/components/business/NodeSwitcher' import { NodeSwitcher } from '@/components/business/NodeSwitcher'
import { SystemNotice } from '@/components/business/SystemNotice' import { SystemNotice } from '@/components/business/SystemNotice'
import { ToastHost } from '@/components/business/ToastHost' import { ToastHost } from '@/components/business/ToastHost'
import { resetAuthCache } from '@/lib/auth' import { resetAuthCache } from '@/lib/auth'
import { eventBus } from '@/lib/event-bus' import { eventBus } from '@/lib/event-bus'
import { Button, cn } from '@/components/ui' import { cn } from '@/components/ui'
const NAV_ITEMS = [ const NAV_ITEMS = [
{ path: '/', icon: LayoutDashboard, label: '数据仪表', exact: true }, { path: '/', icon: LayoutDashboard, label: '数据仪表', exact: true },
@@ -46,6 +51,8 @@ const NAV_ITEMS = [
{ path: '/settings', icon: Settings, label: '系统设置', exact: true }, { path: '/settings', icon: Settings, label: '系统设置', exact: true },
] as const ] as const
const COLLAPSE_KEY = 'taskpool.sidebar.collapsed'
function isActive(pathname: string, path: string, exact: boolean) { function isActive(pathname: string, path: string, exact: boolean) {
if (exact) return pathname === path if (exact) return pathname === path
return pathname === path || pathname.startsWith(path + '/') return pathname === path || pathname.startsWith(path + '/')
@@ -57,11 +64,23 @@ export default function Layout() {
const { theme, toggleTheme } = useTheme() const { theme, toggleTheme } = useTheme()
const { data: site } = usePublicSite() const { data: site } = usePublicSite()
const { data: sentenceData } = useSentence() const { data: sentenceData } = useSentence()
const { data: user } = useUser()
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [collapsed, setCollapsed] = useState(() => {
try {
return localStorage.getItem(COLLAPSE_KEY) === '1'
} catch {
return false
}
})
const [userMenuOpen, setUserMenuOpen] = useState(false)
const userMenuRef = useRef<HTMLDivElement>(null)
const title = site?.title || '任务池' const title = site?.title || '任务池'
const sentence = sentenceData?.sentence || '欢迎使用任务池' const sentence = sentenceData?.sentence || '欢迎使用任务池'
const username = user?.username || '管理员'
const sidebarCollapsed = !isMobile && collapsed
useEffect(() => { useEffect(() => {
document.title = title document.title = title
@@ -86,12 +105,32 @@ export default function Layout() {
useEffect(() => { useEffect(() => {
setMenuOpen(false) setMenuOpen(false)
setUserMenuOpen(false)
}, [location.pathname]) }, [location.pathname])
useEffect(() => { useEffect(() => {
eventBus.init() eventBus.init()
}, []) }, [])
useEffect(() => {
try {
localStorage.setItem(COLLAPSE_KEY, collapsed ? '1' : '0')
} catch {
// ignore
}
}, [collapsed])
useEffect(() => {
function onDocClick(e: MouseEvent) {
if (!userMenuRef.current) return
if (!userMenuRef.current.contains(e.target as Node)) {
setUserMenuOpen(false)
}
}
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
}, [])
async function logout() { async function logout() {
try { try {
await api.auth.logout() await api.auth.logout()
@@ -104,103 +143,162 @@ export default function Layout() {
return ( return (
<div className="flex min-h-screen w-full bg-[var(--bg-primary)] text-[var(--text-primary)]"> <div className="flex min-h-screen w-full bg-[var(--bg-primary)] text-[var(--text-primary)]">
{isMobile && menuOpen && ( {isMobile && menuOpen && (
<div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-[1px]" onClick={() => setMenuOpen(false)} /> <div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-[1px]" onClick={() => setMenuOpen(false)} />
)} )}
<aside <aside
className={cn(
'fixed inset-y-0 left-0 z-50 flex shrink-0 flex-col border-r border-[var(--border)] bg-[var(--bg-secondary)] transition-[width,transform] duration-200 ease-out lg:static lg:translate-x-0',
sidebarCollapsed ? 'w-[52px]' : 'w-[200px]',
isMobile && !menuOpen ? '-translate-x-full' : 'translate-x-0',
)}
>
<div
className={cn( className={cn(
'fixed inset-y-0 left-0 z-50 flex w-[200px] shrink-0 flex-col border-r border-[var(--border)] bg-[var(--bg-secondary)] transition-transform duration-200 lg:static lg:translate-x-0', 'relative flex h-14 items-center border-b border-[var(--border)]',
isMobile && !menuOpen ? '-translate-x-full' : 'translate-x-0', sidebarCollapsed ? 'justify-center px-1' : 'gap-2.5 px-4',
)} )}
> >
<div className="relative flex h-14 items-center gap-2.5 border-b border-[var(--border)] px-4"> <div
<div className="ms-on-accent flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md bg-[var(--bg-accent)] text-[13px] font-bold text-[var(--text-on-accent)]"> className={cn(
T 'ms-on-accent flex shrink-0 select-none items-center justify-center rounded-md bg-[var(--bg-accent)] font-bold text-[var(--text-on-accent)]',
</div> sidebarCollapsed ? 'h-7 w-7 text-[12px]' : 'h-8 w-8 text-[13px]',
)}
>
T
</div>
{!sidebarCollapsed && (
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-semibold tracking-tight">{title}</div> <div className="truncate text-[13px] font-semibold tracking-tight">{title}</div>
<div className="truncate text-[10px] text-[var(--text-muted)]">TaskPool</div> <div className="truncate text-[10px] text-[var(--text-muted)]">TaskPool</div>
</div> </div>
{isMobile && ( )}
{isMobile && (
<button
className="rounded-md p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]"
onClick={() => setMenuOpen(false)}
>
<X size={16} strokeWidth={1.5} />
</button>
)}
</div>
<nav className={cn('flex flex-1 flex-col gap-0.5 overflow-y-auto py-3', sidebarCollapsed ? 'px-1.5' : 'px-2.5')}>
{NAV_ITEMS.map(({ path, icon: Icon, label, exact }) => {
const active = isActive(location.pathname, path, exact)
return (
<button <button
className="rounded-md p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]" key={path}
onClick={() => setMenuOpen(false)} title={sidebarCollapsed ? label : undefined}
onClick={() => navigate({ to: path })}
className={cn(
'group relative flex h-9 w-full items-center rounded-md text-[13px] transition-[background,color,border-color] duration-150',
sidebarCollapsed ? 'justify-center px-0' : 'gap-2.5 px-2.5',
active
? 'border border-[var(--border-hover)] bg-[var(--bg-tertiary)] font-medium text-[var(--text-primary)]'
: 'border border-transparent text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]',
)}
> >
<X size={16} strokeWidth={1.5} /> <Icon
size={15}
strokeWidth={1.5}
className={cn(active ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)] group-hover:text-[var(--text-primary)]')}
/>
{!sidebarCollapsed && (
<>
<span className="truncate">{label}</span>
{active ? <span className="ml-auto h-1.5 w-1.5 rounded-full bg-[var(--bg-accent)]" /> : null}
</>
)}
</button>
)
})}
</nav>
</aside>
<main className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--bg-secondary)_92%,transparent)] px-4 backdrop-blur-md">
<div className="flex min-w-0 flex-1 items-center gap-2">
{isMobile ? (
<button
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]"
onClick={() => setMenuOpen(true)}
title="打开菜单"
>
<Menu size={16} strokeWidth={1.5} />
</button>
) : (
<button
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]"
onClick={() => setCollapsed((v) => !v)}
title={collapsed ? '展开侧边栏' : '收起侧边栏'}
>
{collapsed ? <PanelLeftOpen size={16} strokeWidth={1.5} /> : <PanelLeftClose size={16} strokeWidth={1.5} />}
</button> </button>
)} )}
<p className="truncate text-[13px] text-[var(--text-muted)]" title={sentence}>
{sentence}
</p>
</div> </div>
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2.5 py-3"> {/* 顶栏工具统一 h-8,避免通知/主题/用户高度不一致 */}
{NAV_ITEMS.map(({ path, icon: Icon, label, exact }) => { <div className="flex h-8 items-center gap-1.5">
const active = isActive(location.pathname, path, exact) <NodeSwitcher />
return ( <SystemNotice />
<button <button
key={path} type="button"
onClick={() => navigate({ to: path })} onClick={toggleTheme}
className={cn( title={theme === 'light' ? '深色模式' : '浅色模式'}
'group flex h-9 w-full items-center gap-2.5 rounded-md px-2.5 text-[13px] transition-[background,color,border-color] duration-150', className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--bg-primary)] text-[var(--text-secondary)] transition-colors hover:border-[var(--border-hover)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]"
active >
? 'border border-[var(--border-hover)] bg-[var(--bg-tertiary)] font-medium text-[var(--text-primary)]' {theme === 'light' ? <Moon size={15} strokeWidth={1.5} /> : <Sun size={15} strokeWidth={1.5} />}
: 'border border-transparent text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]', </button>
)}
>
<Icon
size={15}
strokeWidth={1.5}
className={cn(active ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)] group-hover:text-[var(--text-primary)]')}
/>
<span className="truncate">{label}</span>
{active ? <span className="ml-auto h-1.5 w-1.5 rounded-full bg-[var(--bg-accent)]" /> : null}
</button>
)
})}
</nav>
<div className="space-y-1 border-t border-[var(--border)] p-2.5"> <div className="relative" ref={userMenuRef}>
<Button variant="ghost" className="h-9 w-full justify-start gap-2.5 px-2.5 text-[13px]" onClick={logout}> <button
<LogOut size={15} strokeWidth={1.5} /> type="button"
退 onClick={() => setUserMenuOpen((v) => !v)}
</Button> className={cn(
</div> 'inline-flex h-8 items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--bg-primary)] px-2 text-[12px] leading-none text-[var(--text-secondary)] transition-colors hover:border-[var(--border-hover)] hover:text-[var(--text-primary)]',
</aside> userMenuOpen && 'border-[var(--border-hover)] text-[var(--text-primary)]',
)}
<main className="flex min-w-0 flex-1 flex-col"> title="账户"
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--bg-secondary)_92%,transparent)] px-4 backdrop-blur-md">
<div className="flex min-w-0 flex-1 items-center gap-2">
{isMobile && (
<button
className="rounded-md p-2 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]"
onClick={() => setMenuOpen(true)}
>
<Menu size={18} strokeWidth={1.5} />
</button>
)}
<p className="truncate text-[13px] text-[var(--text-muted)]" title={sentence}>
{sentence}
</p>
</div>
<div className="flex items-center gap-1.5">
<NodeSwitcher />
<SystemNotice />
<Button
variant="secondary"
size="icon"
onClick={toggleTheme}
title={theme === 'light' ? '深色模式' : '浅色模式'}
className="h-8 w-8"
> >
{theme === 'light' ? <Moon size={15} strokeWidth={1.5} /> : <Sun size={15} strokeWidth={1.5} />} <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[var(--bg-tertiary)] text-[var(--text-muted)]">
</Button> <UserRound size={12} strokeWidth={1.75} />
</div> </span>
</header> <span className="hidden max-w-[96px] truncate sm:inline">{username}</span>
</button>
<div className="ms-page flex-1 overflow-y-auto p-4 lg:p-6"> {userMenuOpen && (
<Outlet /> <div className="absolute right-0 top-[calc(100%+6px)] z-50 w-44 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--bg-secondary)] shadow-[0_12px_40px_rgba(0,0,0,0.18)]">
<div className="border-b border-[var(--border)] px-3 py-2.5">
<div className="truncate text-[12px] font-medium text-[var(--text-primary)]">{username}</div>
<div className="truncate text-[11px] text-[var(--text-muted)]"></div>
</div>
<button
type="button"
onClick={() => {
setUserMenuOpen(false)
void logout()
}}
className="flex h-10 w-full items-center gap-2 px-3 text-[13px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-hover)] hover:text-[var(--danger)]"
>
<LogOut size={14} strokeWidth={1.5} />
退
</button>
</div>
)}
</div>
</div> </div>
</main> </header>
<ToastHost />
<div className="ms-page flex-1 overflow-y-auto p-4 lg:p-6">
<Outlet />
</div>
</main>
<ToastHost />
</div> </div>
) )
} }
+117 -37
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState, useRef } from 'react'
import { MonitorDot } from 'lucide-react' import { MonitorDot, ChevronDown } from 'lucide-react'
import { createPortal } from 'react-dom'
import { import {
activeInterconnectNodeId, activeInterconnectNodeId,
api, api,
@@ -12,8 +13,12 @@ import { cn } from '@/lib/utils'
export function NodeSwitcher() { export function NodeSwitcher() {
const [nodes, setNodes] = useState<InterconnectNode[]>([]) const [nodes, setNodes] = useState<InterconnectNode[]>([])
const [selected, setSelected] = useState(activeInterconnectNodeId || 'local') const [selected, setSelected] = useState('local')
const [isMaster, setIsMaster] = useState(false) const [isMaster, setIsMaster] = useState(false)
const [open, setOpen] = useState(false)
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({})
const [mounted, setMounted] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
const fetchNodes = useCallback(async () => { const fetchNodes = useCallback(async () => {
try { try {
@@ -34,15 +39,23 @@ export function NodeSwitcher() {
} else { } else {
setNodes([]) setNodes([])
setSelected('local') setSelected('local')
const traveling = !!document.cookie.match(/(?:^| )active_interconnect_node_id=([^;]*)/) if (typeof document !== 'undefined') {
if (activeInterconnectNodeId && !traveling) { const traveling = !!document.cookie.match(/(?:^| )active_interconnect_node_id=([^;]*)/)
setActiveInterconnectNodeId('') if (activeInterconnectNodeId && !traveling) {
setActiveInterconnectNodeId('')
}
} }
} }
}, },
[fetchNodes], [fetchNodes],
) )
useEffect(() => {
setMounted(true)
// 在客户端初始化时从 localStorage 读取选中的节点
setSelected(activeInterconnectNodeId || 'local')
}, [])
useEffect(() => { useEffect(() => {
;(async () => { ;(async () => {
try { try {
@@ -71,40 +84,107 @@ export function NodeSwitcher() {
} }
}, [isMaster, fetchNodes]) }, [isMaster, fetchNodes])
if (!isMaster) return null useEffect(() => {
if (!open) return
const updateMenuPosition = () => {
const el = buttonRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const viewportH = window.innerHeight
const spaceBelow = viewportH - rect.bottom
const openUp = spaceBelow < 220 && rect.top > spaceBelow
setMenuStyle({
position: 'fixed',
left: rect.left,
width: rect.width, // 菜单宽度和按钮一致
zIndex: 80,
...(openUp
? { bottom: viewportH - rect.top + 6, top: 'auto' }
: { top: rect.bottom + 6, bottom: 'auto' }),
})
}
updateMenuPosition()
const onDoc = (e: MouseEvent) => {
const t = e.target as Node
if (buttonRef.current?.contains(t)) return
setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
const onReposition = () => updateMenuPosition()
document.addEventListener('mousedown', onDoc)
document.addEventListener('keydown', onKey)
window.addEventListener('resize', onReposition)
window.addEventListener('scroll', onReposition, true)
return () => {
document.removeEventListener('mousedown', onDoc)
document.removeEventListener('keydown', onKey)
window.removeEventListener('resize', onReposition)
window.removeEventListener('scroll', onReposition, true)
}
}, [open])
// SSR 时或非 master 节点时返回 null
if (!mounted || !isMaster) return null
const handleSelect = (val: string) => {
setSelected(val)
setOpen(false)
if (val === 'local') {
setActiveInterconnectNodeId('')
} else {
const name = nodes.find((n) => n.id === val)?.name || ''
setActiveInterconnectNodeId(val, name)
}
window.location.reload()
}
const allOptions = [
{ value: 'local', label: '本机节点' },
...nodes.map((n) => ({ value: n.id, label: n.name })),
]
const currentLabel = allOptions.find((o) => o.value === selected)?.label || '本机节点'
return ( return (
<div <div className="relative shrink-0">
className={cn( <button
'inline-flex h-8 max-w-[150px] items-center gap-1 rounded-md border px-2 text-[11px]', ref={buttonRef}
selected !== 'local' type="button"
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400' onClick={() => setOpen(!open)}
: 'border-[var(--border)] bg-[var(--bg-secondary)] text-[var(--text-secondary)]', className={cn(
)} 'ui-control ui-select-trigger h-[var(--ui-height-sm)] px-[var(--ui-px-sm)] text-left text-[length:var(--ui-font-sm)]',
> open && 'ui-control-open',
<MonitorDot size={12} className="shrink-0" /> selected !== 'local' && 'border-amber-500/30 bg-amber-500/10 text-amber-600 hover:bg-amber-500/15 dark:text-amber-400',
<select )}
className="max-w-[120px] truncate border-0 bg-transparent text-[11px] outline-none"
value={selected}
onChange={(e) => {
const val = e.target.value
setSelected(val)
if (val === 'local') {
setActiveInterconnectNodeId('')
} else {
const name = nodes.find((n) => n.id === val)?.name || ''
setActiveInterconnectNodeId(val, name)
}
window.location.reload()
}}
> >
<option value="local"></option> <MonitorDot size={12} className="shrink-0" />
{nodes.map((n) => ( <span className="min-w-0 flex-1 truncate">{currentLabel}</span>
<option key={n.id} value={n.id}> <ChevronDown
{n.name} size={12}
</option> strokeWidth={1.5}
))} className={cn('shrink-0 text-[var(--text-muted)] transition-transform', open && 'rotate-180')}
</select> />
</button>
{open &&
mounted &&
createPortal(
<div className="ui-select-menu" style={menuStyle}>
{allOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleSelect(opt.value)}
className={cn('ui-select-item', opt.value === selected && 'is-active')}
>
<span className="min-w-0 flex-1 truncate">{opt.label}</span>
</button>
))}
</div>,
document.body,
)}
</div> </div>
) )
} }
+5 -7
View File
@@ -3,7 +3,6 @@ import { Bell, Check } from 'lucide-react'
import { api, LOG_CATEGORY, LOG_STATUS, type AppLog } from '@/api' import { api, LOG_CATEGORY, LOG_STATUS, type AppLog } from '@/api'
import { LOG_EVENTS } from '@/constants' import { LOG_EVENTS } from '@/constants'
import { useEventBus } from '@/hooks/useEventBus' import { useEventBus } from '@/hooks/useEventBus'
import { Button } from '@/components/ui'
import { formatDateTime } from '@/lib/format' import { formatDateTime } from '@/lib/format'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -63,18 +62,17 @@ export function SystemNotice() {
return ( return (
<div className="relative"> <div className="relative">
<Button <button
variant="secondary" type="button"
size="icon" className="relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--bg-primary)] text-[var(--text-secondary)] transition-colors hover:border-[var(--border-hover)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]"
className="relative h-8 w-8"
title="系统通知" title="系统通知"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
> >
<Bell size={15} strokeWidth={1.5} /> <Bell size={15} strokeWidth={1.5} />
{unread > 0 ? ( {unread > 0 ? (
<span className="absolute right-1 top-1 h-2 w-2 animate-pulse rounded-full bg-red-500" /> <span className="absolute right-1.5 top-1.5 h-1.5 w-1.5 animate-pulse rounded-full bg-red-500" />
) : null} ) : null}
</Button> </button>
{open ? ( {open ? (
<> <>
+8 -1
View File
@@ -6,9 +6,11 @@ export type BadgeTone = 'default' | 'success' | 'warn' | 'danger' | 'info'
export function Badge({ export function Badge({
children, children,
tone = 'default', tone = 'default',
size = 'md',
}: { }: {
children: ReactNode children: ReactNode
tone?: BadgeTone tone?: BadgeTone
size?: 'sm' | 'md' | 'lg'
}) { }) {
const tones: Record<BadgeTone, string> = { const tones: Record<BadgeTone, string> = {
default: 'border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]', default: 'border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]',
@@ -21,8 +23,13 @@ export function Badge({
info: info:
'border-[color-mix(in_srgb,var(--info)_25%,transparent)] bg-[color-mix(in_srgb,var(--info)_12%,transparent)] text-[var(--info)]', 'border-[color-mix(in_srgb,var(--info)_25%,transparent)] bg-[color-mix(in_srgb,var(--info)_12%,transparent)] text-[var(--info)]',
} }
const badgeSizes = {
sm: 'px-1.5 py-px text-[10px]',
md: 'px-2 py-0.5 text-[11px]',
lg: 'px-2.5 py-1 text-[12px]',
}
return ( return (
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium', tones[tone])}> <span className={cn('inline-flex items-center rounded-md border font-medium', badgeSizes[size], tones[tone])}>
{children} {children}
</span> </span>
) )
+1 -1
View File
@@ -2,7 +2,7 @@ import type { ButtonHTMLAttributes } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export type ButtonVariant = 'default' | 'secondary' | 'ghost' | 'danger' | 'outline' export type ButtonVariant = 'default' | 'secondary' | 'ghost' | 'danger' | 'outline'
export type ButtonSize = 'sm' | 'md' | 'icon' export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant variant?: ButtonVariant
+1
View File
@@ -10,6 +10,7 @@ export { Badge } from './badge'
export type { BadgeTone } from './badge' export type { BadgeTone } from './badge'
export { Modal } from './modal' export { Modal } from './modal'
export { Table, Th, Td } from './table' export { Table, Th, Td } from './table'
export type { TableSize } from './table'
export { Field } from './field' export { Field } from './field'
export { PageHeader } from './page-header' export { PageHeader } from './page-header'
export { Empty } from './empty' export { Empty } from './empty'
+8 -2
View File
@@ -1,6 +1,12 @@
import type { InputHTMLAttributes } from 'react' import type { InputHTMLAttributes } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) { const heights: Record<string, string> = {
return <input {...props} className={cn('ui-control h-9 px-3', className)} /> sm: 'h-[var(--ui-height-sm)] text-[length:var(--ui-font-sm)] px-[var(--ui-px-sm)]',
md: 'h-[var(--ui-height-md)] text-[length:var(--ui-font-md)] px-[var(--ui-px-md)]',
lg: 'h-[var(--ui-height-lg)] text-[length:var(--ui-font-lg)] px-[var(--ui-px-lg)]',
}
export function Input({ className, size = 'md', ...props }: InputHTMLAttributes<HTMLInputElement> & { size?: 'sm' | 'md' | 'lg' }) {
return <input {...props} className={cn('ui-control', heights[size], className)} />
} }
+1 -1
View File
@@ -144,7 +144,7 @@ export function Select({
disabled={disabled} disabled={disabled}
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={open} aria-expanded={open}
className={cn('ui-control ui-select-trigger h-9 px-3 text-left', open && 'ui-control-open')} className={cn('ui-control ui-select-trigger h-[var(--ui-height-md)] px-[var(--ui-px-md)] text-left text-[length:var(--ui-font-md)]', open && 'ui-control-open')}
onClick={() => { onClick={() => {
if (disabled) return if (disabled) return
setOpen((v) => !v) setOpen((v) => !v)
+14 -3
View File
@@ -1,16 +1,25 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const sizes = {
sm: { track: 'h-4 w-7', thumb: 'h-2.5 w-2.5', on: 'translate-x-[14px]', off: 'translate-x-[2px]' },
md: { track: 'h-5 w-9', thumb: 'h-3.5 w-3.5', on: 'translate-x-[18px]', off: 'translate-x-[3px]' },
lg: { track: 'h-6 w-11', thumb: 'h-4 w-4', on: 'translate-x-[22px]', off: 'translate-x-[3px]' },
}
export function Switch({ export function Switch({
checked, checked,
onCheckedChange, onCheckedChange,
disabled, disabled,
className, className,
size = 'md',
}: { }: {
checked: boolean checked: boolean
onCheckedChange: (v: boolean) => void onCheckedChange: (v: boolean) => void
disabled?: boolean disabled?: boolean
className?: string className?: string
size?: 'sm' | 'md' | 'lg'
}) { }) {
const s = sizes[size]
return ( return (
<button <button
type="button" type="button"
@@ -18,7 +27,8 @@ export function Switch({
aria-checked={checked} aria-checked={checked}
disabled={disabled} disabled={disabled}
className={cn( className={cn(
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition', 'relative inline-flex shrink-0 items-center rounded-full border transition',
s.track,
checked ? 'border-transparent bg-[var(--bg-accent)]' : 'border-[var(--border)] bg-[var(--bg-tertiary)]', checked ? 'border-transparent bg-[var(--bg-accent)]' : 'border-[var(--border)] bg-[var(--bg-tertiary)]',
disabled && 'opacity-55 cursor-not-allowed', disabled && 'opacity-55 cursor-not-allowed',
className, className,
@@ -27,8 +37,9 @@ export function Switch({
> >
<span <span
className={cn( className={cn(
'inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform', 'inline-block rounded-full bg-white shadow transition-transform',
checked ? 'translate-x-[18px]' : 'translate-x-[3px]', s.thumb,
checked ? s.on : s.off,
)} )}
/> />
</button> </button>
+19 -4
View File
@@ -1,6 +1,20 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export type TableSize = 'sm' | 'md' | 'lg'
const thSizes: Record<TableSize, string> = {
sm: 'px-2 py-[var(--ui-table-py-sm)] text-[10px]',
md: 'px-3 py-[var(--ui-table-py-md)] text-[11px]',
lg: 'px-4 py-[var(--ui-table-py-lg)] text-[12px]',
}
const tdSizes: Record<TableSize, string> = {
sm: 'px-2 py-[var(--ui-table-py-sm)] text-[length:var(--ui-font-sm)]',
md: 'px-3 py-[var(--ui-table-py-md)] text-[length:var(--ui-font-md)]',
lg: 'px-4 py-[var(--ui-table-py-lg)] text-[length:var(--ui-font-lg)]',
}
export function Table({ children }: { children: ReactNode }) { export function Table({ children }: { children: ReactNode }) {
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -9,11 +23,12 @@ export function Table({ children }: { children: ReactNode }) {
) )
} }
export function Th({ children, className }: { children?: ReactNode; className?: string }) { export function Th({ children, className, size = 'md' }: { children?: ReactNode; className?: string; size?: TableSize }) {
return ( return (
<th <th
className={cn( className={cn(
'border-b border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--text-muted)]', 'border-b border-[var(--border)] bg-[var(--bg-secondary)] font-medium uppercase tracking-wide text-[var(--text-muted)]',
thSizes[size],
className, className,
)} )}
> >
@@ -22,9 +37,9 @@ export function Th({ children, className }: { children?: ReactNode; className?:
) )
} }
export function Td({ children, className }: { children?: ReactNode; className?: string }) { export function Td({ children, className, size = 'md' }: { children?: ReactNode; className?: string; size?: TableSize }) {
return ( return (
<td className={cn('border-b border-[var(--border)] px-3 py-2.5 align-middle text-[var(--text-primary)]', className)}> <td className={cn('border-b border-[var(--border)] align-middle text-[var(--text-primary)]', tdSizes[size], className)}>
{children} {children}
</td> </td>
) )
+9 -1
View File
@@ -5,12 +5,19 @@ export function Tabs({
onValueChange, onValueChange,
items, items,
className, className,
size = 'md',
}: { }: {
value: string value: string
onValueChange: (v: string) => void onValueChange: (v: string) => void
items: { value: string; label: string }[] items: { value: string; label: string }[]
className?: string className?: string
size?: 'sm' | 'md' | 'lg'
}) { }) {
const tabSizes = {
sm: 'px-2.5 py-1 text-[12px]',
md: 'px-3.5 py-1.5 text-[13px]',
lg: 'px-4 py-2 text-[14px]',
}
return ( return (
<div <div
className={cn( className={cn(
@@ -29,7 +36,8 @@ export function Tabs({
role="tab" role="tab"
aria-selected={active} aria-selected={active}
className={cn( className={cn(
'relative shrink-0 rounded-md px-3.5 py-1.5 text-[13px] font-medium whitespace-nowrap transition-colors', 'relative shrink-0 rounded-md font-medium whitespace-nowrap transition-colors',
tabSizes[size],
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)]', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)]',
active active
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-[var(--shadow-sm)] ring-1 ring-[var(--border)]' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-[var(--shadow-sm)] ring-1 ring-[var(--border)]'
+8 -2
View File
@@ -1,6 +1,12 @@
import type { TextareaHTMLAttributes } from 'react' import type { TextareaHTMLAttributes } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) { const sizes: Record<string, string> = {
return <textarea {...props} className={cn('ui-control min-h-24 resize-y px-3 py-2', className)} /> sm: 'text-[length:var(--ui-font-sm)] px-[var(--ui-px-sm)] py-1.5 min-h-16',
md: 'text-[length:var(--ui-font-md)] px-[var(--ui-px-md)] py-2 min-h-24',
lg: 'text-[length:var(--ui-font-lg)] px-[var(--ui-px-lg)] py-2.5 min-h-32',
}
export function Textarea({ className, size = 'md', ...props }: TextareaHTMLAttributes<HTMLTextAreaElement> & { size?: 'sm' | 'md' | 'lg' }) {
return <textarea {...props} className={cn('ui-control resize-y', sizes[size], className)} />
} }
+81 -19
View File
@@ -78,6 +78,44 @@
--info: #2563eb; --info: #2563eb;
} }
/* ---- 尺寸系统:默认 md ---- */
:root {
--ui-height-sm: 1.75rem; /* 28px */
--ui-height-md: 2.25rem; /* 36px */
--ui-height-lg: 2.75rem; /* 44px */
--ui-font-sm: 12px;
--ui-font-md: 14px;
--ui-font-lg: 15px;
--ui-px-sm: 0.5rem;
--ui-px-md: 0.75rem;
--ui-px-lg: 1rem;
--ui-gap-sm: 0.25rem;
--ui-gap-md: 0.375rem;
--ui-gap-lg: 0.5rem;
--ui-table-py-sm: 0.375rem;
--ui-table-py-md: 0.625rem;
--ui-table-py-lg: 0.75rem;
}
/* 紧凑模式 */
[data-density='compact'] {
--ui-height-sm: 1.5rem; /* 24px */
--ui-height-md: 1.875rem; /* 30px */
--ui-height-lg: 2.25rem; /* 36px */
--ui-font-sm: 11px;
--ui-font-md: 13px;
--ui-font-lg: 14px;
--ui-px-sm: 0.375rem;
--ui-px-md: 0.5rem;
--ui-px-lg: 0.75rem;
--ui-gap-sm: 0.125rem;
--ui-gap-md: 0.25rem;
--ui-gap-lg: 0.375rem;
--ui-table-py-sm: 0.25rem;
--ui-table-py-md: 0.375rem;
--ui-table-py-lg: 0.5rem;
}
*, *,
*::before, *::before,
*::after { *::after {
@@ -215,8 +253,8 @@ pre,
.ui-control:focus, .ui-control:focus,
.ui-control:focus-visible { .ui-control:focus-visible {
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-color: var(--bg-accent); border-color: var(--border-hover);
box-shadow: 0 0 0 3px var(--focus-ring); outline: none;
} }
.ui-control:disabled { .ui-control:disabled {
@@ -225,7 +263,7 @@ pre,
} }
.ui-control-open { .ui-control-open {
border-color: var(--bg-accent) !important; border-color: var(--border-hover) !important;
background: var(--bg-tertiary); background: var(--bg-tertiary);
} }
@@ -234,10 +272,10 @@ pre,
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.375rem; gap: var(--ui-gap-md);
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 14px; font-size: var(--ui-font-md);
font-weight: 500; font-weight: 500;
line-height: 1.2; line-height: 1.2;
transition: transition:
@@ -310,19 +348,38 @@ pre,
} }
.ui-btn-sm { .ui-btn-sm {
height: 2rem; height: var(--ui-height-sm);
padding: 0 0.625rem; padding: 0 var(--ui-px-sm);
font-size: 12px; font-size: var(--ui-font-sm);
} }
.ui-btn-md { .ui-btn-md {
height: 2.25rem; height: var(--ui-height-md);
padding: 0 0.75rem; padding: 0 var(--ui-px-md);
font-size: var(--ui-font-md);
}
.ui-btn-lg {
height: var(--ui-height-lg);
padding: 0 var(--ui-px-lg);
font-size: var(--ui-font-lg);
} }
.ui-btn-icon { .ui-btn-icon {
height: 2.25rem; height: var(--ui-height-md);
width: 2.25rem; width: var(--ui-height-md);
padding: 0;
}
.ui-btn-icon-sm {
height: var(--ui-height-sm);
width: var(--ui-height-sm);
padding: 0;
}
.ui-btn-icon-lg {
height: var(--ui-height-lg);
width: var(--ui-height-lg);
padding: 0; padding: 0;
} }
@@ -337,14 +394,14 @@ pre,
.ui-select-menu { .ui-select-menu {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 4px;
max-height: 280px; max-height: 280px;
overflow: auto; overflow: auto;
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--bg-secondary); background: var(--bg-secondary);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
padding: 8px; padding: 6px;
} }
.ui-select-item { .ui-select-item {
@@ -357,10 +414,10 @@ pre,
background: transparent; background: transparent;
color: var(--text-primary); color: var(--text-primary);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: var(--ui-font-sm);
line-height: 1.35; line-height: 1.35;
min-height: 2rem; min-height: calc(var(--ui-height-sm) - 4px);
padding: 0.5rem 0.75rem; padding: 0.375rem 0.5rem;
text-align: left; text-align: left;
} }
@@ -368,13 +425,18 @@ pre,
margin-top: 0; margin-top: 0;
} }
.ui-select-item:hover:not(:disabled) { .ui-select-item:hover:not(:disabled):not(.is-active) {
background: var(--bg-tertiary); background: var(--bg-hover);
} }
.ui-select-item.is-active { .ui-select-item.is-active {
background: var(--bg-selected); background: var(--bg-selected);
color: var(--text-primary); color: var(--text-primary);
font-weight: 500;
}
.ui-select-item.is-active:hover {
background: var(--bg-selected);
} }
.ui-select-item:disabled { .ui-select-item:disabled {
+3
View File
@@ -21,6 +21,9 @@ class EventBusDriver {
private initialized = false private initialized = false
constructor() { constructor() {
// 延迟初始化,避免 SSR 时访问 window
if (typeof window === 'undefined') return
const baseUrl = (window as any).__BASE_URL__ || '' const baseUrl = (window as any).__BASE_URL__ || ''
const apiVersion = (window as any).__API_VERSION__ || '/api/v1' const apiVersion = (window as any).__API_VERSION__ || '/api/v1'
let host = window.location.host let host = window.location.host
+100 -15
View File
@@ -267,11 +267,23 @@ export default function Settings() {
</Card> </Card>
<Card className="space-y-3 p-4"> <Card className="space-y-3 p-4">
<h2 className="font-medium"></h2> <h2 className="font-medium"></h2>
<Input placeholder="标题" value={siteForm.title} onChange={(e) => setSiteForm((f) => ({ ...f, title: e.target.value }))} /> <div>
<Input placeholder="副标题" value={siteForm.subtitle} onChange={(e) => setSiteForm((f) => ({ ...f, subtitle: e.target.value }))} /> <label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="任务池" value={siteForm.title} onChange={(e) => setSiteForm((f) => ({ ...f, title: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="轻量级任务调度平台" value={siteForm.subtitle} onChange={(e) => setSiteForm((f) => ({ ...f, subtitle: e.target.value }))} />
</div>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<Input placeholder="分页大小" value={siteForm.page_size} onChange={(e) => setSiteForm((f) => ({ ...f, page_size: e.target.value }))} /> <div>
<Input placeholder="Cookie 天数" value={siteForm.cookie_days} onChange={(e) => setSiteForm((f) => ({ ...f, cookie_days: e.target.value }))} /> <label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="20" value={siteForm.page_size} onChange={(e) => setSiteForm((f) => ({ ...f, page_size: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"> Cookie </label>
<Input placeholder="7" value={siteForm.cookie_days} onChange={(e) => setSiteForm((f) => ({ ...f, cookie_days: e.target.value }))} />
</div>
</div> </div>
<label className="flex items-center justify-between text-sm"> <label className="flex items-center justify-between text-sm">
<span> OpenAPI</span> <span> OpenAPI</span>
@@ -283,16 +295,76 @@ export default function Settings() {
Token Token
</Button> </Button>
</div> </div>
<div className="rounded-md border border-[var(--border)] bg-[var(--bg-secondary)] p-3 text-xs leading-5 text-[var(--text-secondary)]">
<div className="mb-2 font-medium text-[var(--text-primary)]">MCP ServerAI Agent </div>
<p className="mb-2">
MCP Server OpenAPI 使AI Agent MCP //
</p>
<div className="mb-3 space-y-2">
<div className="rounded border border-[var(--border)] bg-[var(--bg-tertiary)]/50 p-2">
<div className="mb-1 text-xs font-medium text-[var(--text-primary)]">HTTP </div>
<p className="mb-2 text-[var(--text-muted)]"> Agent 使访Hermes / OpenClaw</p>
<pre className="overflow-x-auto whitespace-pre-wrap rounded border border-[var(--border)] bg-[var(--bg-primary)] p-2 font-mono text-[11px] text-[var(--text-primary)]">{`{
"mcpServers": {
"taskpool": {
"url": "${window.location.origin}/mcp",
"headers": {
"Authorization": "Bearer ${siteForm.openapi_token || '你的Token'}"
}
}
}
}`}</pre>
</div>
<div className="rounded border border-[var(--border)] bg-[var(--bg-tertiary)]/50 p-2">
<div className="mb-1 text-xs font-medium text-[var(--text-primary)]">stdio </div>
<p className="mb-2 text-[var(--text-muted)]"> Agent 使 taskpool Claude Desktop / Cursor</p>
<pre className="overflow-x-auto whitespace-pre-wrap rounded border border-[var(--border)] bg-[var(--bg-primary)] p-2 font-mono text-[11px] text-[var(--text-primary)]">{`{
"mcpServers": {
"taskpool": {
"command": "taskpool",
"args": ["mcp"]
}
}
}`}</pre>
</div>
</div>
<p className="text-[var(--text-muted)]"> docs/guide/mcp.md skills/taskpool/SKILL.md</p>
</div>
<h3 className="pt-2 text-sm font-medium text-[var(--text-secondary)]"></h3> <h3 className="pt-2 text-sm font-medium text-[var(--text-secondary)]"></h3>
<p className="text-xs text-[var(--text-muted)]"></p>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<Input placeholder="系统通知保留天" value={siteForm.system_notice_days} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_days: e.target.value }))} /> <div>
<Input placeholder="系统通知最大条数" value={siteForm.system_notice_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_max_count: e.target.value }))} /> <label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="推送日志保留天" value={siteForm.push_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_days: e.target.value }))} /> <Input placeholder="7" value={siteForm.system_notice_days} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_days: e.target.value }))} />
<Input placeholder="推送日志最大条数" value={siteForm.push_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_max_count: e.target.value }))} /> </div>
<Input placeholder="登录日志保留天" value={siteForm.login_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_days: e.target.value }))} /> <div>
<Input placeholder="登录日志最大条数" value={siteForm.login_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_max_count: e.target.value }))} /> <label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="调度日志保留天" value={siteForm.scheduler_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_days: e.target.value }))} /> <Input placeholder="1000" value={siteForm.system_notice_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_max_count: e.target.value }))} />
<Input placeholder="调度日志最大条数" value={siteForm.scheduler_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_max_count: e.target.value }))} /> </div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="7" value={siteForm.push_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_days: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="1000" value={siteForm.push_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_max_count: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="30" value={siteForm.login_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_days: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="1000" value={siteForm.login_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_max_count: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="7" value={siteForm.scheduler_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_days: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="1000" value={siteForm.scheduler_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_max_count: e.target.value }))} />
</div>
</div> </div>
<Button onClick={() => saveSite.mutate()} disabled={saveSite.isPending}> <Button onClick={() => saveSite.mutate()} disabled={saveSite.isPending}>
@@ -388,10 +460,23 @@ export default function Settings() {
{tab === 'scheduler' ? ( {tab === 'scheduler' ? (
<Card className="space-y-3 p-4"> <Card className="space-y-3 p-4">
<h2 className="font-medium"></h2> <h2 className="font-medium"></h2>
<p className="text-xs text-[var(--text-muted)]"></p>
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">
<Input placeholder="Worker 数" value={schedulerForm.worker_count} onChange={(e) => setSchedulerForm((f) => ({ ...f, worker_count: e.target.value }))} /> <div>
<Input placeholder="队列大小" value={schedulerForm.queue_size} onChange={(e) => setSchedulerForm((f) => ({ ...f, queue_size: e.target.value }))} /> <label className="mb-1 block text-xs text-[var(--text-muted)]">Worker </label>
<Input placeholder="限速间隔" value={schedulerForm.rate_interval} onChange={(e) => setSchedulerForm((f) => ({ ...f, rate_interval: e.target.value }))} /> <Input placeholder="4" value={schedulerForm.worker_count} onChange={(e) => setSchedulerForm((f) => ({ ...f, worker_count: e.target.value }))} />
<p className="mt-1 text-xs text-[var(--text-muted)]"></p>
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="100" value={schedulerForm.queue_size} onChange={(e) => setSchedulerForm((f) => ({ ...f, queue_size: e.target.value }))} />
<p className="mt-1 text-xs text-[var(--text-muted)]"></p>
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="0" value={schedulerForm.rate_interval} onChange={(e) => setSchedulerForm((f) => ({ ...f, rate_interval: e.target.value }))} />
<p className="mt-1 text-xs text-[var(--text-muted)]">0 </p>
</div>
</div> </div>
<Button onClick={() => saveScheduler.mutate()} disabled={saveScheduler.isPending}> <Button onClick={() => saveScheduler.mutate()} disabled={saveScheduler.isPending}>