feat: Initial commit

This commit is contained in:
engigu
2025-12-20 09:30:16 +08:00
commit 362237241e
189 changed files with 12035 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
name: Build and Push Docker Image
on:
# push:
# branches: [ main ]
# tags: [ 'v*' ]
# pull_request:
# branches: [ main ]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: baihu
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
- name: Get build time
id: build_time
run: echo "time=$(date '+%Y/%m/%d %H:%M:%S')" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
BUILD_TIME=${{ steps.build_time.outputs.time }}
cache-from: type=gha
cache-to: type=gha,mode=max
+36
View File
@@ -0,0 +1,36 @@
# Binaries
baihu
*.exe
*.dll
*.so
*.dylib
# Go
# go.sum should be committed for reproducible builds
# Data & Logs
data/
# logs/
# scripts/
# IDE
.idea/
.vscode/
*.swp
*.swo
.DS_Store
# Node
node_modules/
web/dist/
# Embedded static files (built during CI/Docker)
internal/static/dist/
!internal/static/dist/.gitkeep
# Config (sensitive)
configs/config.local.json
# Misc
*.log
*.tmp
+74
View File
@@ -0,0 +1,74 @@
# Stage 1: Build frontend
FROM node:20-alpine AS frontend-builder
WORKDIR /app/web
# Copy package files
COPY web/package*.json ./
# Install dependencies
RUN npm ci
# Copy frontend source
COPY web/ ./
# Build frontend
RUN npm run build
# Stage 2: Build backend
FROM golang:1.24-alpine AS backend-builder
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
RUN go env -w GOPROXY=https://goproxy.cn,direct && go mod download
# Copy source code
COPY . .
# Copy built frontend to embed location
COPY --from=frontend-builder /app/web/dist ./internal/static/dist
# Build Go binary (no CGO needed, auto-detect target arch)
ARG VERSION=dev
ARG BUILD_TIME
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X baihu/internal/constant.Version=${VERSION} -X 'baihu/internal/constant.BuildTime=${BUILD_TIME}'" -o baihu .
# Stage 3: Final image based on Dockerfile.debian
FROM debian:bookworm-slim
ENV TZ=Asia/Shanghai
ENV CONDA_DIR=/opt/miniforge3
ENV PATH=${CONDA_DIR}/bin:$PATH
RUN sed -i 's@deb.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \
&& sed -i 's@security.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \
&& echo "${TZ}" > /etc/timezone \
&& ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& apt update \
&& apt install -y tzdata git gcc curl wget vim ca-certificates htop \
&& rm -rf /var/lib/apt/lists/* \
&& curl -sSL https://gh-proxy.com/https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh -o /tmp/miniforge.sh \
&& bash /tmp/miniforge.sh -b -p ${CONDA_DIR} \
&& rm -f /tmp/miniforge.sh \
&& "${CONDA_DIR}"/bin/conda config --set show_channel_urls yes \
&& "${CONDA_DIR}"/bin/conda config --set channel_priority strict \
&& "${CONDA_DIR}"/bin/conda clean -afy
WORKDIR /app
# Copy binary from builder
COPY --from=backend-builder /app/baihu .
# Copy config files
COPY --from=backend-builder /app/configs ./configs
# Create directories
RUN mkdir -p ./data ./logs ./scripts
# Expose port
EXPOSE 8052
# Run
CMD ["./baihu"]
+21
View File
@@ -0,0 +1,21 @@
### debian
FROM debian:bookworm-slim
ENV TZ=Asia/Shanghai
ENV CONDA_DIR=/opt/miniforge3
ENV PATH=${CONDA_DIR}/bin:$PATH
RUN sed -i 's@deb.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \
&& sed -i 's@security.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \
&& echo "${TZ}" > /etc/timezone \
&& ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& apt update \
&& apt install -y tzdata git gcc curl wget vim \
&& rm -rf /var/lib/apt/lists/* \
&& curl -sSL https://gh-proxy.com/https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh -o /tmp/miniforge.sh \
&& bash /tmp/miniforge.sh -b -p ${CONDA_DIR} \
&& rm -f /tmp/miniforge.sh \
&& "${CONDA_DIR}"/bin/conda config --set show_channel_urls yes \
&& "${CONDA_DIR}"/bin/conda config --set channel_priority strict \
&& "${CONDA_DIR}"/bin/conda clean -afy
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 engigu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+77
View File
@@ -0,0 +1,77 @@
# Variables
BINARY=baihu
GOBUILD=go build
GOCLEAN=go clean
GOGET=go get
GOMOD=go mod
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date '+%Y/%m/%d %H:%M:%S')
LDFLAGS=-ldflags="-s -w -X 'baihu/internal/constant.Version=$(VERSION)' -X 'baihu/internal/constant.BuildTime=$(BUILD_TIME)'"
# Default target
all: build
# Build frontend
build-web:
cd web && npm ci && npm run build
rm -rf internal/static/dist
cp -r web/dist internal/static/dist
# Build the application (requires frontend to be built first)
build:
CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) -o $(BINARY) main.go
# Build all (frontend + backend)
build-all: build-web build
# Clean built files
clean:
$(GOCLEAN)
rm -f $(BINARY)
rm -rf internal/static/dist
mkdir -p internal/static/dist
touch internal/static/dist/.gitkeep
# Run the application
run:
$(GOBUILD) -o $(BINARY) main.go
./$(BINARY)
# Development run (without embedding frontend)
dev:
$(GOBUILD) -o $(BINARY) main.go
./$(BINARY)
# Install dependencies
deps:
$(GOMOD) tidy
# Docker build
docker-build:
docker build -t $(BINARY) .
# Docker run
docker-run:
docker run -p 8052:8052 $(BINARY)
# Docker compose up
docker-up:
docker-compose up -d
# Docker compose down
docker-down:
docker-compose down
# Help
help:
@echo "Available targets:"
@echo " all - Build the application (default)"
@echo " build - Build the application"
@echo " clean - Clean built files"
@echo " run - Run the application"
@echo " deps - Install dependencies"
@echo " docker-build - Build Docker image"
@echo " docker-run - Run Docker container"
@echo " docker-up - Start Docker Compose stack"
@echo " docker-down - Stop Docker Compose stack"
@echo " help - Show this help message"
+138
View File
@@ -0,0 +1,138 @@
# QingLong Panel (Go Implementation)
This is a Go implementation of the popular QingLong Panel, a task management system for automated scripts.
## Features
- Task scheduling and management
- RESTful API
- User authentication
- Environment variable management
- Script file management
- Task execution and logging
- Web interface
- Docker support
## Project Structure
```
├── main.go # Application entry point
├── go.mod # Go modules definition
├── go.sum # Go modules checksums
├── Dockerfile # Docker configuration
├── docker-compose.yml # Docker Compose configuration
├── configs/ # Configuration files
├── internal/
│ ├── controllers/ # HTTP handlers
│ ├── models/ # Data structures
│ ├── services/ # Business logic
│ └── utils/ # Utility functions
├── web/
│ ├── static/ # Static assets (CSS, JS, images)
│ └── templates/ # HTML templates
├── data/ # Data storage
├── logs/ # Log files
└── scripts/ # User scripts
```
## Getting Started
### Using Go directly
1. Install Go (version 1.21 or higher)
2. Clone the repository
3. Run `go mod tidy` to install dependencies
4. Run `go run main.go` to start the server
### Using Docker
1. Install Docker and Docker Compose
2. Run `docker-compose up -d` to start the server
The server will start on port 8080.
## API Endpoints
### Authentication
- `POST /api/auth/login` - User login
- `POST /api/auth/register` - User registration
### Tasks
- `GET /api/tasks` - Get all tasks
- `POST /api/tasks` - Create a new task
- `GET /api/tasks/:id` - Get a specific task
- `PUT /api/tasks/:id` - Update a specific task
- `DELETE /api/tasks/:id` - Delete a specific task
### Task Execution
- `POST /api/execute/task/:id` - Execute a task by ID
- `POST /api/execute/command` - Execute a command directly
- `GET /api/execute/results` - Get last execution results
### Environment Variables
- `GET /api/env` - Get all environment variables
- `POST /api/env` - Create a new environment variable
- `GET /api/env/:id` - Get a specific environment variable
- `PUT /api/env/:id` - Update a specific environment variable
- `DELETE /api/env/:id` - Delete a specific environment variable
### Scripts
- `GET /api/scripts` - Get all scripts
- `POST /api/scripts` - Create a new script
- `GET /api/scripts/:id` - Get a specific script
- `PUT /api/scripts/:id` - Update a specific script
- `DELETE /api/scripts/:id` - Delete a specific script
## Web Interface
- `/` - Dashboard
- `/login` - Login page
- `/tasks` - Task management
- `/scripts` - Script management
- `/environments` - Environment variable management
## Default Admin User
Username: `admin`
Password: `admin123`
## Configuration
The application can be configured using the `configs/config.json` file:
```json
{
"server": {
"port": 8080,
"host": "0.0.0.0"
},
"database": {
"type": "sqlite",
"path": "./data/ql.db"
},
"security": {
"jwt_secret": "ql_panel_secret_key",
"password_salt": "ql_panel_salt"
},
"task": {
"default_timeout": 3600,
"log_retention_days": 30
}
}
```
## Docker Support
The application includes Docker support for easy deployment:
1. Build the Docker image: `docker build -t baihu .`
2. Run the container: `docker run -p 8080:8080 baihu`
Or use Docker Compose:
```bash
docker-compose up -d
```
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
+24
View File
@@ -0,0 +1,24 @@
{
"server": {
"port": 8052,
"host": "0.0.0.0"
},
"database": {
"type": "sqlite",
"host": "localhost",
"port": 3306,
"user": "root",
"password": "",
"dbname": "ql_panel",
"path": "./data/ql.db",
"table_prefix": "baihu_"
},
"security": {
"jwt_secret": "ql_panel_secret_key",
"password_salt": "ql_panel_salt"
},
"task": {
"default_timeout": 3600,
"log_retention_days": 30
}
}
+15
View File
@@ -0,0 +1,15 @@
version: '3.8'
services:
baihu:
build: .
ports:
- "8052:8052"
volumes:
- ./data:/root/data
- ./logs:/root/logs
- ./scripts:/root/scripts
- ./configs:/root/configs
environment:
- TZ=Asia/Shanghai
restart: unless-stopped
+64
View File
@@ -0,0 +1,64 @@
module baihu
go 1.24.0
toolchain go1.24.7
require (
github.com/creack/pty v1.1.24
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/gorilla/websocket v1.5.3
github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.9.3
golang.org/x/text v0.32.0
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+151
View File
@@ -0,0 +1,151 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+76
View File
@@ -0,0 +1,76 @@
package bootstrap
import (
"fmt"
"os"
"baihu/internal/constant"
"baihu/internal/database"
"baihu/internal/logger"
"baihu/internal/router"
"baihu/internal/services"
"github.com/gin-gonic/gin"
)
type App struct {
Config *services.AppConfig
Router *gin.Engine
}
func New() *App {
app := &App{}
app.initConfig()
app.initDatabase()
app.initRouter()
return app
}
func (a *App) initConfig() {
cfg, err := services.LoadConfig(constant.ConfigPath)
if err != nil {
logger.Fatalf("Failed to load config: %v", err)
}
a.Config = cfg
// Ensure directories exist
err = os.MkdirAll(constant.DataDir, 0755)
if err != nil {
return
}
err = os.MkdirAll(constant.ScriptsWorkDir, 0755)
if err != nil {
return
}
}
func (a *App) initDatabase() {
dbCfg := &database.Config{
Type: a.Config.Database.Type,
Host: a.Config.Database.Host,
Port: a.Config.Database.Port,
User: a.Config.Database.User,
Password: a.Config.Database.Password,
DBName: a.Config.Database.DBName,
Path: a.Config.Database.Path,
}
if err := database.Init(dbCfg); err != nil {
logger.Fatalf("Failed to init database: %v", err)
}
if err := database.Migrate(); err != nil {
logger.Fatalf("Failed to migrate database: %v", err)
}
}
func (a *App) initRouter() {
ctrls := router.RegisterControllers()
a.Router = router.Setup(ctrls)
}
func (a *App) Run() {
addr := fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port)
logger.Infof("Starting server on %s", addr)
a.Router.Run(addr)
}
+48
View File
@@ -0,0 +1,48 @@
package constant
const (
// ConfigPath 配置文件路径
ConfigPath = "configs/config.json"
// DataDir 数据目录
DataDir = "./data"
// WebDistDir 前端构建目录
WebDistDir = "./web/dist"
// DefaultRole 默认用户角色
DefaultRole = "user"
// AdminRole 管理员角色
AdminRole = "admin"
// DefaultTablePrefix 默认表前缀
DefaultTablePrefix = "baihu_"
// ScriptsWorkDir 脚本工作目录
ScriptsWorkDir = "./data/scripts"
// DefaultPageSize 默认分页大小
DefaultPageSize = 10
// CookieName Cookie 名称
CookieName = "BHToken"
// TokenExpireDays Token 过期天数
TokenExpireDays = 7
// CookieMaxAge Cookie 有效期(秒)7天
CookieMaxAge = 86400 * TokenExpireDays
// DefaultJWTSecret 默认 JWT 密钥
DefaultJWTSecret = "baihu-default-secret-key"
// DefaultTaskTimeout 默认任务超时时间(分钟)
DefaultTaskTimeout = 30
)
// TablePrefix 表前缀,可在运行时设置
var TablePrefix = DefaultTablePrefix
// JWTSecret JWT 密钥,可通过配置文件设置
var JWTSecret = DefaultJWTSecret
+12
View File
@@ -0,0 +1,12 @@
package constant
import "time"
// 构建时注入的变量
var (
Version = "dev"
BuildTime = "unknown"
)
// 程序启动时间
var StartTime = time.Now()
+81
View File
@@ -0,0 +1,81 @@
package controllers
import (
"baihu/internal/middleware"
"baihu/internal/services"
"baihu/internal/utils"
"github.com/gin-gonic/gin"
)
type AuthController struct {
userService *services.UserService
}
func NewAuthController(userService *services.UserService) *AuthController {
return &AuthController{userService: userService}
}
func (ac *AuthController) Login(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
user := ac.userService.GetUserByUsername(req.Username)
if user == nil || !ac.userService.ValidatePassword(user, req.Password) {
utils.Unauthorized(c, "用户名或密码错误")
return
}
// 生成 token
token, err := utils.GenerateToken(user.ID, user.Username)
if err != nil {
utils.ServerError(c, "登录失败")
return
}
// 设置 Cookie
middleware.SetAuthCookie(c, token)
utils.Success(c, gin.H{
"user": user.Username,
})
}
func (ac *AuthController) Logout(c *gin.Context) {
middleware.ClearAuthCookie(c)
utils.SuccessMsg(c, "退出成功")
}
func (ac *AuthController) GetCurrentUser(c *gin.Context) {
username, exists := c.Get("username")
if !exists {
utils.Unauthorized(c, "未登录")
return
}
utils.Success(c, gin.H{
"username": username,
})
}
func (ac *AuthController) Register(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
user := ac.userService.CreateUser(req.Username, req.Email, req.Password, "user")
utils.Success(c, user)
}
@@ -0,0 +1,51 @@
package controllers
import (
"baihu/internal/database"
"baihu/internal/models"
"baihu/internal/services"
"baihu/internal/utils"
"github.com/gin-gonic/gin"
)
type DashboardController struct {
cronService *services.CronService
executorService *services.ExecutorService
}
func NewDashboardController(cronService *services.CronService, executorService *services.ExecutorService) *DashboardController {
return &DashboardController{
cronService: cronService,
executorService: executorService,
}
}
type StatsResponse struct {
Tasks int64 `json:"tasks"`
Scripts int64 `json:"scripts"`
Envs int64 `json:"envs"`
Logs int64 `json:"logs"`
Scheduled int `json:"scheduled"`
Running int `json:"running"`
}
func (dc *DashboardController) GetStats(c *gin.Context) {
var taskCount, scriptCount, envCount, logCount int64
database.DB.Model(&models.Task{}).Count(&taskCount)
database.DB.Model(&models.Script{}).Count(&scriptCount)
database.DB.Model(&models.EnvironmentVariable{}).Count(&envCount)
database.DB.Model(&models.TaskLog{}).Count(&logCount)
stats := StatsResponse{
Tasks: taskCount,
Scripts: scriptCount,
Envs: envCount,
Logs: logCount,
Scheduled: dc.cronService.GetScheduledCount(),
Running: dc.executorService.GetRunningCount(),
}
utils.Success(c, stats)
}
+103
View File
@@ -0,0 +1,103 @@
package controllers
import (
"strconv"
"baihu/internal/services"
"baihu/internal/utils"
"github.com/gin-gonic/gin"
)
type EnvController struct {
envService *services.EnvService
}
func NewEnvController(envService *services.EnvService) *EnvController {
return &EnvController{envService: envService}
}
func (ec *EnvController) CreateEnvVar(c *gin.Context) {
userID := 1
var req struct {
Name string `json:"name" binding:"required"`
Value string `json:"value" binding:"required"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
envVar := ec.envService.CreateEnvVar(req.Name, req.Value, req.Remark, userID)
utils.Success(c, envVar)
}
func (ec *EnvController) GetEnvVars(c *gin.Context) {
userID := 1
p := utils.ParsePagination(c)
name := c.DefaultQuery("name", "")
envVars, total := ec.envService.GetEnvVarsWithPagination(userID, name, p.Page, p.PageSize)
utils.PaginatedResponse(c, envVars, total, p)
}
func (ec *EnvController) GetEnvVar(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.BadRequest(c, "无效的环境变量ID")
return
}
envVar := ec.envService.GetEnvVarByID(id)
if envVar == nil {
utils.NotFound(c, "环境变量不存在")
return
}
utils.Success(c, envVar)
}
func (ec *EnvController) UpdateEnvVar(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.BadRequest(c, "无效的环境变量ID")
return
}
var req struct {
Name string `json:"name"`
Value string `json:"value"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
envVar := ec.envService.UpdateEnvVar(id, req.Name, req.Value, req.Remark)
if envVar == nil {
utils.NotFound(c, "环境变量不存在")
return
}
utils.Success(c, envVar)
}
func (ec *EnvController) DeleteEnvVar(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.BadRequest(c, "无效的环境变量ID")
return
}
success := ec.envService.DeleteEnvVar(id)
if !success {
utils.NotFound(c, "环境变量不存在")
return
}
utils.SuccessMsg(c, "删除成功")
}
@@ -0,0 +1,55 @@
package controllers
import (
"strconv"
"baihu/internal/services"
"baihu/internal/utils"
"github.com/gin-gonic/gin"
)
type ExecutorController struct {
executorService *services.ExecutorService
}
func NewExecutorController(executorService *services.ExecutorService) *ExecutorController {
return &ExecutorController{executorService: executorService}
}
func (ec *ExecutorController) ExecuteTask(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.BadRequest(c, "无效的任务ID")
return
}
result := ec.executorService.ExecuteTask(id)
utils.Success(c, result)
}
func (ec *ExecutorController) ExecuteCommand(c *gin.Context) {
var req struct {
Command string `json:"command" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
result := ec.executorService.ExecuteCommand(req.Command)
utils.Success(c, result)
}
func (ec *ExecutorController) GetLastResults(c *gin.Context) {
count := 10
if c.Query("count") != "" {
if parsedCount, err := strconv.Atoi(c.Query("count")); err == nil && parsedCount > 0 {
count = parsedCount
}
}
results := ec.executorService.GetLastResults(count)
utils.Success(c, results)
}
+344
View File
@@ -0,0 +1,344 @@
package controllers
import (
"io/fs"
"os"
"path/filepath"
"strings"
"baihu/internal/utils"
"github.com/gin-gonic/gin"
)
var (
extractZip = utils.ExtractZip
extractTar = utils.ExtractTar
extractTarGz = utils.ExtractTarGz
)
type FileController struct {
workDir string
}
func NewFileController(workDir string) *FileController {
os.MkdirAll(workDir, 0755)
absPath, err := filepath.Abs(workDir)
if err != nil {
absPath = workDir
}
return &FileController{workDir: absPath}
}
type FileNode struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Children []*FileNode `json:"children,omitempty"`
}
func (fc *FileController) GetFileTree(c *gin.Context) {
root := &FileNode{
Name: filepath.Base(fc.workDir),
Path: "",
IsDir: true,
Children: []*FileNode{},
}
err := filepath.WalkDir(fc.workDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if path == fc.workDir {
return nil
}
relPath, _ := filepath.Rel(fc.workDir, path)
parts := strings.Split(relPath, string(filepath.Separator))
current := root
for i, part := range parts {
found := false
for _, child := range current.Children {
if child.Name == part {
current = child
found = true
break
}
}
if !found {
isLast := i == len(parts)-1
isDir := !isLast || d.IsDir()
node := &FileNode{
Name: part,
Path: strings.Join(parts[:i+1], "/"),
IsDir: isDir,
}
if isDir {
node.Children = []*FileNode{}
}
current.Children = append(current.Children, node)
current = node
}
}
return nil
})
if err != nil {
utils.ServerError(c, err.Error())
return
}
utils.Success(c, root.Children)
}
func (fc *FileController) GetFileContent(c *gin.Context) {
filePath := c.Query("path")
if filePath == "" {
utils.BadRequest(c, "path参数必填")
return
}
fullPath := filepath.Join(fc.workDir, filepath.Clean(filePath))
if !strings.HasPrefix(fullPath, fc.workDir) {
utils.Forbidden(c, "访问被拒绝")
return
}
content, err := os.ReadFile(fullPath)
if err != nil {
utils.NotFound(c, "文件不存在")
return
}
utils.Success(c, gin.H{
"path": filePath,
"content": string(content),
})
}
func (fc *FileController) SaveFileContent(c *gin.Context) {
var req struct {
Path string `json:"path" binding:"required"`
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
fullPath := filepath.Join(fc.workDir, filepath.Clean(req.Path))
if !strings.HasPrefix(fullPath, fc.workDir) {
utils.Forbidden(c, "访问被拒绝")
return
}
os.MkdirAll(filepath.Dir(fullPath), 0755)
if err := os.WriteFile(fullPath, []byte(req.Content), 0644); err != nil {
utils.ServerError(c, err.Error())
return
}
utils.SuccessMsg(c, "保存成功")
}
func (fc *FileController) CreateFile(c *gin.Context) {
var req struct {
Path string `json:"path" binding:"required"`
IsDir bool `json:"isDir"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
fullPath := filepath.Join(fc.workDir, filepath.Clean(req.Path))
if !strings.HasPrefix(fullPath, fc.workDir) {
utils.Forbidden(c, "访问被拒绝")
return
}
if req.IsDir {
if err := os.MkdirAll(fullPath, 0755); err != nil {
utils.ServerError(c, err.Error())
return
}
} else {
os.MkdirAll(filepath.Dir(fullPath), 0755)
if err := os.WriteFile(fullPath, []byte(""), 0644); err != nil {
utils.ServerError(c, err.Error())
return
}
}
utils.SuccessMsg(c, "创建成功")
}
func (fc *FileController) DeleteFile(c *gin.Context) {
var req struct {
Path string `json:"path" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
fullPath := filepath.Join(fc.workDir, filepath.Clean(req.Path))
if !strings.HasPrefix(fullPath, fc.workDir) {
utils.Forbidden(c, "访问被拒绝")
return
}
if err := os.RemoveAll(fullPath); err != nil {
utils.ServerError(c, err.Error())
return
}
utils.SuccessMsg(c, "删除成功")
}
func (fc *FileController) RenameFile(c *gin.Context) {
var req struct {
OldPath string `json:"oldPath" binding:"required"`
NewPath string `json:"newPath" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
oldFull := filepath.Join(fc.workDir, filepath.Clean(req.OldPath))
newFull := filepath.Join(fc.workDir, filepath.Clean(req.NewPath))
if !strings.HasPrefix(oldFull, fc.workDir) || !strings.HasPrefix(newFull, fc.workDir) {
utils.Forbidden(c, "访问被拒绝")
return
}
// 确保目标目录存在
os.MkdirAll(filepath.Dir(newFull), 0755)
if err := os.Rename(oldFull, newFull); err != nil {
utils.ServerError(c, err.Error())
return
}
utils.SuccessMsg(c, "移动成功")
}
// UploadArchive handles archive file upload and extraction
func (fc *FileController) UploadArchive(c *gin.Context) {
targetDir := c.PostForm("path")
file, err := c.FormFile("file")
if err != nil {
utils.BadRequest(c, "请选择文件")
return
}
// 检查文件类型
ext := strings.ToLower(filepath.Ext(file.Filename))
if ext != ".zip" && ext != ".tar" && ext != ".gz" && ext != ".tgz" {
utils.BadRequest(c, "仅支持 zip、tar、gz、tgz 格式")
return
}
// 确定解压目标目录
extractDir := fc.workDir
if targetDir != "" {
extractDir = filepath.Join(fc.workDir, filepath.Clean(targetDir))
if !strings.HasPrefix(extractDir, fc.workDir) {
utils.Forbidden(c, "访问被拒绝")
return
}
}
os.MkdirAll(extractDir, 0755)
// 保存临时文件
tempFile := filepath.Join(os.TempDir(), file.Filename)
if err := c.SaveUploadedFile(file, tempFile); err != nil {
utils.ServerError(c, "保存文件失败")
return
}
defer os.Remove(tempFile)
// 解压文件
var extractErr error
switch {
case ext == ".zip":
extractErr = extractZip(tempFile, extractDir)
case ext == ".tar":
extractErr = extractTar(tempFile, extractDir)
case ext == ".gz" || ext == ".tgz":
extractErr = extractTarGz(tempFile, extractDir)
}
if extractErr != nil {
utils.ServerError(c, "解压失败: "+extractErr.Error())
return
}
utils.SuccessMsg(c, "导入成功")
}
// UploadFiles handles multiple file uploads
func (fc *FileController) UploadFiles(c *gin.Context) {
targetDir := c.PostForm("path")
// 确定目标目录
destDir := fc.workDir
if targetDir != "" {
destDir = filepath.Join(fc.workDir, filepath.Clean(targetDir))
if !strings.HasPrefix(destDir, fc.workDir) {
utils.Forbidden(c, "访问被拒绝")
return
}
}
os.MkdirAll(destDir, 0755)
form, err := c.MultipartForm()
if err != nil {
utils.BadRequest(c, "请选择文件")
return
}
files := form.File["files"]
paths := form.Value["paths"] // 相对路径数组,用于保持文件夹结构
if len(files) == 0 {
utils.BadRequest(c, "请选择文件")
return
}
for i, file := range files {
// 获取相对路径(如果有)
relPath := file.Filename
if i < len(paths) && paths[i] != "" {
relPath = paths[i]
}
// 构建完整路径
fullPath := filepath.Join(destDir, filepath.Clean(relPath))
// 安全检查
if !strings.HasPrefix(fullPath, fc.workDir) {
continue
}
// 确保父目录存在
os.MkdirAll(filepath.Dir(fullPath), 0755)
// 保存文件
if err := c.SaveUploadedFile(file, fullPath); err != nil {
utils.ServerError(c, "保存文件失败: "+err.Error())
return
}
}
utils.SuccessMsg(c, "上传成功")
}
+107
View File
@@ -0,0 +1,107 @@
package controllers
import (
"strconv"
"baihu/internal/database"
"baihu/internal/models"
"baihu/internal/utils"
"github.com/gin-gonic/gin"
)
type LogController struct{}
func NewLogController() *LogController {
return &LogController{}
}
type TaskLogResponse struct {
ID uint `json:"id"`
TaskID uint `json:"task_id"`
TaskName string `json:"task_name"`
Command string `json:"command"`
Status string `json:"status"`
Duration int64 `json:"duration"`
CreatedAt models.LocalTime `json:"created_at"`
}
func (lc *LogController) GetLogs(c *gin.Context) {
p := utils.ParsePagination(c)
taskID, _ := strconv.Atoi(c.DefaultQuery("task_id", "0"))
taskName := c.DefaultQuery("task_name", "")
var logs []models.TaskLog
var total int64
query := database.DB.Model(&models.TaskLog{})
if taskID > 0 {
query = query.Where("task_id = ?", taskID)
}
// 按任务名称过滤
if taskName != "" {
var taskIDs []uint
database.DB.Model(&models.Task{}).Where("name LIKE ?", "%"+taskName+"%").Pluck("id", &taskIDs)
if len(taskIDs) > 0 {
query = query.Where("task_id IN ?", taskIDs)
} else {
utils.PaginatedResponse(c, []TaskLogResponse{}, 0, p)
return
}
}
query.Count(&total)
query.Order("id DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&logs)
taskIDList := make([]uint, 0)
for _, log := range logs {
taskIDList = append(taskIDList, log.TaskID)
}
var tasks []models.Task
database.DB.Where("id IN ?", taskIDList).Find(&tasks)
taskMap := make(map[uint]string)
for _, t := range tasks {
taskMap[t.ID] = t.Name
}
result := make([]TaskLogResponse, len(logs))
for i, log := range logs {
result[i] = TaskLogResponse{
ID: log.ID,
TaskID: log.TaskID,
TaskName: taskMap[log.TaskID],
Command: log.Command,
Status: log.Status,
Duration: log.Duration,
CreatedAt: log.CreatedAt,
}
}
utils.PaginatedResponse(c, result, total, p)
}
func (lc *LogController) GetLogDetail(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.BadRequest(c, "无效的日志ID")
return
}
var log models.TaskLog
if err := database.DB.First(&log, id).Error; err != nil {
utils.NotFound(c, "日志不存在")
return
}
utils.Success(c, gin.H{
"id": log.ID,
"task_id": log.TaskID,
"command": log.Command,
"output": log.Output,
"status": log.Status,
"duration": log.Duration,
"created_at": log.CreatedAt,
})
}
+99
View File
@@ -0,0 +1,99 @@
package controllers
import (
"strconv"
"baihu/internal/services"
"baihu/internal/utils"
"github.com/gin-gonic/gin"
)
type ScriptController struct {
scriptService *services.ScriptService
}
func NewScriptController(scriptService *services.ScriptService) *ScriptController {
return &ScriptController{scriptService: scriptService}
}
func (sc *ScriptController) CreateScript(c *gin.Context) {
userID := 1
var req struct {
Name string `json:"name" binding:"required"`
Content string `json:"content" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
script := sc.scriptService.CreateScript(req.Name, req.Content, userID)
utils.Success(c, script)
}
func (sc *ScriptController) GetScripts(c *gin.Context) {
userID := 1
scripts := sc.scriptService.GetScriptsByUserID(userID)
utils.Success(c, scripts)
}
func (sc *ScriptController) GetScript(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.BadRequest(c, "无效的脚本ID")
return
}
script := sc.scriptService.GetScriptByID(id)
if script == nil {
utils.NotFound(c, "脚本不存在")
return
}
utils.Success(c, script)
}
func (sc *ScriptController) UpdateScript(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.BadRequest(c, "无效的脚本ID")
return
}
var req struct {
Name string `json:"name"`
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
script := sc.scriptService.UpdateScript(id, req.Name, req.Content)
if script == nil {
utils.NotFound(c, "脚本不存在")
return
}
utils.Success(c, script)
}
func (sc *ScriptController) DeleteScript(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.BadRequest(c, "无效的脚本ID")
return
}
success := sc.scriptService.DeleteScript(id)
if !success {
utils.NotFound(c, "脚本不存在")
return
}
utils.SuccessMsg(c, "删除成功")
}
+149
View File
@@ -0,0 +1,149 @@
package controllers
import (
"baihu/internal/constant"
"baihu/internal/database"
"baihu/internal/models"
"baihu/internal/services"
"baihu/internal/utils"
"fmt"
"runtime"
"time"
"github.com/gin-gonic/gin"
)
type SettingsController struct {
userService *services.UserService
}
func NewSettingsController(userService *services.UserService) *SettingsController {
return &SettingsController{userService: userService}
}
// ChangePassword 修改密码
func (sc *SettingsController) ChangePassword(c *gin.Context) {
var req struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, "参数错误")
return
}
// 暂时使用固定用户名 admin
user := sc.userService.GetUserByUsername("admin")
if user == nil {
utils.NotFound(c, "用户不存在")
return
}
if !sc.userService.ValidatePassword(user, req.OldPassword) {
utils.BadRequest(c, "原密码错误")
return
}
if err := sc.userService.UpdatePassword(user.ID, req.NewPassword); err != nil {
utils.ServerError(c, "修改密码失败")
return
}
utils.SuccessMsg(c, "密码修改成功")
}
// CleanLogs 清理日志
func (sc *SettingsController) CleanLogs(c *gin.Context) {
var req struct {
Days int `json:"days" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, "参数错误")
return
}
cutoff := time.Now().AddDate(0, 0, -req.Days)
result := database.DB.Where("created_at < ?", cutoff).Delete(&models.TaskLog{})
utils.Success(c, gin.H{
"deleted": result.RowsAffected,
})
}
// GetSiteSettings 获取站点设置
func (sc *SettingsController) GetSiteSettings(c *gin.Context) {
config := services.Config
if config == nil {
utils.Success(c, gin.H{
"site_name": "白虎面板",
"port": 8080,
})
return
}
utils.Success(c, gin.H{
"site_name": config.Server.SiteName,
"port": config.Server.Port,
})
}
// GetAbout 获取关于信息
func (sc *SettingsController) GetAbout(c *gin.Context) {
var taskCount, logCount, envCount int64
database.DB.Model(&models.Task{}).Count(&taskCount)
database.DB.Model(&models.TaskLog{}).Count(&logCount)
database.DB.Model(&models.EnvironmentVariable{}).Count(&envCount)
// 内存使用
var m runtime.MemStats
runtime.ReadMemStats(&m)
memUsage := formatBytes(m.Alloc)
// 运行时间
uptime := formatDuration(time.Since(constant.StartTime))
utils.Success(c, gin.H{
"version": constant.Version,
"build_time": constant.BuildTime,
"mem_usage": memUsage,
"uptime": uptime,
"task_count": taskCount,
"log_count": logCount,
"env_count": envCount,
})
}
// formatBytes 格式化字节数
func formatBytes(bytes uint64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := uint64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
// formatDuration 格式化时间间隔
func formatDuration(d time.Duration) string {
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
minutes := int(d.Minutes()) % 60
seconds := int(d.Seconds()) % 60
if days > 0 {
return fmt.Sprintf("%d天%d小时%d分钟%d秒", days, hours, minutes, seconds)
}
if hours > 0 {
return fmt.Sprintf("%d小时%d分钟%d秒", hours, minutes, seconds)
}
if minutes > 0 {
return fmt.Sprintf("%d分钟%d秒", minutes, seconds)
}
return fmt.Sprintf("%d秒", seconds)
}
+128
View File
@@ -0,0 +1,128 @@
package controllers
import (
"strconv"
"baihu/internal/services"
"baihu/internal/utils"
"github.com/gin-gonic/gin"
)
type TaskController struct {
taskService *services.TaskService
cronService *services.CronService
}
func NewTaskController(taskService *services.TaskService, cronService *services.CronService) *TaskController {
return &TaskController{
taskService: taskService,
cronService: cronService,
}
}
func (tc *TaskController) CreateTask(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Command string `json:"command" binding:"required"`
Schedule string `json:"schedule" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
if err := tc.cronService.ValidateCron(req.Schedule); err != nil {
utils.BadRequest(c, "无效的cron表达式: "+err.Error())
return
}
task := tc.taskService.CreateTask(req.Name, req.Command, req.Schedule)
tc.cronService.AddTask(task)
utils.Success(c, task)
}
func (tc *TaskController) GetTasks(c *gin.Context) {
p := utils.ParsePagination(c)
name := c.DefaultQuery("name", "")
tasks, total := tc.taskService.GetTasksWithPagination(p.Page, p.PageSize, name)
utils.PaginatedResponse(c, tasks, total, p)
}
func (tc *TaskController) GetTask(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.BadRequest(c, "无效的任务ID")
return
}
task := tc.taskService.GetTaskByID(id)
if task == nil {
utils.NotFound(c, "任务不存在")
return
}
utils.Success(c, task)
}
func (tc *TaskController) UpdateTask(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.BadRequest(c, "无效的任务ID")
return
}
var req struct {
Name string `json:"name"`
Command string `json:"command"`
Schedule string `json:"schedule"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
if req.Schedule != "" {
if err := tc.cronService.ValidateCron(req.Schedule); err != nil {
utils.BadRequest(c, "无效的cron表达式: "+err.Error())
return
}
}
task := tc.taskService.UpdateTask(id, req.Name, req.Command, req.Schedule, req.Enabled)
if task == nil {
utils.NotFound(c, "任务不存在")
return
}
if task.Enabled {
tc.cronService.AddTask(task)
} else {
tc.cronService.RemoveTask(task.ID)
}
utils.Success(c, task)
}
func (tc *TaskController) DeleteTask(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.BadRequest(c, "无效的任务ID")
return
}
tc.cronService.RemoveTask(uint(id))
success := tc.taskService.DeleteTask(id)
if !success {
utils.NotFound(c, "任务不存在")
return
}
utils.SuccessMsg(c, "删除成功")
}
+249
View File
@@ -0,0 +1,249 @@
package controllers
import (
"bufio"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"sync"
"unicode/utf8"
"baihu/internal/constant"
"baihu/internal/utils"
"github.com/creack/pty"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
type TerminalController struct{}
func NewTerminalController() *TerminalController {
return &TerminalController{}
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// toUTF8 将可能是 GBK 编码的字节转换为 UTF-8
func toUTF8(data []byte) string {
if utf8.Valid(data) {
return string(data)
}
// 尝试从 GBK 转换
reader := transform.NewReader(
bufio.NewReader(
&byteReader{data: data},
),
simplifiedchinese.GBK.NewDecoder(),
)
result, err := io.ReadAll(reader)
if err != nil {
return string(data)
}
return string(result)
}
type byteReader struct {
data []byte
pos int
}
func (r *byteReader) Read(p []byte) (n int, err error) {
if r.pos >= len(r.data) {
return 0, io.EOF
}
n = copy(p, r.data[r.pos:])
r.pos += n
return n, nil
}
func (tc *TerminalController) HandleWebSocket(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
// Windows 使用 pipe 模式,Unix 使用 PTY 模式
if runtime.GOOS == "windows" {
tc.handlePipeMode(conn)
} else {
tc.handlePtyMode(conn)
}
}
// handlePtyMode 使用 PTY 处理终端(Unix/macOS
func (tc *TerminalController) handlePtyMode(conn *websocket.Conn) {
// 发送 PTY 模式标识
conn.WriteMessage(websocket.TextMessage, []byte("__PTY_MODE__"))
cmd := utils.NewShellCmd()
if absDir, err := filepath.Abs(constant.ScriptsWorkDir); err == nil {
cmd.Dir = absDir
}
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
ptmx, err := pty.Start(cmd)
if err != nil {
conn.WriteMessage(websocket.TextMessage, []byte("Error starting shell: "+err.Error()))
return
}
defer ptmx.Close()
pty.Setsize(ptmx, &pty.Winsize{Rows: 24, Cols: 80})
var wg sync.WaitGroup
var connMu sync.Mutex
writeMessage := func(data []byte) {
connMu.Lock()
defer connMu.Unlock()
conn.WriteMessage(websocket.TextMessage, data)
}
wg.Add(1)
go func() {
defer wg.Done()
buf := make([]byte, 4096)
for {
n, err := ptmx.Read(buf)
if err != nil {
return
}
if n > 0 {
text := toUTF8(buf[:n])
writeMessage([]byte(text))
}
}
}()
for {
_, message, err := conn.ReadMessage()
if err != nil {
break
}
if _, err := ptmx.Write(message); err != nil {
break
}
}
cmd.Process.Kill()
cmd.Wait()
wg.Wait()
}
// handlePipeMode 使用 pipe 处理终端(Windows
func (tc *TerminalController) handlePipeMode(conn *websocket.Conn) {
// 发送 pipe 模式标识
conn.WriteMessage(websocket.TextMessage, []byte("__PIPE_MODE__"))
cmd := utils.NewShellCmd()
if absDir, err := filepath.Abs(constant.ScriptsWorkDir); err == nil {
cmd.Dir = absDir
}
stdin, err := cmd.StdinPipe()
if err != nil {
conn.WriteMessage(websocket.TextMessage, []byte("Error: "+err.Error()))
return
}
stdout, err := cmd.StdoutPipe()
if err != nil {
conn.WriteMessage(websocket.TextMessage, []byte("Error: "+err.Error()))
return
}
stderr, err := cmd.StderrPipe()
if err != nil {
conn.WriteMessage(websocket.TextMessage, []byte("Error: "+err.Error()))
return
}
if err := cmd.Start(); err != nil {
conn.WriteMessage(websocket.TextMessage, []byte("Error: "+err.Error()))
return
}
var wg sync.WaitGroup
var connMu sync.Mutex
writeMessage := func(data []byte) {
connMu.Lock()
defer connMu.Unlock()
conn.WriteMessage(websocket.TextMessage, data)
}
readOutput := func(reader io.Reader) {
defer wg.Done()
defer func() { recover() }()
buf := make([]byte, 4096)
for {
n, err := reader.Read(buf)
if err != nil {
return
}
if n > 0 {
text := toUTF8(buf[:n])
writeMessage([]byte(text))
}
}
}
wg.Add(2)
go readOutput(stdout)
go readOutput(stderr)
for {
_, message, err := conn.ReadMessage()
if err != nil {
break
}
if _, err := stdin.Write(message); err != nil {
break
}
}
stdin.Close()
cmd.Process.Kill()
cmd.Wait()
wg.Wait()
}
// ExecuteShellCommand 执行单个命令并返回结果
func (tc *TerminalController) ExecuteShellCommand(c *gin.Context) {
var req struct {
Command string `json:"command" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
cmd := utils.NewShellCommandCmd(req.Command)
output, err := cmd.CombinedOutput()
if err != nil {
utils.Success(c, gin.H{
"output": string(output),
"error": err.Error(),
})
return
}
utils.Success(c, gin.H{
"output": string(output),
})
}
+74
View File
@@ -0,0 +1,74 @@
package database
import (
"fmt"
"time"
"baihu/internal/logger"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
var DB *gorm.DB
type Config struct {
Type string // sqlite, mysql, postgres
Host string
Port int
User string
Password string
DBName string
Path string // for sqlite
}
func Init(cfg *Config) error {
// 设置东八区时区
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
logger.Warnf("Failed to load timezone, using UTC: %v", err)
loc = time.UTC
}
time.Local = loc
var dialector gorm.Dialector
switch cfg.Type {
case "sqlite":
dialector = sqlite.Open(cfg.Path)
case "mysql":
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Asia%%2FShanghai",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)
dialector = mysql.Open(dsn)
case "postgres":
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable TimeZone=Asia/Shanghai",
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName)
dialector = postgres.Open(dsn)
default:
return fmt.Errorf("unsupported database type: %s", cfg.Type)
}
DB, err = gorm.Open(dialector, &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Warn),
NowFunc: func() time.Time {
return time.Now().In(loc)
},
})
if err != nil {
return fmt.Errorf("failed to connect database: %w", err)
}
logger.Infof("Connected to %s database with Asia/Shanghai timezone", cfg.Type)
return nil
}
func AutoMigrate(models ...interface{}) error {
return DB.AutoMigrate(models...)
}
func GetDB() *gorm.DB {
return DB
}
+16
View File
@@ -0,0 +1,16 @@
package database
import (
"baihu/internal/models"
)
func Migrate() error {
return AutoMigrate(
&models.User{},
&models.Task{},
&models.TaskLog{},
&models.Script{},
&models.EnvironmentVariable{},
&models.Setting{},
)
}
+83
View File
@@ -0,0 +1,83 @@
package logger
import (
"os"
"path/filepath"
"time"
"github.com/sirupsen/logrus"
)
var Log *logrus.Logger
func init() {
Log = logrus.New()
// 设置日志格式
Log.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "2006-01-02 15:04:05",
ForceColors: true,
})
// 设置日志级别
Log.SetLevel(logrus.InfoLevel)
// 输出到标准输出
Log.SetOutput(os.Stdout)
}
// SetupFileOutput 设置文件输出
func SetupFileOutput(logDir string) error {
if err := os.MkdirAll(logDir, 0755); err != nil {
return err
}
logFile := filepath.Join(logDir, time.Now().Format("2006-01-02")+".log")
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return err
}
Log.SetOutput(file)
return nil
}
// SetLevel 设置日志级别
func SetLevel(level string) {
switch level {
case "debug":
Log.SetLevel(logrus.DebugLevel)
case "info":
Log.SetLevel(logrus.InfoLevel)
case "warn":
Log.SetLevel(logrus.WarnLevel)
case "error":
Log.SetLevel(logrus.ErrorLevel)
default:
Log.SetLevel(logrus.InfoLevel)
}
}
// 便捷方法
func Debug(args ...interface{}) { Log.Debug(args...) }
func Info(args ...interface{}) { Log.Info(args...) }
func Warn(args ...interface{}) { Log.Warn(args...) }
func Error(args ...interface{}) { Log.Error(args...) }
func Fatal(args ...interface{}) { Log.Fatal(args...) }
func Debugf(format string, args ...interface{}) { Log.Debugf(format, args...) }
func Infof(format string, args ...interface{}) { Log.Infof(format, args...) }
func Warnf(format string, args ...interface{}) { Log.Warnf(format, args...) }
func Errorf(format string, args ...interface{}) { Log.Errorf(format, args...) }
func Fatalf(format string, args ...interface{}) { Log.Fatalf(format, args...) }
// WithField 带字段的日志
func WithField(key string, value interface{}) *logrus.Entry {
return Log.WithField(key, value)
}
// WithFields 带多个字段的日志
func WithFields(fields logrus.Fields) *logrus.Entry {
return Log.WithFields(fields)
}
+43
View File
@@ -0,0 +1,43 @@
package middleware
import (
"baihu/internal/constant"
"baihu/internal/utils"
"github.com/gin-gonic/gin"
)
// AuthRequired 认证中间件
func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
token, err := c.Cookie(constant.CookieName)
if err != nil || token == "" {
utils.Unauthorized(c, "请先登录")
c.Abort()
return
}
// 验证 token
userID, username, err := utils.ParseToken(token)
if err != nil {
utils.Unauthorized(c, "登录已过期,请重新登录")
c.Abort()
return
}
// 将用户信息存入上下文
c.Set("userID", userID)
c.Set("username", username)
c.Next()
}
}
// SetAuthCookie 设置认证 Cookie
func SetAuthCookie(c *gin.Context, token string) {
c.SetCookie(constant.CookieName, token, constant.CookieMaxAge, "/", "", false, true)
}
// ClearAuthCookie 清除认证 Cookie
func ClearAuthCookie(c *gin.Context) {
c.SetCookie(constant.CookieName, "", -1, "/", "", false, true)
}
+54
View File
@@ -0,0 +1,54 @@
package middleware
import (
"fmt"
"time"
"baihu/internal/logger"
"github.com/gin-gonic/gin"
)
// GinLogger 返回使用 logrus 的 Gin 日志中间件
func GinLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
clientIP := c.ClientIP()
method := c.Request.Method
if query != "" {
path = path + "?" + query
}
msg := fmt.Sprintf("%3d | %13v | %15s | %-7s %s",
status, latency, clientIP, method, path)
if status >= 500 {
logger.Error(msg)
} else if status >= 400 {
logger.Warn(msg)
} else {
logger.Info(msg)
}
}
}
// GinRecovery 返回使用 logrus 的 Gin 恢复中间件
func GinRecovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
logger.Errorf("Panic recovered: %v | path: %s", err, c.Request.URL.Path)
c.AbortWithStatus(500)
}
}()
c.Next()
}
}
+38
View File
@@ -0,0 +1,38 @@
package models
import (
"baihu/internal/constant"
"gorm.io/gorm"
)
// EnvironmentVariable represents an environment variable
type EnvironmentVariable struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:255;not null"`
Value string `json:"value" gorm:"type:text"`
Remark string `json:"remark" gorm:"size:500"`
UserID uint `json:"user_id" gorm:"index"`
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (EnvironmentVariable) TableName() string {
return constant.TablePrefix + "envs"
}
// Script represents a script file
type Script struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:255;not null"`
Content string `json:"content" gorm:"type:text"`
UserID uint `json:"user_id" gorm:"index"`
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (Script) TableName() string {
return constant.TablePrefix + "scripts"
}
+17
View File
@@ -0,0 +1,17 @@
package models
import (
"baihu/internal/constant"
)
// Setting 系统设置
type Setting struct {
ID uint `json:"id" gorm:"primaryKey"`
Section string `json:"section" gorm:"size:50;not null;index:idx_section_key"`
Key string `json:"key" gorm:"size:100;not null;index:idx_section_key"`
Value string `json:"value" gorm:"type:text"`
}
func (Setting) TableName() string {
return constant.TablePrefix + "settings"
}
+42
View File
@@ -0,0 +1,42 @@
package models
import (
"baihu/internal/constant"
"gorm.io/gorm"
)
// Task represents a scheduled task
type Task struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:255;not null"`
Command string `json:"command" gorm:"type:text;not null"`
Schedule string `json:"schedule" gorm:"size:100"` // cron expression
Timeout int `json:"timeout" gorm:"default:30"` // 超时时间(分钟),默认30分钟
Enabled bool `json:"enabled" gorm:"default:true"`
LastRun *LocalTime `json:"last_run"`
NextRun *LocalTime `json:"next_run"`
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (Task) TableName() string {
return constant.TablePrefix + "tasks"
}
// TaskLog represents a log entry for task execution
type TaskLog struct {
ID uint `json:"id" gorm:"primaryKey"`
TaskID uint `json:"task_id" gorm:"index"`
Command string `json:"command" gorm:"type:text"`
Output string `json:"-" gorm:"type:longtext"` // gzip+base64 compressed
Status string `json:"status" gorm:"size:20"` // success, failed
Duration int64 `json:"duration"` // milliseconds
ExitCode int `json:"exit_code"`
CreatedAt LocalTime `json:"created_at"`
}
func (TaskLog) TableName() string {
return constant.TablePrefix + "task_logs"
}
+70
View File
@@ -0,0 +1,70 @@
package models
import (
"database/sql/driver"
"fmt"
"time"
)
const TimeFormat = "2006-01-02 15:04:05"
// LocalTime 自定义时间类型,JSON 序列化为 "年-月-日 时:分:秒" 格式
type LocalTime time.Time
func (t LocalTime) MarshalJSON() ([]byte, error) {
tt := time.Time(t)
if tt.IsZero() {
return []byte("null"), nil
}
return []byte(fmt.Sprintf(`"%s"`, tt.Format(TimeFormat))), nil
}
func (t *LocalTime) UnmarshalJSON(data []byte) error {
if string(data) == "null" || string(data) == `""` {
return nil
}
// 去掉引号
s := string(data)
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
s = s[1 : len(s)-1]
}
tt, err := time.ParseInLocation(TimeFormat, s, time.Local)
if err != nil {
// 尝试解析 ISO 格式
tt, err = time.Parse(time.RFC3339, s)
if err != nil {
return err
}
}
*t = LocalTime(tt)
return nil
}
func (t LocalTime) Value() (driver.Value, error) {
return time.Time(t), nil
}
func (t *LocalTime) Scan(v interface{}) error {
if v == nil {
return nil
}
switch val := v.(type) {
case time.Time:
*t = LocalTime(val)
case string:
tt, err := time.ParseInLocation(TimeFormat, val, time.Local)
if err != nil {
return err
}
*t = LocalTime(tt)
}
return nil
}
func (t LocalTime) Time() time.Time {
return time.Time(t)
}
func Now() LocalTime {
return LocalTime(time.Now())
}
+23
View File
@@ -0,0 +1,23 @@
package models
import (
"baihu/internal/constant"
"gorm.io/gorm"
)
// User represents a system user
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
Username string `json:"username" gorm:"size:100;uniqueIndex;not null"`
Password string `json:"-" gorm:"size:255;not null"`
Email string `json:"email" gorm:"size:255"`
Role string `json:"role" gorm:"size:20;default:user"` // admin, user
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (User) TableName() string {
return constant.TablePrefix + "users"
}
+48
View File
@@ -0,0 +1,48 @@
package router
import (
"baihu/internal/constant"
"baihu/internal/controllers"
"baihu/internal/services"
)
var cronService *services.CronService
func RegisterControllers() *Controllers {
// Initialize services
taskService := services.NewTaskService()
userService := services.NewUserService()
envService := services.NewEnvService()
scriptService := services.NewScriptService()
executorService := services.NewExecutorService(taskService)
settingsService := services.NewSettingsService()
// 执行系统初始化
initService := services.NewInitService(settingsService, userService)
initService.Initialize()
// Initialize cron service
cronService = services.NewCronService(taskService, executorService)
cronService.Start()
// Initialize and return controllers
return &Controllers{
Task: controllers.NewTaskController(taskService, cronService),
Auth: controllers.NewAuthController(userService),
Env: controllers.NewEnvController(envService),
Script: controllers.NewScriptController(scriptService),
Executor: controllers.NewExecutorController(executorService),
File: controllers.NewFileController(constant.ScriptsWorkDir),
Dashboard: controllers.NewDashboardController(cronService, executorService),
Log: controllers.NewLogController(),
Terminal: controllers.NewTerminalController(),
Settings: controllers.NewSettingsController(userService),
}
}
// StopCron stops the cron service gracefully
func StopCron() {
if cronService != nil {
cronService.Stop()
}
}
+176
View File
@@ -0,0 +1,176 @@
package router
import (
"io/fs"
"net/http"
"baihu/internal/controllers"
"baihu/internal/middleware"
"baihu/internal/static"
"github.com/gin-gonic/gin"
)
type Controllers struct {
Task *controllers.TaskController
Auth *controllers.AuthController
Env *controllers.EnvController
Script *controllers.ScriptController
Executor *controllers.ExecutorController
File *controllers.FileController
Dashboard *controllers.DashboardController
Log *controllers.LogController
Terminal *controllers.TerminalController
Settings *controllers.SettingsController
}
func mustSubFS(fsys fs.FS, dir string) fs.FS {
sub, err := fs.Sub(fsys, dir)
if err != nil {
panic(err)
}
return sub
}
// cacheControl 返回设置 Cache-Control header 的中间件
func cacheControl(value string) gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Cache-Control", value)
c.Next()
}
}
func Setup(c *Controllers) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
router := gin.New()
router.Use(middleware.GinLogger(), middleware.GinRecovery())
// Serve embedded Vue SPA static files with cache headers
staticFS := static.GetFS()
assetsGroup := router.Group("/assets")
assetsGroup.Use(cacheControl("public, max-age=31536000, immutable")) // 1 year cache for hashed assets
assetsGroup.StaticFS("/", http.FS(mustSubFS(staticFS, "assets")))
// Serve logo.svg with short cache
router.GET("/logo.svg", func(ctx *gin.Context) {
data, err := static.ReadFile("logo.svg")
if err != nil {
ctx.Status(404)
return
}
ctx.Header("Cache-Control", "public, max-age=86400") // 1 day
ctx.Data(200, "image/svg+xml", data)
})
// SPA fallback - serve index.html (no cache for HTML)
router.NoRoute(func(ctx *gin.Context) {
data, err := static.ReadFile("index.html")
if err != nil {
ctx.String(500, "index.html not found")
return
}
ctx.Header("Cache-Control", "no-cache, no-store, must-revalidate")
ctx.Data(200, "text/html; charset=utf-8", data)
})
// API routes
api := router.Group("/api")
{
// Health check (无需认证)
api.GET("/ping", func(ctx *gin.Context) {
ctx.JSON(200, gin.H{"message": "pong"})
})
// Authentication routes (无需认证)
auth := api.Group("/auth")
{
auth.POST("/login", c.Auth.Login)
auth.POST("/logout", c.Auth.Logout)
auth.POST("/register", c.Auth.Register)
}
// 需要认证的路由
authorized := api.Group("")
authorized.Use(middleware.AuthRequired())
{
// 获取当前用户
authorized.GET("/auth/me", c.Auth.GetCurrentUser)
// Dashboard stats
authorized.GET("/stats", c.Dashboard.GetStats)
// Task routes
tasks := authorized.Group("/tasks")
{
tasks.POST("", c.Task.CreateTask)
tasks.GET("", c.Task.GetTasks)
tasks.GET("/:id", c.Task.GetTask)
tasks.PUT("/:id", c.Task.UpdateTask)
tasks.DELETE("/:id", c.Task.DeleteTask)
}
// Task execution routes
execution := authorized.Group("/execute")
{
execution.POST("/task/:id", c.Executor.ExecuteTask)
execution.POST("/command", c.Executor.ExecuteCommand)
execution.GET("/results", c.Executor.GetLastResults)
}
// Environment variable routes
env := authorized.Group("/env")
{
env.POST("", c.Env.CreateEnvVar)
env.GET("", c.Env.GetEnvVars)
env.GET("/:id", c.Env.GetEnvVar)
env.PUT("/:id", c.Env.UpdateEnvVar)
env.DELETE("/:id", c.Env.DeleteEnvVar)
}
// Script routes
scripts := authorized.Group("/scripts")
{
scripts.POST("", c.Script.CreateScript)
scripts.GET("", c.Script.GetScripts)
scripts.GET("/:id", c.Script.GetScript)
scripts.PUT("/:id", c.Script.UpdateScript)
scripts.DELETE("/:id", c.Script.DeleteScript)
}
// File routes
files := authorized.Group("/files")
{
files.GET("/tree", c.File.GetFileTree)
files.GET("/content", c.File.GetFileContent)
files.POST("/content", c.File.SaveFileContent)
files.POST("/create", c.File.CreateFile)
files.POST("/delete", c.File.DeleteFile)
files.POST("/rename", c.File.RenameFile)
files.POST("/upload", c.File.UploadArchive)
files.POST("/upload-files", c.File.UploadFiles)
}
// Log routes
logs := authorized.Group("/logs")
{
logs.GET("", c.Log.GetLogs)
logs.GET("/:id", c.Log.GetLogDetail)
}
// Terminal routes
authorized.GET("/terminal/ws", c.Terminal.HandleWebSocket)
authorized.POST("/terminal/exec", c.Terminal.ExecuteShellCommand)
// Settings routes
settings := authorized.Group("/settings")
{
settings.POST("/password", c.Settings.ChangePassword)
settings.POST("/clean-logs", c.Settings.CleanLogs)
settings.GET("/site", c.Settings.GetSiteSettings)
settings.GET("/about", c.Settings.GetAbout)
}
}
}
return router
}
+71
View File
@@ -0,0 +1,71 @@
package services
import (
"baihu/internal/constant"
"encoding/json"
"os"
)
type ServerConfig struct {
Port int `json:"port"`
Host string `json:"host"`
SiteName string `json:"site_name"`
}
type DatabaseConfig struct {
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
DBName string `json:"dbname"`
Path string `json:"path"`
TablePrefix string `json:"table_prefix"`
}
type SecurityConfig struct {
JWTSecret string `json:"jwt_secret"`
PasswordSalt string `json:"password_salt"`
}
type TaskConfig struct {
DefaultTimeout int `json:"default_timeout"`
LogRetentionDays int `json:"log_retention_days"`
}
type AppConfig struct {
Server ServerConfig `json:"server"`
Database DatabaseConfig `json:"database"`
Security SecurityConfig `json:"security"`
Task TaskConfig `json:"task"`
}
var Config *AppConfig
func LoadConfig(path string) (*AppConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
Config = &AppConfig{}
if err := json.Unmarshal(data, Config); err != nil {
return nil, err
}
// 设置表前缀到 constant 包
if Config.Database.TablePrefix != "" {
constant.TablePrefix = Config.Database.TablePrefix
}
// 设置 JWT 密钥
if Config.Security.JWTSecret != "" {
constant.JWTSecret = Config.Security.JWTSecret
}
return Config, nil
}
func GetConfig() *AppConfig {
return Config
}
+150
View File
@@ -0,0 +1,150 @@
package services
import (
"sync"
"time"
"baihu/internal/database"
"baihu/internal/logger"
"baihu/internal/models"
"github.com/robfig/cron/v3"
)
// CronService manages scheduled tasks using robfig/cron
type CronService struct {
cron *cron.Cron
taskService *TaskService
executorService *ExecutorService
entryMap map[uint]cron.EntryID // task ID -> cron entry ID
mu sync.RWMutex
}
// NewCronService creates a new cron service
func NewCronService(taskService *TaskService, executorService *ExecutorService) *CronService {
// 使用秒级精度的 cron parser,支持 5 位和 6 位表达式
c := cron.New(cron.WithParser(cron.NewParser(
cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
)))
return &CronService{
cron: c,
taskService: taskService,
executorService: executorService,
entryMap: make(map[uint]cron.EntryID),
}
}
// Start starts the cron service and loads all enabled tasks
func (cs *CronService) Start() {
cs.loadTasks()
cs.cron.Start()
logger.Info("Cron service started")
}
// Stop stops the cron service
func (cs *CronService) Stop() {
ctx := cs.cron.Stop()
<-ctx.Done()
logger.Info("Cron service stopped")
}
// loadTasks loads all enabled tasks from database
func (cs *CronService) loadTasks() {
tasks := cs.taskService.GetTasks()
for _, task := range tasks {
if task.Enabled {
err := cs.AddTask(&task)
if err != nil {
return
}
}
}
}
// AddTask adds a task to the cron scheduler
func (cs *CronService) AddTask(task *models.Task) error {
cs.mu.Lock()
// 如果已存在,先移除
if entryID, exists := cs.entryMap[task.ID]; exists {
cs.cron.Remove(entryID)
delete(cs.entryMap, task.ID)
}
taskID := task.ID
entryID, err := cs.cron.AddFunc(task.Schedule, func() {
cs.runTask(taskID)
})
if err != nil {
cs.mu.Unlock()
logger.Errorf("Failed to add task %d: %v", task.ID, err)
return err
}
cs.entryMap[task.ID] = entryID
cs.mu.Unlock()
logger.Infof("Task %d (%s) scheduled with cron: %s", task.ID, task.Name, task.Schedule)
// 更新下次运行时间
cs.updateNextRun(task.ID)
return nil
}
// RemoveTask removes a task from the cron scheduler
func (cs *CronService) RemoveTask(taskID uint) {
cs.mu.Lock()
defer cs.mu.Unlock()
if entryID, exists := cs.entryMap[taskID]; exists {
cs.cron.Remove(entryID)
delete(cs.entryMap, taskID)
logger.Infof("Task %d removed from scheduler", taskID)
}
}
// runTask executes a task and updates its status
func (cs *CronService) runTask(taskID uint) {
logger.Infof("Running task %d", taskID)
// 更新 last_run
now := time.Now()
database.DB.Model(&models.Task{}).Where("id = ?", taskID).Update("last_run", now)
// 执行任务
cs.executorService.ExecuteTask(int(taskID))
// 更新 next_run
cs.updateNextRun(taskID)
}
// updateNextRun updates the next run time for a task
func (cs *CronService) updateNextRun(taskID uint) {
cs.mu.RLock()
entryID, exists := cs.entryMap[taskID]
cs.mu.RUnlock()
if !exists {
return
}
entry := cs.cron.Entry(entryID)
if !entry.Next.IsZero() {
database.DB.Model(&models.Task{}).Where("id = ?", taskID).Update("next_run", entry.Next)
}
}
// ValidateCron validates a cron expression
func (cs *CronService) ValidateCron(expression string) error {
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
_, err := parser.Parse(expression)
return err
}
// GetScheduledCount returns the number of scheduled tasks
func (cs *CronService) GetScheduledCount() int {
cs.mu.RLock()
defer cs.mu.RUnlock()
return len(cs.entryMap)
}
+68
View File
@@ -0,0 +1,68 @@
package services
import (
"baihu/internal/database"
"baihu/internal/models"
)
type EnvService struct{}
func NewEnvService() *EnvService {
return &EnvService{}
}
func (es *EnvService) CreateEnvVar(name, value, remark string, userID int) *models.EnvironmentVariable {
env := &models.EnvironmentVariable{
Name: name,
Value: value,
Remark: remark,
UserID: uint(userID),
}
database.DB.Create(env)
return env
}
func (es *EnvService) GetEnvVarsByUserID(userID int) []models.EnvironmentVariable {
var envs []models.EnvironmentVariable
database.DB.Where("user_id = ?", userID).Find(&envs)
return envs
}
func (es *EnvService) GetEnvVarsWithPagination(userID int, name string, page, pageSize int) ([]models.EnvironmentVariable, int64) {
var envs []models.EnvironmentVariable
var total int64
query := database.DB.Model(&models.EnvironmentVariable{}).Where("user_id = ?", userID)
if name != "" {
query = query.Where("name LIKE ?", "%"+name+"%")
}
query.Count(&total)
query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&envs)
return envs, total
}
func (es *EnvService) GetEnvVarByID(id int) *models.EnvironmentVariable {
var env models.EnvironmentVariable
if err := database.DB.First(&env, id).Error; err != nil {
return nil
}
return &env
}
func (es *EnvService) UpdateEnvVar(id int, name, value, remark string) *models.EnvironmentVariable {
var env models.EnvironmentVariable
if err := database.DB.First(&env, id).Error; err != nil {
return nil
}
env.Name = name
env.Value = value
env.Remark = remark
database.DB.Save(&env)
return &env
}
func (es *EnvService) DeleteEnvVar(id int) bool {
result := database.DB.Delete(&models.EnvironmentVariable{}, id)
return result.RowsAffected > 0
}
+183
View File
@@ -0,0 +1,183 @@
package services
import (
"bytes"
"context"
"os/exec"
"sync"
"time"
"baihu/internal/constant"
"baihu/internal/database"
"baihu/internal/logger"
"baihu/internal/models"
"baihu/internal/utils"
)
// ExecutionResult represents the result of a task execution
type ExecutionResult struct {
TaskID int
Success bool
Output string
Error string
Start time.Time
End time.Time
}
// ExecutorService handles task execution
type ExecutorService struct {
taskService *TaskService
results []ExecutionResult
runningTasks map[int]bool // 正在运行的任务
mu sync.RWMutex
}
// NewExecutorService creates a new executor service
func NewExecutorService(taskService *TaskService) *ExecutorService {
return &ExecutorService{
taskService: taskService,
results: make([]ExecutionResult, 0),
runningTasks: make(map[int]bool),
}
}
// ExecuteTask executes a task by ID
func (es *ExecutorService) ExecuteTask(taskID int) *ExecutionResult {
task := es.taskService.GetTaskByID(taskID)
if task == nil {
return &ExecutionResult{
TaskID: taskID,
Success: false,
Error: "Task not found",
Start: time.Now(),
End: time.Now(),
}
}
// 标记任务开始运行
es.mu.Lock()
es.runningTasks[taskID] = true
es.mu.Unlock()
// 使用任务配置的超时时间
timeout := task.Timeout
if timeout <= 0 {
timeout = constant.DefaultTaskTimeout
}
result := es.ExecuteCommandWithTimeout(task.Command, time.Duration(timeout)*time.Minute)
result.TaskID = taskID
// 标记任务结束
es.mu.Lock()
delete(es.runningTasks, taskID)
es.mu.Unlock()
// Save log to database
es.saveTaskLog(uint(taskID), task.Command, result)
return result
}
// GetRunningCount 获取正在运行的任务数量
func (es *ExecutorService) GetRunningCount() int {
es.mu.RLock()
defer es.mu.RUnlock()
return len(es.runningTasks)
}
// ExecuteCommand executes a shell command with default timeout
func (es *ExecutorService) ExecuteCommand(command string) *ExecutionResult {
return es.ExecuteCommandWithTimeout(command, time.Duration(constant.DefaultTaskTimeout)*time.Minute)
}
// ExecuteCommandWithTimeout executes a shell command with specified timeout
func (es *ExecutorService) ExecuteCommandWithTimeout(command string, timeout time.Duration) *ExecutionResult {
result := &ExecutionResult{
Success: false,
Start: time.Now(),
}
// Create a context with timeout
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Execute the command
shell, args := utils.GetShellCommand(command)
cmd := exec.CommandContext(ctx, shell, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
result.End = time.Now()
// Process results
result.Output = stdout.String()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
result.Error = "执行超时\n" + stderr.String()
} else {
result.Error = err.Error() + "\n" + stderr.String()
}
} else {
result.Success = true
}
// Store result
es.mu.Lock()
es.results = append(es.results, *result)
// Keep only the last 100 results to prevent memory issues
if len(es.results) > 100 {
es.results = es.results[1:]
}
es.mu.Unlock()
return result
}
// GetLastResults returns the last execution results
func (es *ExecutorService) GetLastResults(count int) []ExecutionResult {
es.mu.RLock()
defer es.mu.RUnlock()
start := 0
if len(es.results) > count {
start = len(es.results) - count
}
results := make([]ExecutionResult, len(es.results[start:]))
copy(results, es.results[start:])
return results
}
// saveTaskLog saves execution log to database with gzip+base64 compression
func (es *ExecutorService) saveTaskLog(taskID uint, command string, result *ExecutionResult) {
output := result.Output
if result.Error != "" {
output += "\n[ERROR]\n" + result.Error
}
// Compress output
compressed, err := utils.CompressToBase64(output)
if err != nil {
logger.Errorf("Failed to compress log: %v", err)
compressed = ""
}
status := "success"
if !result.Success {
status = "failed"
}
taskLog := &models.TaskLog{
TaskID: taskID,
Command: command,
Output: compressed,
Status: status,
Duration: result.End.Sub(result.Start).Milliseconds(),
}
if err := database.DB.Create(taskLog).Error; err != nil {
logger.Errorf("Failed to save task log: %v", err)
}
}
+57
View File
@@ -0,0 +1,57 @@
package services
import (
"baihu/internal/logger"
)
const (
InitSection = "system"
InitKey = "initialized"
InitValue = "true"
)
type InitService struct {
settingsService *SettingsService
userService *UserService
}
func NewInitService(settingsService *SettingsService, userService *UserService) *InitService {
return &InitService{
settingsService: settingsService,
userService: userService,
}
}
// Initialize 执行初始化,如果已初始化则跳过
func (s *InitService) Initialize() {
if s.IsInitialized() {
logger.Info("系统已初始化,跳过")
return
}
logger.Info("开始初始化系统...")
// 创建管理员账号
s.createAdminUser()
// 标记为已初始化
s.settingsService.Set(InitSection, InitKey, InitValue)
logger.Info("系统初始化完成")
}
// IsInitialized 检查是否已初始化
func (s *InitService) IsInitialized() bool {
return s.settingsService.Get(InitSection, InitKey) == InitValue
}
// createAdminUser 创建管理员账号
func (s *InitService) createAdminUser() {
existingUser := s.userService.GetUserByUsername("admin")
if existingUser != nil {
logger.Info("管理员账号已存在,跳过创建")
return
}
s.userService.CreateUser("admin", "123456", "admin@local", "admin")
logger.Info("管理员账号创建成功: admin / 123456")
}
+52
View File
@@ -0,0 +1,52 @@
package services
import (
"baihu/internal/database"
"baihu/internal/models"
)
type ScriptService struct{}
func NewScriptService() *ScriptService {
return &ScriptService{}
}
func (ss *ScriptService) CreateScript(name, content string, userID int) *models.Script {
script := &models.Script{
Name: name,
Content: content,
UserID: uint(userID),
}
database.DB.Create(script)
return script
}
func (ss *ScriptService) GetScriptsByUserID(userID int) []models.Script {
var scripts []models.Script
database.DB.Where("user_id = ?", userID).Find(&scripts)
return scripts
}
func (ss *ScriptService) GetScriptByID(id int) *models.Script {
var script models.Script
if err := database.DB.First(&script, id).Error; err != nil {
return nil
}
return &script
}
func (ss *ScriptService) UpdateScript(id int, name, content string) *models.Script {
var script models.Script
if err := database.DB.First(&script, id).Error; err != nil {
return nil
}
script.Name = name
script.Content = content
database.DB.Save(&script)
return &script
}
func (ss *ScriptService) DeleteScript(id int) bool {
result := database.DB.Delete(&models.Script{}, id)
return result.RowsAffected > 0
}
+69
View File
@@ -0,0 +1,69 @@
package services
import (
"baihu/internal/database"
"baihu/internal/models"
)
type SettingsService struct{}
func NewSettingsService() *SettingsService {
return &SettingsService{}
}
// Get 获取设置值
func (s *SettingsService) Get(section, key string) string {
var setting models.Setting
if err := database.DB.Where("section = ? AND key = ?", section, key).First(&setting).Error; err != nil {
return ""
}
return setting.Value
}
// GetWithDefault 获取设置值,如果不存在则返回默认值
func (s *SettingsService) GetWithDefault(section, key, defaultValue string) string {
value := s.Get(section, key)
if value == "" {
return defaultValue
}
return value
}
// Set 设置值
func (s *SettingsService) Set(section, key, value string) error {
var setting models.Setting
result := database.DB.Where("section = ? AND key = ?", section, key).First(&setting)
if result.Error != nil {
// 不存在则创建
setting = models.Setting{
Section: section,
Key: key,
Value: value,
}
return database.DB.Create(&setting).Error
}
// 存在则更新
return database.DB.Model(&setting).Update("value", value).Error
}
// GetBySection 获取某个 section 下的所有设置
func (s *SettingsService) GetBySection(section string) map[string]string {
var settings []models.Setting
database.DB.Where("section = ?", section).Find(&settings)
result := make(map[string]string)
for _, s := range settings {
result[s.Key] = s.Value
}
return result
}
// Delete 删除设置
func (s *SettingsService) Delete(section, key string) error {
return database.DB.Where("section = ? AND key = ?", section, key).Delete(&models.Setting{}).Error
}
// DeleteBySection 删除某个 section 下的所有设置
func (s *SettingsService) DeleteBySection(section string) error {
return database.DB.Where("section = ?", section).Delete(&models.Setting{}).Error
}
+71
View File
@@ -0,0 +1,71 @@
package services
import (
"baihu/internal/database"
"baihu/internal/models"
)
type TaskService struct{}
func NewTaskService() *TaskService {
return &TaskService{}
}
func (ts *TaskService) CreateTask(name, command, schedule string) *models.Task {
task := &models.Task{
Name: name,
Command: command,
Schedule: schedule,
Enabled: true,
}
database.DB.Create(task)
return task
}
func (ts *TaskService) GetTasks() []models.Task {
var tasks []models.Task
database.DB.Find(&tasks)
return tasks
}
// GetTasksWithPagination 分页获取任务列表
func (ts *TaskService) GetTasksWithPagination(page, pageSize int, name string) ([]models.Task, int64) {
var tasks []models.Task
var total int64
query := database.DB.Model(&models.Task{})
if name != "" {
query = query.Where("name LIKE ?", "%"+name+"%")
}
query.Count(&total)
query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&tasks)
return tasks, total
}
func (ts *TaskService) GetTaskByID(id int) *models.Task {
var task models.Task
if err := database.DB.First(&task, id).Error; err != nil {
return nil
}
return &task
}
func (ts *TaskService) UpdateTask(id int, name, command, schedule string, enabled bool) *models.Task {
var task models.Task
if err := database.DB.First(&task, id).Error; err != nil {
return nil
}
task.Name = name
task.Command = command
task.Schedule = schedule
task.Enabled = enabled
database.DB.Save(&task)
return &task
}
func (ts *TaskService) DeleteTask(id int) bool {
result := database.DB.Delete(&models.Task{}, id)
return result.RowsAffected > 0
}
+67
View File
@@ -0,0 +1,67 @@
package services
import (
"crypto/sha256"
"encoding/hex"
"baihu/internal/database"
"baihu/internal/models"
)
type UserService struct{}
func NewUserService() *UserService {
return &UserService{}
}
func (us *UserService) hashPassword(password string) string {
salt := ""
if Config != nil {
salt = Config.Security.PasswordSalt
}
hash := sha256.Sum256([]byte(password + salt))
return hex.EncodeToString(hash[:])
}
func (us *UserService) CreateUser(username, password, email, role string) *models.User {
user := &models.User{
Username: username,
Password: us.hashPassword(password),
Email: email,
Role: role,
}
database.DB.Create(user)
return user
}
func (us *UserService) GetUserByUsername(username string) *models.User {
var user models.User
if err := database.DB.Where("username = ?", username).First(&user).Error; err != nil {
return nil
}
return &user
}
func (us *UserService) ValidatePassword(user *models.User, password string) bool {
return user.Password == us.hashPassword(password)
}
func (us *UserService) EnsureAdminExists() {
var count int64
database.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&count)
if count == 0 {
us.CreateUser("admin", "admin123", "admin@local", "admin")
}
}
func (us *UserService) AuthenticateUser(username, password string) bool {
user := us.GetUserByUsername(username)
if user == nil {
return false
}
return us.ValidatePassword(user, password)
}
func (us *UserService) UpdatePassword(userID uint, newPassword string) error {
return database.DB.Model(&models.User{}).Where("id = ?", userID).Update("password", us.hashPassword(newPassword)).Error
}
+33
View File
@@ -0,0 +1,33 @@
package static
import (
"embed"
"io/fs"
"net/http"
)
//go:embed dist/*
var distFS embed.FS
// GetFileSystem 返回嵌入的静态文件系统
func GetFileSystem() http.FileSystem {
subFS, err := fs.Sub(distFS, "dist")
if err != nil {
panic(err)
}
return http.FS(subFS)
}
// GetFS 返回嵌入的 fs.FS
func GetFS() fs.FS {
subFS, err := fs.Sub(distFS, "dist")
if err != nil {
panic(err)
}
return subFS
}
// ReadFile 读取嵌入的文件
func ReadFile(name string) ([]byte, error) {
return distFS.ReadFile("dist/" + name)
}
+125
View File
@@ -0,0 +1,125 @@
package utils
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"io"
"os"
"path/filepath"
"strings"
)
func ExtractZip(src, dest string) error {
r, err := zip.OpenReader(src)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
fpath := filepath.Join(dest, f.Name)
// 安全检查:防止路径遍历
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
continue
}
if f.FileInfo().IsDir() {
os.MkdirAll(fpath, 0755)
continue
}
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil {
return err
}
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
outFile.Close()
return err
}
_, err = io.Copy(outFile, rc)
outFile.Close()
rc.Close()
if err != nil {
return err
}
}
return nil
}
func ExtractTar(src, dest string) error {
file, err := os.Open(src)
if err != nil {
return err
}
defer file.Close()
return extractTarReader(tar.NewReader(file), dest)
}
func ExtractTarGz(src, dest string) error {
file, err := os.Open(src)
if err != nil {
return err
}
defer file.Close()
gzr, err := gzip.NewReader(file)
if err != nil {
return err
}
defer gzr.Close()
return extractTarReader(tar.NewReader(gzr), dest)
}
func extractTarReader(tr *tar.Reader, dest string) error {
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
fpath := filepath.Join(dest, header.Name)
// 安全检查:防止路径遍历
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
continue
}
switch header.Typeflag {
case tar.TypeDir:
os.MkdirAll(fpath, 0755)
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil {
return err
}
outFile, err := os.Create(fpath)
if err != nil {
return err
}
if _, err := io.Copy(outFile, tr); err != nil {
outFile.Close()
return err
}
outFile.Close()
os.Chmod(fpath, os.FileMode(header.Mode))
}
}
return nil
}
+39
View File
@@ -0,0 +1,39 @@
package utils
import (
"bytes"
"compress/gzip"
"encoding/base64"
"io"
)
// CompressToBase64 compresses data using gzip and encodes to base64
func CompressToBase64(data string) (string, error) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write([]byte(data)); err != nil {
return "", err
}
if err := gz.Close(); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
// DecompressFromBase64 decodes base64 and decompresses gzip data
func DecompressFromBase64(data string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return "", err
}
gz, err := gzip.NewReader(bytes.NewReader(decoded))
if err != nil {
return "", err
}
defer gz.Close()
result, err := io.ReadAll(gz)
if err != nil {
return "", err
}
return string(result), nil
}
+45
View File
@@ -0,0 +1,45 @@
package utils
import (
"strconv"
"baihu/internal/constant"
"github.com/gin-gonic/gin"
)
// Pagination 分页参数
type Pagination struct {
Page int
PageSize int
}
// ParsePagination 从请求中解析分页参数
func ParsePagination(c *gin.Context) Pagination {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", strconv.Itoa(constant.DefaultPageSize)))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = constant.DefaultPageSize
}
return Pagination{Page: page, PageSize: pageSize}
}
// Offset 计算偏移量
func (p Pagination) Offset() int {
return (p.Page - 1) * p.PageSize
}
// PaginatedResponse 分页响应
func PaginatedResponse(c *gin.Context, data interface{}, total int64, p Pagination) {
Success(c, gin.H{
"data": data,
"total": total,
"page": p.Page,
"page_size": p.PageSize,
})
}
+55
View File
@@ -0,0 +1,55 @@
package utils
import (
"net/http"
"github.com/gin-gonic/gin"
)
type Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data,omitempty"`
}
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: 200,
Msg: "success",
Data: data,
})
}
func SuccessMsg(c *gin.Context, msg string) {
c.JSON(http.StatusOK, Response{
Code: 200,
Msg: msg,
})
}
func Error(c *gin.Context, code int, msg string) {
c.JSON(http.StatusOK, Response{
Code: code,
Msg: msg,
})
}
func BadRequest(c *gin.Context, msg string) {
Error(c, 400, msg)
}
func Unauthorized(c *gin.Context, msg string) {
Error(c, 401, msg)
}
func Forbidden(c *gin.Context, msg string) {
Error(c, 403, msg)
}
func NotFound(c *gin.Context, msg string) {
Error(c, 404, msg)
}
func ServerError(c *gin.Context, msg string) {
Error(c, 500, msg)
}
+54
View File
@@ -0,0 +1,54 @@
package utils
import (
"os"
"os/exec"
"runtime"
)
// GetShell 返回当前操作系统的 shell 和参数
func GetShell() (shell string, args []string) {
if runtime.GOOS == "windows" {
return "cmd", []string{}
}
// 优先使用环境变量中的 SHELL
if envShell := os.Getenv("SHELL"); envShell != "" {
return envShell, []string{}
}
// macOS 默认使用 zsh
if runtime.GOOS == "darwin" {
if _, err := exec.LookPath("/bin/zsh"); err == nil {
return "/bin/zsh", []string{}
}
}
// Linux 默认使用 bash
return "/bin/bash", []string{}
}
// GetShellCommand 返回执行命令的 shell 和参数
func GetShellCommand(command string) (shell string, args []string) {
shell, _ = GetShell()
if runtime.GOOS == "windows" {
return shell, []string{"/c", command}
}
return shell, []string{"-c", command}
}
// NewShellCmd 创建一个交互式 shell 命令
func NewShellCmd() *exec.Cmd {
shell, _ := GetShell()
if runtime.GOOS == "windows" {
return exec.Command(shell)
}
// Unix 系统使用 -i 启用交互模式
return exec.Command(shell, "-i")
}
// NewShellCommandCmd 创建一个执行指定命令的 shell 命令
func NewShellCommandCmd(command string) *exec.Cmd {
shell, args := GetShellCommand(command)
return exec.Command(shell, args...)
}
+48
View File
@@ -0,0 +1,48 @@
package utils
import (
"errors"
"time"
"baihu/internal/constant"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
jwt.RegisteredClaims
}
// GenerateToken 生成 JWT token
func GenerateToken(userID uint, username string) (string, error) {
claims := Claims{
UserID: userID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(constant.TokenExpireDays) * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(constant.JWTSecret))
}
// ParseToken 解析 JWT token
func ParseToken(tokenString string) (uint, string, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
return []byte(constant.JWTSecret), nil
})
if err != nil {
return 0, "", err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims.UserID, claims.Username, nil
}
return 0, "", errors.New("invalid token")
}
+7
View File
@@ -0,0 +1,7 @@
package main
import "baihu/internal/bootstrap"
func main() {
bootstrap.New().Run()
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
# logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/assets/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"registries": {}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>白虎面板</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2790
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "web-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@guolao/vue-monaco-editor": "^1.6.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jinrishici": "^1.0.6",
"lucide-react": "^0.562.0",
"lucide-vue-next": "^0.562.0",
"pako": "^2.1.0",
"radix-vue": "^1.9.17",
"reka-ui": "^2.6.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vaul-vue": "^0.4.1",
"vue": "^3.5.24",
"vue-router": "^4.6.4",
"vue-sonner": "^2.0.9",
"xterm": "^5.3.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@types/pako": "^2.0.4",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}
+1
View File
@@ -0,0 +1 @@
<svg t="1766107903919" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1942" width="200" height="200"><path d="M884.992 273.05984c4.10624 0 5.0688-2.36544 2.10944-5.25312 0 0-64.28672-65.55648-111.7696-75.55072-47.48288-9.984-45.47584-59.37152-80.56832-75.02848-72.0896-32.16384-158.6176-34.3552-158.6176-34.3552s-91.37152-4.92544-138.752-9.89184c-19.46624-2.03776-54.46656-9.58464-54.46656-9.58464-4.0448-0.84992-10.63936-1.19808-14.66368-0.44032 0 0-30.63808-0.07168-44.30848 43.35616-8.8576 28.11904 1.792 104.79616 1.792 104.79616 1.46432 12.27776-3.42016 30.21824-10.72128 40.20224 0 0-36.77184 46.03904-58.9312 100.34176-22.15936 54.30272 118.15936 145.05984 208.98816 205.27104C507.82208 613.89824 502.03648 743.424 502.03648 743.424s-74.19904-96.75776-194.00704-156.9792C188.2112 526.22336 150.30272 442.23488 150.30272 442.23488c-2.89792-5.45792-5.94944-4.94592-6.71744 1.21856 0 0-15.1552 91.61728 16.25088 147.8144 70.92224 126.88384 141.74208 112.88576 197.03808 183.27552s54.272 164.9152 54.272 164.9152-97.62816-141.29152-235.66336-205.55776c-91.53536-61.27616-74.7008-125.91104-85.49376-101.85728-10.79296 24.05376 26.73664 192.41984 65.40288 222.2592 80.57856 62.18752 94.16704 101.98016 94.16704 101.98016h175.53408S544.512 814.85824 572.928 725.73952c44.41088-139.30496 40.20224-191.26272 40.20224-191.26272 0.08192-8.22272 6.81984-14.19264 14.98112-13.29152 0 0 46.45888 4.64896 66.64192 9.68704 23.53152 5.86752 55.35744 26.20416 55.35744 26.20416 3.49184 2.14016 7.39328 0.68608 8.69376-3.1744l34.4576-102.77888c1.30048-3.8912-0.63488-5.51936-4.352-3.75808 0 0-45.37344 25.09824-88.17664 12.1856-20.15232-6.08256-59.60704-14.82752-74.69056-32.6656-16.95744-20.03968-15.59552-71.3728 26.66496-79.21664 48.0256-8.9088 33.13664 15.14496 65.91488 24.64768 27.42272 7.95648 22.29248-1.69984 26.69568 5.21216 1.67936 2.63168 0.38912 32.65536 0.38912 32.65536-0.21504 6.144 3.39968 7.84384 8.0384 3.79904l41.89184-36.46464c17.37728-11.24352 30.86336-0.57344 48.24064-11.81696 14.73536-9.53344 29.58336-43.66336 29.58336-43.66336 4.48512-9.24672 0.60416-20.41856-8.63232-24.92416l-42.58816-20.80768c-3.69664-1.80224-3.38944-3.26656 0.74752-3.26656h62.0032zM422.54336 123.87328s-49.88928 50.7392-74.5472 50.66752c-24.65792-0.07168-33.24928-56.12544-3.92192-56.12544 18.00192 0 76.32896 0.21504 76.32896 0.21504 4.13696 0.02048 5.0688 2.36544 2.14016 5.24288z m123.09504 249.64096s-3.31776-25.53856-33.16736-40.05888c-29.8496-14.52032-52.5312-41.13408-59.648-68.95616-12.1856-54.8864 48.29184-104.192 48.29184-104.192s-30.1056 73.6768 3.5328 106.60864c54.272 53.12512 40.99072 106.5984 40.99072 106.5984z m155.56608-164.46464c-7.94624 10.32192-9.60512 37.92896-59.2384 20.736-49.63328-17.2032-71.00416-54.5792-71.00416-54.5792-2.27328-3.40992-0.79872-6.49216 3.328-6.8096 0 0 43.55072-5.03808 80.06656 6.44096 24.91392 7.82336 54.79424 23.88992 46.848 34.21184z" fill="#272636" p-id="1943"></path><path d="M366.30528 259.26656c1.05472-1.76128 1.51552-1.57696 1.19808 0.43008 0 0-7.55712 19.0464 22.8864 87.63392 27.0848 61.02016 87.49056 68.7104 118.66112 98.23232 55.808 52.82816 53.52448 123.45344 53.52448 123.45344s-25.82528-49.85856-85.98528-78.83776-92.6208-50.52416-127.7952-85.77024c-45.02528-45.12768 17.5104-145.14176 17.5104-145.14176zM500.0704 961.44384h134.49216s95.8464-138.07616 46.68416-291.25632c-16.19968-50.46272-43.45856-80.00512-43.45856-80.00512-5.18144-6.38976-8.89856-4.88448-8.448 3.34848 0 0 9.15456 99.80928-19.44576 194.00704-28.60032 94.208-109.824 173.90592-109.824 173.90592zM681.61536 956.8768h105.24672s22.38464-73.5232 16.61952-130.21184c-9.30816-91.57632-48.31232-132.72064-48.31232-132.72064-3.80928-4.77184-6.0928-3.67616-5.2224 2.38592 0 0 16.75264 89.1904-14.4896 150.1184-30.9248 60.30336-53.84192 110.42816-53.84192 110.42816zM869.30432 811.35616c-2.88768-2.9184-4.80256-1.95584-4.38272 2.10944 0 0 6.79936 44.99456-7.76192 81.22368-10.12736 25.1904-28.91776 58.60352-28.91776 58.60352h107.17184s5.34528-50.31936-19.89632-83.97824c-11.56096-23.99232-46.21312-57.9584-46.21312-57.9584z" fill="#272636" p-id="1944"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
</script>
<template>
<RouterView />
<Toaster position="bottom-right" :duration="2000" />
</template>
+254
View File
@@ -0,0 +1,254 @@
const BASE_URL = '/api'
interface ApiResponse<T> {
code: number
msg: string
data: T
}
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${url}`, {
...options,
credentials: 'include', // 携带 Cookie
headers: {
'Content-Type': 'application/json',
...options?.headers
}
})
const json: ApiResponse<T> = await res.json()
if (json.code === 401) {
// 未登录或登录过期,跳转到登录页
window.location.href = '/login'
throw new Error(json.msg || '请先登录')
}
if (json.code !== 200) {
throw new Error(json.msg || '请求失败')
}
return json.data
}
// 检查登录状态(不触发自动跳转)
export async function checkAuth(): Promise<boolean> {
try {
const res = await fetch(`${BASE_URL}/auth/me`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json' }
})
const json: ApiResponse<{ username: string }> = await res.json()
return json.code === 200
} catch {
return false
}
}
export const api = {
auth: {
login: (data: { username: string; password: string }) =>
request<{ user: string }>('/auth/login', { method: 'POST', body: JSON.stringify(data) }),
logout: () => request('/auth/logout', { method: 'POST' }),
me: () => request<{ username: string }>('/auth/me'),
register: (data: { username: string; password: string; email: string }) =>
request('/auth/register', { method: 'POST', body: JSON.stringify(data) })
},
tasks: {
list: (params?: { page?: number; page_size?: number; name?: string }) => {
const query = new URLSearchParams()
if (params?.page) query.set('page', String(params.page))
if (params?.page_size) query.set('page_size', String(params.page_size))
if (params?.name) query.set('name', params.name)
return request<TaskListResponse>(`/tasks?${query}`)
},
create: (data: Partial<Task>) => request<Task>('/tasks', { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Partial<Task>) => request<Task>(`/tasks/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: number) => request(`/tasks/${id}`, { method: 'DELETE' }),
execute: (id: number) => request(`/execute/task/${id}`, { method: 'POST' })
},
scripts: {
list: () => request<Script[]>('/scripts'),
create: (data: Partial<Script>) => request<Script>('/scripts', { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Partial<Script>) => request<Script>(`/scripts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: number) => request(`/scripts/${id}`, { method: 'DELETE' })
},
env: {
list: (params?: { page?: number; page_size?: number; name?: string }) => {
const query = new URLSearchParams()
if (params?.page) query.set('page', String(params.page))
if (params?.page_size) query.set('page_size', String(params.page_size))
if (params?.name) query.set('name', params.name)
return request<EnvListResponse>(`/env?${query}`)
},
create: (data: Partial<EnvVar>) => request<EnvVar>('/env', { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Partial<EnvVar>) => request<EnvVar>(`/env/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: number) => request(`/env/${id}`, { method: 'DELETE' })
},
execute: {
command: (command: string) => request('/execute/command', { method: 'POST', body: JSON.stringify({ command }) }),
results: () => request('/execute/results')
},
logs: {
list: (params?: { page?: number; page_size?: number; task_id?: number; task_name?: string }) => {
const query = new URLSearchParams()
if (params?.page) query.set('page', String(params.page))
if (params?.page_size) query.set('page_size', String(params.page_size))
if (params?.task_id) query.set('task_id', String(params.task_id))
if (params?.task_name) query.set('task_name', params.task_name)
return request<LogListResponse>(`/logs?${query}`)
},
detail: (id: number) => request<LogDetail>(`/logs/${id}`)
},
dashboard: {
stats: () => request<Stats>('/stats')
},
settings: {
changePassword: (data: { old_password: string; new_password: string }) =>
request('/settings/password', { method: 'POST', body: JSON.stringify(data) }),
cleanLogs: (days: number) =>
request<{ deleted: number }>('/settings/clean-logs', { method: 'POST', body: JSON.stringify({ days }) }),
getSite: () => request<{ site_name: string; port: number }>('/settings/site'),
getAbout: () => request<AboutInfo>('/settings/about')
},
files: {
tree: () => request<FileNode[]>('/files/tree'),
getContent: (path: string) => request<{ path: string; content: string }>(`/files/content?path=${encodeURIComponent(path)}`),
saveContent: (path: string, content: string) => request('/files/content', { method: 'POST', body: JSON.stringify({ path, content }) }),
create: (path: string, isDir: boolean) => request('/files/create', { method: 'POST', body: JSON.stringify({ path, isDir }) }),
delete: (path: string) => request('/files/delete', { method: 'POST', body: JSON.stringify({ path }) }),
rename: (oldPath: string, newPath: string) => request('/files/rename', { method: 'POST', body: JSON.stringify({ oldPath, newPath }) }),
uploadArchive: async (file: File, targetPath?: string) => {
const formData = new FormData()
formData.append('file', file)
if (targetPath) formData.append('path', targetPath)
const res = await fetch(`${BASE_URL}/files/upload`, {
method: 'POST',
credentials: 'include',
body: formData
})
const json: ApiResponse<null> = await res.json()
if (json.code === 401) {
window.location.href = '/login'
throw new Error('请先登录')
}
if (json.code !== 200) throw new Error(json.msg || '上传失败')
},
uploadFiles: async (files: FileList, paths: string[], targetPath?: string) => {
const formData = new FormData()
for (let i = 0; i < files.length; i++) {
const file = files[i]
if (file) {
formData.append('files', file)
formData.append('paths', paths[i] || file.name)
}
}
if (targetPath) formData.append('path', targetPath)
const res = await fetch(`${BASE_URL}/files/upload-files`, {
method: 'POST',
credentials: 'include',
body: formData
})
const json: ApiResponse<null> = await res.json()
if (json.code === 401) {
window.location.href = '/login'
throw new Error('请先登录')
}
if (json.code !== 200) throw new Error(json.msg || '上传失败')
}
}
}
export interface FileNode {
name: string
path: string
isDir: boolean
children?: FileNode[]
}
export interface Task {
id: number
name: string
command: string
schedule: string
timeout: number
enabled: boolean
last_run: string
next_run: string
}
export interface TaskListResponse {
data: Task[]
total: number
page: number
page_size: number
}
export interface Script {
id: number
name: string
content: string
}
export interface EnvVar {
id: number
name: string
value: string
remark: string
}
export interface EnvListResponse {
data: EnvVar[]
total: number
page: number
page_size: number
}
export interface Stats {
tasks: number
scripts: number
envs: number
logs: number
scheduled: number
running: number
}
export interface TaskLog {
id: number
task_id: number
task_name: string
command: string
status: string
duration: number
created_at: string
}
export interface LogListResponse {
data: TaskLog[]
total: number
page: number
page_size: number
}
export interface LogDetail {
id: number
task_id: number
command: string
output: string
status: string
duration: number
created_at: string
}
export interface AboutInfo {
version: string
build_time: string
mem_usage: string
uptime: string
task_count: number
log_count: number
env_count: number
}
+145
View File
@@ -0,0 +1,145 @@
@import "tailwindcss";
@import "tw-animate-css";
/* Sonner toast styles */
[data-sonner-toaster] {
--width: 356px;
--border-radius: var(--radius);
font-family: inherit;
z-index: 9999 !important;
}
[data-sonner-toast] {
background: var(--popover) !important;
color: var(--popover-foreground) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.22 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.26 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.26 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.32 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.32 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.32 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.26 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.32 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 12%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground antialiased;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Folder, File, ChevronRight, ChevronDown, Trash2, Plus } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import type { FileNode } from '@/api'
const props = defineProps<{
node: FileNode
expandedDirs: Set<string>
selectedPath: string | null
depth?: number
}>()
const emit = defineEmits<{
select: [node: FileNode]
delete: [path: string]
create: [parentDir: string]
move: [oldPath: string, newPath: string]
}>()
const depth = computed(() => props.depth ?? 0)
const isExpanded = computed(() => props.expandedDirs.has(props.node.path))
const isSelected = computed(() => props.selectedPath === props.node.path)
const isDragOver = ref(false)
function handleSelect() {
emit('select', props.node)
}
function handleDelete(e: Event) {
e.stopPropagation()
emit('delete', props.node.path)
}
function handleCreate(e: Event) {
e.stopPropagation()
emit('create', props.node.path)
}
function handleDragStart(e: DragEvent) {
e.dataTransfer?.setData('text/plain', props.node.path)
e.dataTransfer!.effectAllowed = 'move'
}
function handleDragOver(e: DragEvent) {
if (!props.node.isDir) return
e.preventDefault()
e.dataTransfer!.dropEffect = 'move'
isDragOver.value = true
}
function handleDragLeave() {
isDragOver.value = false
}
function handleDrop(e: DragEvent) {
e.preventDefault()
isDragOver.value = false
if (!props.node.isDir) return
const sourcePath = e.dataTransfer?.getData('text/plain')
if (!sourcePath || sourcePath === props.node.path) return
// 不能移动到自己的子目录
if (props.node.path.startsWith(sourcePath + '/')) return
const fileName = sourcePath.split('/').pop()
const newPath = props.node.path ? `${props.node.path}/${fileName}` : fileName
if (newPath !== sourcePath) {
emit('move', sourcePath, newPath!)
}
}
</script>
<template>
<div>
<div
:class="[
'flex items-center gap-1 py-0.5 px-1 rounded cursor-pointer text-xs hover:bg-muted group',
isSelected && 'bg-accent',
isDragOver && 'bg-blue-500/20 ring-1 ring-blue-500'
]"
:style="{ paddingLeft: depth * 12 + 4 + 'px' }"
draggable="true"
@click="handleSelect"
@dragstart="handleDragStart"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<template v-if="node.isDir">
<ChevronDown v-if="isExpanded" class="h-3 w-3 flex-shrink-0" />
<ChevronRight v-else class="h-3 w-3 flex-shrink-0" />
</template>
<span v-else class="w-3" />
<Folder v-if="node.isDir" class="h-3 w-3 text-yellow-500 flex-shrink-0" />
<File v-else class="h-3 w-3 text-blue-500 flex-shrink-0" />
<span class="truncate flex-1">{{ node.name }}</span>
<Button v-if="node.isDir" variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" @click="handleCreate">
<Plus class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" @click="handleDelete">
<Trash2 class="h-3 w-3 text-destructive" />
</Button>
</div>
<template v-if="node.isDir && isExpanded && node.children">
<FileTreeNode
v-for="child in node.children"
:key="child.path"
:node="child"
:expanded-dirs="expandedDirs"
:selected-path="selectedPath"
:depth="depth + 1"
@select="emit('select', $event)"
@delete="emit('delete', $event)"
@create="emit('create', $event)"
@move="(oldPath, newPath) => emit('move', oldPath, newPath)"
/>
</template>
</div>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
const props = defineProps<{
total: number
page: number
pageSize: number
}>()
const emit = defineEmits<{
'update:page': [page: number]
}>()
const totalPages = computed(() => Math.ceil(props.total / props.pageSize) || 1)
function prevPage() {
if (props.page > 1) {
emit('update:page', props.page - 1)
}
}
function nextPage() {
if (props.page < totalPages.value) {
emit('update:page', props.page + 1)
}
}
</script>
<template>
<div v-if="total > pageSize" class="flex items-center justify-between px-3 py-2 border-t text-xs text-muted-foreground">
<span> {{ total }} </span>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="page <= 1" @click="prevPage">
<ChevronLeft class="h-3 w-3" />
</Button>
<span class="px-2">{{ page }} / {{ totalPages }}</span>
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="page >= totalPages" @click="nextPage">
<ChevronRight class="h-3 w-3" />
</Button>
</div>
</div>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Sun, Moon, Monitor } from 'lucide-vue-next'
import { useTheme } from '@/composables/useTheme'
const { theme, setTheme } = useTheme()
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-8 w-8">
<Sun v-if="theme === 'light'" class="h-4 w-4" />
<Moon v-else-if="theme === 'dark'" class="h-4 w-4" />
<Monitor v-else class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="setTheme('light')" class="gap-2">
<Sun class="h-4 w-4" />
<span>浅色</span>
</DropdownMenuItem>
<DropdownMenuItem @click="setTheme('dark')" class="gap-2">
<Moon class="h-4 w-4" />
<span>深色</span>
</DropdownMenuItem>
<DropdownMenuItem @click="setTheme('system')" class="gap-2">
<Monitor class="h-4 w-4" />
<span>跟随系统</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
<slot v-bind="slotProps" />
</AlertDialogRoot>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { AlertDialogActionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogAction } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { AlertDialogCancelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogCancel } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
props.class,
)"
>
<slot />
</AlertDialogCancel>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
data-slot="alert-dialog-overlay"
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
/>
<AlertDialogContent
data-slot="alert-dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { AlertDialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
AlertDialogDescription,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogDescription
data-slot="alert-dialog-description"
v-bind="delegatedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</AlertDialogDescription>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-dialog-footer"
:class="
cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { AlertDialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogTitle } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogTitle
data-slot="alert-dialog-title"
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { AlertDialogTriggerProps } from "reka-ui"
import { AlertDialogTrigger } from "reka-ui"
const props = defineProps<AlertDialogTriggerProps>()
</script>
<template>
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
<slot />
</AlertDialogTrigger>
</template>
@@ -0,0 +1,9 @@
export { default as AlertDialog } from "./AlertDialog.vue"
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { BadgeVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { badgeVariants } from "."
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>
+26
View File
@@ -0,0 +1,26 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Badge } from "./Badge.vue"
export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
})
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+38
View File
@@ -0,0 +1,38 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Button } from "./Button.vue"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon": "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-content"
:class="cn('px-6', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<p
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-footer"
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-header"
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)"
>
<slot />
</h3>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Card } from "./Card.vue"
export { default as CardAction } from "./CardAction.vue"
export { default as CardContent } from "./CardContent.vue"
export { default as CardDescription } from "./CardDescription.vue"
export { default as CardFooter } from "./CardFooter.vue"
export { default as CardHeader } from "./CardHeader.vue"
export { default as CardTitle } from "./CardTitle.vue"
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
v-slot="slotProps"
data-slot="dialog"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DialogRoot>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogCloseProps } from "reka-ui"
import { DialogClose } from "reka-ui"
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="dialog-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import DialogOverlay from "./DialogOverlay.vue"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogDescription, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</DialogDescription>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div
data-slot="dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
>
<slot />
</DialogOverlay>
</template>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-lg leading-none font-semibold', props.class)"
>
<slot />
</DialogTitle>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="dialog-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>
+10
View File
@@ -0,0 +1,10 @@
export { default as Dialog } from "./Dialog.vue"
export { default as DialogClose } from "./DialogClose.vue"
export { default as DialogContent } from "./DialogContent.vue"
export { default as DialogDescription } from "./DialogDescription.vue"
export { default as DialogFooter } from "./DialogFooter.vue"
export { default as DialogHeader } from "./DialogHeader.vue"
export { default as DialogOverlay } from "./DialogOverlay.vue"
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
export { default as DialogTitle } from "./DialogTitle.vue"
export { default as DialogTrigger } from "./DialogTrigger.vue"

Some files were not shown because too many files have changed in this diff Show More