Compare commits
53 Commits
3fa0674cbb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| afd37f961a | |||
| a45cdda3aa | |||
| e425015cb1 | |||
| eb85a8536d | |||
| e4b9602d0e | |||
| 2902919ae5 | |||
| 8ef0ff8507 | |||
| 36d39e4a47 | |||
| 901de3e340 | |||
| 455f46caa0 | |||
| 8be7e8ad2f | |||
| e2063dbeb8 | |||
| ce9d1b4f1c | |||
| 7430457f35 | |||
| 2cda95ddbd | |||
| 5db4364615 | |||
| ea5f712f8c | |||
| bf7e87b582 | |||
| 4d38d5df3c | |||
| 870ee1ce85 | |||
| 4c10a0032f | |||
| 42cb37b5e3 | |||
| 2bf410c825 | |||
| f3c89d35a7 | |||
| 9589572b4c | |||
| ec38778951 | |||
| 727c1dd8cb | |||
| f68c092520 | |||
| 2cc584713f | |||
| 060a8f132d | |||
| 19b3fb60f7 | |||
| 2c9b44aeca | |||
| bcf7cbbeea | |||
| f3a44a901e | |||
| 066383de83 | |||
| f004cfc937 | |||
| 334152417b | |||
| 6709c4ed77 | |||
| d104442e6b | |||
| a9024ff6f8 | |||
| 29319ee2d3 | |||
| a0a90f558c | |||
| 7f54256ac0 | |||
| eb8df6de0f | |||
| 88ca62b6c0 | |||
| 90e0552f7b | |||
| 7043c166ec | |||
| 5fe8639342 | |||
| 472b956203 | |||
| afe7833539 | |||
| c06a8a7f7d | |||
| 3b77837323 | |||
| 24e809557e |
+62
-24
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
- API:http://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 Compose(SQLite):**
|
**Docker Compose(SQLite):**
|
||||||
|
|
||||||
```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 Compose(MySQL):**
|
|
||||||
|
|
||||||
```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)仅作为一个轻量级的任务托管与调度平台,本项目及相关代码**不提供、不内置任何具有实际业务逻辑的第三方脚本**。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 Server(stdio 模式)
|
||||||
|
// 环境变量(可选,默认使用本地服务器配置):
|
||||||
|
// 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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# MCP Server(Hermes / 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 有效 |
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: "一键补全指定任务执行日志中的缺失依赖包",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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] + "..."
|
||||||
|
}
|
||||||
@@ -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. 不要编造任务 ID;ID 以 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 MCP:1) 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 任务 %s:1) 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Generated
+235
-15
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)]'
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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 Server(AI 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}>
|
||||||
保存调度设置
|
保存调度设置
|
||||||
|
|||||||
Reference in New Issue
Block a user