first commit

This commit is contained in:
2026-02-16 00:13:37 +08:00
commit 74f15c282e
44 changed files with 8708 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Node/React
node_modules/
build/
coverage/
.env.local
.env.development.local
.env.test.local
.env.production.local
# Go
*.exe
*.test
*.out
*.dll
*.so
*.dylib
/tmp/
# 数据文件
data/data.json
# 日志
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# 操作系统
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# 其他
.env
dist/

View File

@@ -0,0 +1,47 @@
# Git 相关
.git
.gitignore
.gitattributes
# 编辑器和 IDE
.vscode
.idea
*.swp
*.swo
*~
# 操作系统文件
.DS_Store
Thumbs.db
# 数据文件(运行时生成)
data/*.json
# 日志文件
*.log
# 临时文件
tmp/
temp/
# 文档
README.md
LICENSE
*.md
# Docker 相关
Dockerfile
.dockerignore
docker-compose.yml
# 测试文件
*_test.go
test/
tests/
# 构建产物
*.exe
*.exe~
*.dll
*.so
*.dylib

View File

@@ -0,0 +1,56 @@
# 多阶段构建 - 使用官方 Golang 镜像作为构建环境
FROM golang:1.25-alpine AS builder
# 设置工作目录
WORKDIR /app
# 安装必要的构建工具
RUN apk add --no-cache git ca-certificates tzdata
# 复制 go.mod 和 go.sum 文件
COPY go.mod go.sum ./
# 下载依赖
RUN go mod download
# 复制源代码
COPY . .
# 构建应用程序
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o mengyaping-backend .
# 使用轻量级的 alpine 镜像作为运行环境
FROM alpine:latest
# 安装必要的运行时依赖
RUN apk --no-cache add ca-certificates tzdata
# 设置时区为上海
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
# 创建非 root 用户
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser
# 设置工作目录
WORKDIR /app
# 从构建阶段复制编译好的二进制文件
COPY --from=builder /app/mengyaping-backend .
# 创建数据目录
RUN mkdir -p /app/data && chown -R appuser:appuser /app
# 切换到非 root 用户
USER appuser
# 暴露端口
EXPOSE 8080
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1
# 运行应用程序
CMD ["./mengyaping-backend"]

View File

@@ -0,0 +1,176 @@
package config
import (
"encoding/json"
"fmt"
"os"
"sync"
"time"
)
// Config 应用配置
type Config struct {
Server ServerConfig `json:"server"`
Monitor MonitorConfig `json:"monitor"`
DataPath string `json:"data_path"`
}
// ServerConfig 服务器配置
type ServerConfig struct {
Port string `json:"port"`
Host string `json:"host"`
}
// MonitorConfig 监控配置
type MonitorConfig struct {
Interval time.Duration `json:"interval"` // 检测间隔
Timeout time.Duration `json:"timeout"` // 请求超时时间
RetryCount int `json:"retry_count"` // 重试次数
HistoryDays int `json:"history_days"` // 保留历史天数
}
var (
cfg *Config
once sync.Once
)
// GetConfig 获取配置单例
func GetConfig() *Config {
once.Do(func() {
cfg = &Config{
Server: ServerConfig{
Port: getEnv("SERVER_PORT", "8080"),
Host: getEnv("SERVER_HOST", "0.0.0.0"),
},
Monitor: MonitorConfig{
Interval: parseDuration(getEnv("MONITOR_INTERVAL", "5m"), 5*time.Minute),
Timeout: parseDuration(getEnv("MONITOR_TIMEOUT", "10s"), 10*time.Second),
RetryCount: parseInt(getEnv("MONITOR_RETRY_COUNT", "3"), 3),
HistoryDays: parseInt(getEnv("MONITOR_HISTORY_DAYS", "7"), 7),
},
DataPath: getEnv("DATA_PATH", "./data"),
}
// 尝试从配置文件加载(会覆盖环境变量配置)
loadConfigFromFile()
})
return cfg
}
// getEnv 获取环境变量,如果不存在则返回默认值
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// parseInt 解析整数环境变量
func parseInt(value string, defaultValue int) int {
if value == "" {
return defaultValue
}
var result int
if _, err := fmt.Sscanf(value, "%d", &result); err != nil {
return defaultValue
}
return result
}
// parseDuration 解析时间间隔环境变量
func parseDuration(value string, defaultValue time.Duration) time.Duration {
if value == "" {
return defaultValue
}
if duration, err := time.ParseDuration(value); err == nil {
return duration
}
return defaultValue
}
// loadConfigFromFile 从文件加载配置
func loadConfigFromFile() {
configFile := "./data/config.json"
if _, err := os.Stat(configFile); os.IsNotExist(err) {
return
}
data, err := os.ReadFile(configFile)
if err != nil {
return
}
var fileCfg struct {
Server ServerConfig `json:"server"`
Monitor struct {
IntervalMinutes int `json:"interval_minutes"`
TimeoutSeconds int `json:"timeout_seconds"`
RetryCount int `json:"retry_count"`
HistoryDays int `json:"history_days"`
} `json:"monitor"`
DataPath string `json:"data_path"`
}
if err := json.Unmarshal(data, &fileCfg); err != nil {
return
}
if fileCfg.Server.Port != "" {
cfg.Server.Port = fileCfg.Server.Port
}
if fileCfg.Server.Host != "" {
cfg.Server.Host = fileCfg.Server.Host
}
if fileCfg.Monitor.IntervalMinutes > 0 {
cfg.Monitor.Interval = time.Duration(fileCfg.Monitor.IntervalMinutes) * time.Minute
}
if fileCfg.Monitor.TimeoutSeconds > 0 {
cfg.Monitor.Timeout = time.Duration(fileCfg.Monitor.TimeoutSeconds) * time.Second
}
if fileCfg.Monitor.RetryCount > 0 {
cfg.Monitor.RetryCount = fileCfg.Monitor.RetryCount
}
if fileCfg.Monitor.HistoryDays > 0 {
cfg.Monitor.HistoryDays = fileCfg.Monitor.HistoryDays
}
if fileCfg.DataPath != "" {
cfg.DataPath = fileCfg.DataPath
}
}
// SaveConfig 保存配置到文件
func SaveConfig() error {
configFile := cfg.DataPath + "/config.json"
fileCfg := struct {
Server ServerConfig `json:"server"`
Monitor struct {
IntervalMinutes int `json:"interval_minutes"`
TimeoutSeconds int `json:"timeout_seconds"`
RetryCount int `json:"retry_count"`
HistoryDays int `json:"history_days"`
} `json:"monitor"`
DataPath string `json:"data_path"`
}{
Server: cfg.Server,
Monitor: struct {
IntervalMinutes int `json:"interval_minutes"`
TimeoutSeconds int `json:"timeout_seconds"`
RetryCount int `json:"retry_count"`
HistoryDays int `json:"history_days"`
}{
IntervalMinutes: int(cfg.Monitor.Interval.Minutes()),
TimeoutSeconds: int(cfg.Monitor.Timeout.Seconds()),
RetryCount: cfg.Monitor.RetryCount,
HistoryDays: cfg.Monitor.HistoryDays,
},
DataPath: cfg.DataPath,
}
data, err := json.MarshalIndent(fileCfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(configFile, data, 0644)
}

View File

@@ -0,0 +1,13 @@
{
"server": {
"port": "8080",
"host": "0.0.0.0"
},
"monitor": {
"interval_minutes": 5,
"timeout_seconds": 10,
"retry_count": 3,
"history_days": 7
},
"data_path": "./data"
}

View File

@@ -0,0 +1,18 @@
[
{
"id": "self-made",
"name": "自制网站"
},
{
"id": "self-deploy",
"name": "自部署网站"
},
{
"id": "admin",
"name": "管理员网站"
},
{
"id": "api",
"name": "API网站"
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,626 @@
[
{
"id": "188cb48c619bd5d896bff546",
"name": "萌芽主页",
"group": "self-made",
"urls": [
{
"id": "d19b0a475334",
"url": "https://shumengya.top",
"remark": ""
}
],
"favicon": "https://shumengya.top/favicon.ico",
"title": "萌芽主页",
"created_at": "2026-01-21T17:21:08.6757862+08:00",
"updated_at": "2026-01-27T13:02:31.4021667+08:00"
},
{
"id": "188cb49298d72b78a37301a1",
"name": "萌芽备忘录",
"group": "self-deploy",
"urls": [
{
"id": "9d25ae024f36",
"url": "https://memos.shumengya.top",
"remark": ""
}
],
"favicon": "https://memos.shumengya.top/logo.webp",
"title": "Memos",
"created_at": "2026-01-21T17:21:35.3722254+08:00",
"updated_at": "2026-01-27T13:02:37.9135994+08:00"
},
{
"id": "188cb52c04cf639cfc2c8ba4",
"name": "萌芽盘",
"group": "self-deploy",
"urls": [
{
"id": "cfe6229fb86b",
"url": "https://pan.shumengya.top",
"remark": ""
}
],
"favicon": "https://img.shumengya.top/i/2026/01/04/695a660870959.png",
"title": "萌芽盘",
"created_at": "2026-01-21T17:32:34.3136511+08:00",
"updated_at": "2026-01-27T13:02:42.907207+08:00"
},
{
"id": "188cb53623f46b7872bf17b6",
"name": "萌芽Git仓库",
"group": "self-deploy",
"urls": [
{
"id": "b53594e7285d",
"url": "https://git.shumengya.top",
"remark": ""
}
],
"favicon": "https://git.shumengya.top/assets/img/favicon.svg",
"title": "萌芽Git仓库",
"created_at": "2026-01-21T17:33:17.7858446+08:00",
"updated_at": "2026-01-27T13:03:13.0909987+08:00"
},
{
"id": "188cb54b93ddd1e487bf347e",
"name": "萌芽图床",
"group": "self-deploy",
"urls": [
{
"id": "46d822694242",
"url": "https://image.shumengya.top",
"remark": ""
}
],
"favicon": "https://image.shumengya.top/favicon.ico",
"title": "萌芽图床",
"created_at": "2026-01-21T17:34:49.8577249+08:00",
"updated_at": "2026-01-27T13:03:19.4761768+08:00"
},
{
"id": "188cb5507dcd2da0559bd6dd",
"name": "萌芽笔记",
"group": "self-made",
"urls": [
{
"id": "6d5125db9d81",
"url": "https://note.shumengya.top",
"remark": ""
}
],
"favicon": "https://note.shumengya.top/logo.png",
"title": "萌芽笔记",
"created_at": "2026-01-21T17:35:10.962372+08:00",
"updated_at": "2026-01-27T13:03:25.8837656+08:00"
},
{
"id": "188cb55693193b80d2383cc3",
"name": "萌芽作品集",
"group": "self-made",
"urls": [
{
"id": "74a3b6e1f50e",
"url": "https://work.shumengya.top",
"remark": ""
}
],
"favicon": "https://work.shumengya.top/favicon.ico",
"title": "502 Bad Gateway",
"created_at": "2026-01-21T17:35:37.0894816+08:00",
"updated_at": "2026-01-27T13:03:30.0659765+08:00"
},
{
"id": "188cb56d8df57dc8c66d42ee",
"name": "万象口袋",
"group": "self-made",
"urls": [
{
"id": "e8f8fd4cf9b9",
"url": "https://infogenie.shumengya.top",
"remark": ""
}
],
"favicon": "https://infogenie.shumengya.top/assets/logo.png",
"title": "万象口袋",
"created_at": "2026-01-21T17:37:15.787501+08:00",
"updated_at": "2026-01-27T13:03:34.8680468+08:00"
},
{
"id": "188cb574b122f71087e53ae1",
"name": "萌芽问卷",
"group": "self-deploy",
"urls": [
{
"id": "2f9ffdedd860",
"url": "https://survey.shumengya.top",
"remark": ""
}
],
"favicon": "https://survey.shumengya.top/favicon.ico",
"title": "502 Bad Gateway",
"created_at": "2026-01-21T17:37:46.4424548+08:00",
"updated_at": "2026-01-27T13:03:38.9707881+08:00"
},
{
"id": "188cb5a19b836e80d93df86a",
"name": "萌芽短链",
"group": "self-made",
"urls": [
{
"id": "8a4ebcb7eb03",
"url": "https://short.shumengya.top",
"remark": ""
}
],
"favicon": "https://short.shumengya.top/logo.png",
"title": "萌芽短链",
"created_at": "2026-01-21T17:40:59.3532064+08:00",
"updated_at": "2026-01-27T13:04:16.9980537+08:00"
},
{
"id": "188cb6da20586d34df88b086",
"name": "萌芽漂流瓶",
"group": "self-made",
"urls": [
{
"id": "e7e250eb8985",
"url": "https://bottle.shumengya.top",
"remark": ""
}
],
"favicon": "https://bottle.shumengya.top/logo.png",
"title": "萌芽漂流瓶(´,,•ω•,,)♡",
"created_at": "2026-01-21T18:03:21.6115541+08:00",
"updated_at": "2026-01-27T13:04:27.0426589+08:00"
},
{
"id": "188cb6ff716e2590408704ee",
"name": "编程速查表[CloudFlare]",
"group": "self-deploy",
"urls": [
{
"id": "85620784f1a3",
"url": "https://reference.smyhub.com",
"remark": ""
}
],
"favicon": "https://reference.smyhub.com/icons/favicon.svg",
"title": "Quick Reference\n \u0026#x26; Quick Reference",
"created_at": "2026-01-21T18:06:01.885722+08:00",
"updated_at": "2026-01-27T13:10:39.3541213+08:00"
},
{
"id": "188cb708e8cf52a49a436570",
"name": "思绪思维导图[CloudFlare]",
"group": "self-deploy",
"urls": [
{
"id": "dcc07e30a25a",
"url": "https://mind-map.smyhub.com",
"remark": ""
}
],
"favicon": "https://mind-map.smyhub.com/dist/logo.ico",
"title": "思绪思维导图",
"created_at": "2026-01-21T18:06:42.5432849+08:00",
"updated_at": "2026-01-27T13:10:46.0879149+08:00"
},
{
"id": "188cb71029ad43105a3dc6c0",
"name": "it-tools工具集[CloudFlare]",
"group": "self-deploy",
"urls": [
{
"id": "f278e65dd498",
"url": "https://it-tools.smyhub.com",
"remark": ""
}
],
"favicon": "https://it-tools.smyhub.com/favicon.ico",
"title": "IT Tools - Handy online tools for developers",
"created_at": "2026-01-21T18:07:13.6963428+08:00",
"updated_at": "2026-01-27T13:11:45.7629645+08:00"
},
{
"id": "188cb71b527c48bc3c344acc",
"name": "xtools工具集[CloudFlare]",
"group": "self-deploy",
"urls": [
{
"id": "13cdc2afab17",
"url": "https://xtools.smyhub.com",
"remark": ""
}
],
"favicon": "https://xtools.smyhub.com/favicon.ico",
"title": "百川云常用工具",
"created_at": "2026-01-21T18:08:01.6256391+08:00",
"updated_at": "2026-01-27T13:12:18.9167742+08:00"
},
{
"id": "188cb72555e8d5c4f0d7aa98",
"name": "萌芽监控面板",
"group": "admin",
"urls": [
{
"id": "421fe08ece5e",
"url": "http://monitor.shumengya.top",
"remark": ""
}
],
"favicon": "http://monitor.shumengya.top/logo.png",
"title": "萌芽监控面板",
"created_at": "2026-01-21T18:08:44.6327577+08:00",
"updated_at": "2026-01-23T22:58:04.325685258+08:00"
},
{
"id": "188cb73272be1c9c5a5d6812",
"name": "在线office",
"group": "self-deploy",
"urls": [
{
"id": "cba98c3c329b",
"url": "https://office.shumengya.top",
"remark": ""
}
],
"favicon": "https://office.shumengya.top/img/64.png",
"title": "502 Bad Gateway",
"created_at": "2026-01-21T18:09:40.9510719+08:00",
"updated_at": "2026-02-10T01:28:12.7674442+08:00"
},
{
"id": "188cb7372655f3841e82a210",
"name": "网页魔方[CloudFlare]",
"group": "self-deploy",
"urls": [
{
"id": "ed4c7dcd0565",
"url": "https://cube.smyhub.com",
"remark": ""
}
],
"favicon": "https://cube.smyhub.com/favicon.ico",
"title": "HTML5 3D魔方小游戏",
"created_at": "2026-01-21T18:10:01.1440137+08:00",
"updated_at": "2026-01-27T13:12:55.4496439+08:00"
},
{
"id": "188cb764a98dbb2453c14b8f",
"name": "60sAPI集合",
"group": "api",
"urls": [
{
"id": "2f0596587997",
"url": "https://60s.api.shumengya.top",
"remark": ""
}
],
"favicon": "https://60s.api.shumengya.top/favicon.ico",
"title": "502 Bad Gateway",
"created_at": "2026-01-21T18:13:16.6194034+08:00",
"updated_at": "2026-01-26T11:56:41.21983174+08:00"
},
{
"id": "188cb77f231fe68c73e24b29",
"name": "萌芽主页后端API",
"group": "api",
"urls": [
{
"id": "c182709178d9",
"url": "https://nav.api.shumengya.top",
"remark": ""
}
],
"favicon": "https://nav.api.shumengya.top/favicon.ico",
"title": "502 Bad Gateway",
"created_at": "2026-01-21T18:15:10.3277851+08:00",
"updated_at": "2026-01-26T11:56:41.237091652+08:00"
},
{
"id": "188cb789a76896788f139a96",
"name": "萌芽笔记后端API",
"group": "api",
"urls": [
{
"id": "2b42ed612dd3",
"url": "https://note.api.shumengya.top/api/tree",
"remark": ""
}
],
"favicon": "https://note.api.shumengya.top/favicon.ico",
"title": "502 Bad Gateway",
"created_at": "2026-01-21T18:15:55.4968142+08:00",
"updated_at": "2026-01-26T11:56:40.772396513+08:00"
},
{
"id": "188cb7934d5d413890dea61f",
"name": "萌芽作品集后端API",
"group": "api",
"urls": [
{
"id": "d841646c7b41",
"url": "https://work.api.shumengya.top",
"remark": ""
}
],
"favicon": "https://work.api.shumengya.top/favicon.ico",
"title": "502 Bad Gateway",
"created_at": "2026-01-21T18:16:36.935795+08:00",
"updated_at": "2026-01-26T11:56:40.714095868+08:00"
},
{
"id": "188cb79ed2aeb85cabcf6e66",
"name": "万象口袋后端API",
"group": "api",
"urls": [
{
"id": "7c8ff0e48be4",
"url": "https://infogenie.api.shumengya.top",
"remark": ""
}
],
"favicon": "https://infogenie.api.shumengya.top/favicon.ico",
"title": "502 Bad Gateway",
"created_at": "2026-01-21T18:17:26.4171439+08:00",
"updated_at": "2026-01-26T11:56:41.067849322+08:00"
},
{
"id": "188cb7b7b154ddbc1dc4b14d",
"name": "萌芽短链后端API",
"group": "api",
"urls": [
{
"id": "ef0443476dd8",
"url": "https://short.api.shumengya.top",
"remark": ""
}
],
"favicon": "https://short.api.shumengya.top/favicon.ico",
"title": "502 Bad Gateway",
"created_at": "2026-01-21T18:19:13.2317895+08:00",
"updated_at": "2026-01-26T11:56:41.107775414+08:00"
},
{
"id": "188cb7c8586dd2c4dcf4bd1e",
"name": "萌芽漂流瓶后端API",
"group": "api",
"urls": [
{
"id": "432740a25595",
"url": "https://bottle.api.shumengya.top",
"remark": ""
}
],
"favicon": "https://bottle.api.shumengya.top/favicon.ico",
"title": "502 Bad Gateway",
"created_at": "2026-01-21T18:20:24.7546969+08:00",
"updated_at": "2026-01-26T11:56:41.019334673+08:00"
},
{
"id": "188cf6ed769b75e497cd2a4a",
"name": "大萌芽1Panel面板[WG]",
"group": "admin",
"urls": [
{
"id": "a3e3ff7839f1",
"url": "http://10.0.0.233:19132/smy",
"remark": ""
}
],
"favicon": "http://10.0.0.233:19132/favicon.ico",
"title": "loading...",
"created_at": "2026-01-22T13:37:33.4073441+08:00",
"updated_at": "2026-02-10T01:33:12.8434302+08:00"
},
{
"id": "188cf6f322f76b90c35c3484",
"name": "小萌芽1Panel面板[WG]",
"group": "admin",
"urls": [
{
"id": "76b6f9fe4f04",
"url": "http://10.0.0.100:19132/smy",
"remark": ""
}
],
"favicon": "http://10.0.0.100:19132/favicon.ico",
"title": "loading...",
"created_at": "2026-01-22T13:37:57.7738884+08:00",
"updated_at": "2026-02-10T01:33:17.4968298+08:00"
},
{
"id": "188cf7019e56e618827f4927",
"name": "大萌芽Portaintor面板[WG]",
"group": "admin",
"urls": [
{
"id": "093d47789bb5",
"url": "http://10.0.0.233:8484/",
"remark": ""
}
],
"favicon": "http://10.0.0.233:8484/dd5d4c0b208895c5a7de.png",
"title": "Portainer",
"created_at": "2026-01-22T13:38:59.9732854+08:00",
"updated_at": "2026-02-10T01:38:15.5238829+08:00"
},
{
"id": "188cf70700922fa4041f6470",
"name": "小萌芽Portaintor面板[WG]",
"group": "admin",
"urls": [
{
"id": "777c292c7696",
"url": "http://10.0.0.100:8484/",
"remark": ""
}
],
"favicon": "http://10.0.0.100:8484/dd5d4c0b208895c5a7de.png",
"title": "Portainer",
"created_at": "2026-01-22T13:39:23.0961745+08:00",
"updated_at": "2026-02-10T01:38:36.0226948+08:00"
},
{
"id": "188cf728ed2992f06a8f9e52",
"name": "easytier面板",
"group": "admin",
"urls": [
{
"id": "0a0a59069228",
"url": "http://easytier.shumengya.top",
"remark": ""
}
],
"favicon": "http://easytier.shumengya.top/easytier.png",
"title": "EasyTier Dashboard",
"created_at": "2026-01-22T13:41:48.7994396+08:00",
"updated_at": "2026-01-23T22:58:03.667100466+08:00"
},
{
"id": "188cf72edea71c4cb2284c93",
"name": "wireguard面板",
"group": "admin",
"urls": [
{
"id": "170fab2239ad",
"url": "https://wireguard.shumengya.top",
"remark": ""
}
],
"favicon": "https://wireguard.shumengya.top/favicon.ico",
"title": "502 Bad Gateway",
"created_at": "2026-01-22T13:42:14.3258123+08:00",
"updated_at": "2026-01-26T11:56:40.996902062+08:00"
},
{
"id": "188cf746b16a83dc4d5e3d13",
"name": "萌芽Docker镜像仓库",
"group": "admin",
"urls": [
{
"id": "822329e621c2",
"url": "https://repo.docker.shumengya.top",
"remark": ""
}
],
"favicon": "https://repo.docker.shumengya.top/favicon.ico",
"title": "502 Bad Gateway",
"created_at": "2026-01-22T13:43:56.6460815+08:00",
"updated_at": "2026-01-26T11:56:40.972109897+08:00"
},
{
"id": "188cf74b2fa5da7c8e2b588e",
"name": "萌芽通知",
"group": "admin",
"urls": [
{
"id": "2f580f90f979",
"url": "https://notice.shumengya.top",
"remark": ""
}
],
"favicon": "https://notice.shumengya.top/favicon.ico",
"title": "502 Bad Gateway",
"created_at": "2026-01-22T13:44:15.9437687+08:00",
"updated_at": "2026-01-26T11:56:41.065412821+08:00"
},
{
"id": "188d00db054cf7860dff3440",
"name": "萌芽Ping",
"group": "admin",
"urls": [
{
"id": "ac85b166ccc4",
"url": "https://ping.shumengya.top",
"remark": ""
}
],
"favicon": "https://ping.shumengya.top/favicon.ico",
"title": "萌芽Ping - 网站监控面板",
"created_at": "2026-01-22T16:39:29.31324815+08:00",
"updated_at": "2026-01-23T13:28:03.633565425+08:00"
},
{
"id": "188d00edf571226f42ec8b20",
"name": "萌芽Ping后端API",
"group": "api",
"urls": [
{
"id": "0bf0fe1bb9cc",
"url": "https://ping.api.shumengya.top",
"remark": ""
}
],
"favicon": "https://ping.api.shumengya.top/favicon.ico",
"title": "萌芽Git仓库",
"created_at": "2026-01-22T16:40:50.651561641+08:00",
"updated_at": "2026-01-23T13:28:03.351764738+08:00"
},
{
"id": "188e7f1a40cdc7bc3bba1817",
"name": "DNS查询[CloudFlare]",
"group": "self-deploy",
"urls": [
{
"id": "9af024b3d387",
"url": "https://cf-dns.smyhub.com/",
"remark": ""
}
],
"favicon": "https://cf-assets.www.cloudflare.com/dzlvafdwdttg/6TaQ8Q7BDmdAFRoHpDCb82/8d9bc52a2ac5af100de3a9adcf99ffaa/security-shield-protection-2.svg",
"title": "DNS-over-HTTPS Resolver",
"created_at": "2026-01-27T13:24:14.3362887+08:00",
"updated_at": "2026-01-27T13:24:15.171393+08:00"
},
{
"id": "188e7f27a909fe247a2fcd15",
"name": "Github文件加速[CloudFlare]",
"group": "self-deploy",
"urls": [
{
"id": "b897ef0def67",
"url": "https://gh-proxy.smyhub.com/",
"remark": ""
}
],
"favicon": "https://gh-proxy.smyhub.com/favicon.ico",
"title": "GitHub 文件加速",
"created_at": "2026-01-27T13:25:11.9196401+08:00",
"updated_at": "2026-01-27T13:25:12.8034353+08:00"
},
{
"id": "188e7f33f087667c29da05f2",
"name": "FloppyBird[CloudFlare]",
"group": "self-deploy",
"urls": [
{
"id": "75a0cb223c2d",
"url": "https://floppy-bird.smyhub.com/",
"remark": ""
}
],
"favicon": "https://floppy-bird.smyhub.com/favicon.ico",
"title": "Floppy Bird",
"created_at": "2026-01-27T13:26:04.6586487+08:00",
"updated_at": "2026-01-27T13:26:05.4749231+08:00"
},
{
"id": "188e7f4cdd13a3bc80c68184",
"name": "别踩白方块[CloudFlare]",
"group": "self-deploy",
"urls": [
{
"id": "9f11daaedba4",
"url": "https://floppy-bird.smyhub.com/",
"remark": ""
}
],
"favicon": "https://floppy-bird.smyhub.com/favicon.ico",
"title": "Floppy Bird",
"created_at": "2026-01-27T13:27:51.7064775+08:00",
"updated_at": "2026-01-27T13:27:51.9331276+08:00"
}
]

View File

@@ -0,0 +1,40 @@
version: '3.8'
services:
mengyaping-backend:
build:
context: .
dockerfile: Dockerfile
container_name: mengyaping-backend
restart: unless-stopped
ports:
- "6161:8080"
volumes:
# 持久化数据目录
- /shumengya/docker/mengyaping-backend/data/:/app/data
environment:
# 服务器配置
- SERVER_PORT=8080
- SERVER_HOST=0.0.0.0
# 监控配置
- MONITOR_INTERVAL=5m
- MONITOR_TIMEOUT=10s
- MONITOR_RETRY_COUNT=3
- MONITOR_HISTORY_DAYS=7
networks:
- mengyaping-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
mengyaping-network:
driver: bridge

42
mengyaping-backend/go.mod Normal file
View File

@@ -0,0 +1,42 @@
module mengyaping-backend
go 1.25.0
require (
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // 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.27.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // 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.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)

91
mengyaping-backend/go.sum Normal file
View File

@@ -0,0 +1,91 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
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/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
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.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/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.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
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.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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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=

View File

@@ -0,0 +1,199 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"mengyaping-backend/models"
"mengyaping-backend/services"
)
// WebsiteHandler 网站处理器
type WebsiteHandler struct {
websiteService *services.WebsiteService
monitorService *services.MonitorService
}
// NewWebsiteHandler 创建网站处理器
func NewWebsiteHandler() *WebsiteHandler {
return &WebsiteHandler{
websiteService: services.NewWebsiteService(),
monitorService: services.GetMonitorService(),
}
}
// GetWebsites 获取所有网站状态
func (h *WebsiteHandler) GetWebsites(c *gin.Context) {
statuses := h.monitorService.GetAllWebsiteStatuses()
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "success",
Data: statuses,
})
}
// GetWebsite 获取单个网站状态
func (h *WebsiteHandler) GetWebsite(c *gin.Context) {
id := c.Param("id")
status := h.monitorService.GetWebsiteStatus(id)
if status == nil {
c.JSON(http.StatusNotFound, models.APIResponse{
Code: 404,
Message: "网站不存在",
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "success",
Data: status,
})
}
// CreateWebsite 创建网站
func (h *WebsiteHandler) CreateWebsite(c *gin.Context) {
var req models.CreateWebsiteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.APIResponse{
Code: 400,
Message: "参数错误: " + err.Error(),
})
return
}
website, err := h.websiteService.CreateWebsite(req)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIResponse{
Code: 500,
Message: "创建失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "创建成功",
Data: website,
})
}
// UpdateWebsite 更新网站
func (h *WebsiteHandler) UpdateWebsite(c *gin.Context) {
id := c.Param("id")
var req models.UpdateWebsiteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.APIResponse{
Code: 400,
Message: "参数错误: " + err.Error(),
})
return
}
website, err := h.websiteService.UpdateWebsite(id, req)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIResponse{
Code: 500,
Message: "更新失败: " + err.Error(),
})
return
}
if website == nil {
c.JSON(http.StatusNotFound, models.APIResponse{
Code: 404,
Message: "网站不存在",
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "更新成功",
Data: website,
})
}
// DeleteWebsite 删除网站
func (h *WebsiteHandler) DeleteWebsite(c *gin.Context) {
id := c.Param("id")
if err := h.websiteService.DeleteWebsite(id); err != nil {
c.JSON(http.StatusInternalServerError, models.APIResponse{
Code: 500,
Message: "删除失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "删除成功",
})
}
// CheckWebsiteNow 立即检测网站
func (h *WebsiteHandler) CheckWebsiteNow(c *gin.Context) {
id := c.Param("id")
website := h.websiteService.GetWebsite(id)
if website == nil {
c.JSON(http.StatusNotFound, models.APIResponse{
Code: 404,
Message: "网站不存在",
})
return
}
h.monitorService.CheckWebsiteNow(id)
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "检测任务已提交",
})
}
// GetGroups 获取所有分组
func (h *WebsiteHandler) GetGroups(c *gin.Context) {
groups := h.websiteService.GetGroups()
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "success",
Data: groups,
})
}
// AddGroup 添加分组
func (h *WebsiteHandler) AddGroup(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.APIResponse{
Code: 400,
Message: "参数错误: " + err.Error(),
})
return
}
group, err := h.websiteService.AddGroup(req.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIResponse{
Code: 500,
Message: "添加失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "添加成功",
Data: group,
})
}

View File

@@ -0,0 +1,47 @@
package main
import (
"fmt"
"log"
"os"
"os/signal"
"syscall"
"mengyaping-backend/config"
"mengyaping-backend/router"
"mengyaping-backend/services"
)
func main() {
// 获取配置
cfg := config.GetConfig()
// 确保数据目录存在
os.MkdirAll(cfg.DataPath, 0755)
// 启动监控服务
monitorService := services.GetMonitorService()
go monitorService.Start()
// 设置路由
r := router.SetupRouter()
// 优雅关闭
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("正在关闭服务...")
monitorService.Stop()
os.Exit(0)
}()
// 启动服务器
addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
log.Printf("🌱 萌芽Ping 监控服务已启动,监听地址: %s\n", addr)
if err := r.Run(addr); err != nil {
log.Fatalf("服务器启动失败: %v", err)
}
}

View File

@@ -0,0 +1,98 @@
package models
import (
"time"
)
// Website 网站信息
type Website struct {
ID string `json:"id"`
Name string `json:"name"` // 网站名称
Group string `json:"group"` // 所属分组
URLs []URLInfo `json:"urls"` // 网站访问地址列表
Favicon string `json:"favicon"` // 网站图标URL
Title string `json:"title"` // 网站标题
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedAt time.Time `json:"updated_at"` // 更新时间
}
// URLInfo 单个URL的信息
type URLInfo struct {
ID string `json:"id"`
URL string `json:"url"` // 访问地址
Remark string `json:"remark"` // 备注说明
}
// MonitorRecord 监控记录
type MonitorRecord struct {
WebsiteID string `json:"website_id"`
URLID string `json:"url_id"`
URL string `json:"url"`
StatusCode int `json:"status_code"` // HTTP状态码
Latency int64 `json:"latency"` // 延迟(毫秒)
IsUp bool `json:"is_up"` // 是否可访问
Error string `json:"error"` // 错误信息
CheckedAt time.Time `json:"checked_at"` // 检测时间
}
// WebsiteStatus 网站状态(用于前端展示)
type WebsiteStatus struct {
Website Website `json:"website"`
URLStatuses []URLStatus `json:"url_statuses"`
Uptime24h float64 `json:"uptime_24h"` // 24小时可用率
Uptime7d float64 `json:"uptime_7d"` // 7天可用率
LastChecked time.Time `json:"last_checked"` // 最后检测时间
}
// URLStatus 单个URL的状态
type URLStatus struct {
URLInfo URLInfo `json:"url_info"`
CurrentState MonitorRecord `json:"current_state"` // 当前状态
History24h []MonitorRecord `json:"history_24h"` // 24小时历史
History7d []HourlyStats `json:"history_7d"` // 7天按小时统计
Uptime24h float64 `json:"uptime_24h"` // 24小时可用率
Uptime7d float64 `json:"uptime_7d"` // 7天可用率
AvgLatency int64 `json:"avg_latency"` // 平均延迟
}
// HourlyStats 每小时统计
type HourlyStats struct {
Hour time.Time `json:"hour"`
TotalCount int `json:"total_count"`
UpCount int `json:"up_count"`
AvgLatency int64 `json:"avg_latency"`
Uptime float64 `json:"uptime"`
}
// Group 分组
type Group struct {
ID string `json:"id"`
Name string `json:"name"`
}
// DefaultGroups 默认分组
var DefaultGroups = []Group{
{ID: "normal", Name: "普通网站"},
{ID: "admin", Name: "管理员网站"},
}
// CreateWebsiteRequest 创建网站请求
type CreateWebsiteRequest struct {
Name string `json:"name" binding:"required"`
Group string `json:"group" binding:"required"`
URLs []string `json:"urls" binding:"required,min=1"`
}
// UpdateWebsiteRequest 更新网站请求
type UpdateWebsiteRequest struct {
Name string `json:"name"`
Group string `json:"group"`
URLs []string `json:"urls"`
}
// APIResponse API响应
type APIResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}

View File

@@ -0,0 +1,51 @@
package router
import (
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"mengyaping-backend/handlers"
)
// SetupRouter 设置路由
func SetupRouter() *gin.Engine {
r := gin.Default()
// CORS配置
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
}))
// 创建处理器
websiteHandler := handlers.NewWebsiteHandler()
// API路由组
api := r.Group("/api")
{
// 健康检查
api.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"message": "服务运行正常",
})
})
// 网站相关
api.GET("/websites", websiteHandler.GetWebsites)
api.GET("/websites/:id", websiteHandler.GetWebsite)
api.POST("/websites", websiteHandler.CreateWebsite)
api.PUT("/websites/:id", websiteHandler.UpdateWebsite)
api.DELETE("/websites/:id", websiteHandler.DeleteWebsite)
api.POST("/websites/:id/check", websiteHandler.CheckWebsiteNow)
// 分组相关
api.GET("/groups", websiteHandler.GetGroups)
api.POST("/groups", websiteHandler.AddGroup)
}
return r
}

View File

@@ -0,0 +1,302 @@
package services
import (
"log"
"sync"
"time"
"mengyaping-backend/config"
"mengyaping-backend/models"
"mengyaping-backend/storage"
"mengyaping-backend/utils"
)
// MonitorService 监控服务
type MonitorService struct {
httpClient *utils.HTTPClient
storage *storage.Storage
stopCh chan struct{}
running bool
mu sync.Mutex
}
var (
monitorService *MonitorService
monitorOnce sync.Once
)
// GetMonitorService 获取监控服务单例
func GetMonitorService() *MonitorService {
monitorOnce.Do(func() {
cfg := config.GetConfig()
monitorService = &MonitorService{
httpClient: utils.NewHTTPClient(cfg.Monitor.Timeout),
storage: storage.GetStorage(),
stopCh: make(chan struct{}),
}
})
return monitorService
}
// Start 启动监控服务
func (s *MonitorService) Start() {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return
}
s.running = true
s.mu.Unlock()
log.Println("监控服务已启动")
// 立即执行一次检测
go s.checkAll()
// 定时检测
cfg := config.GetConfig()
ticker := time.NewTicker(cfg.Monitor.Interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
go s.checkAll()
case <-s.stopCh:
log.Println("监控服务已停止")
return
}
}
}
// Stop 停止监控服务
func (s *MonitorService) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
close(s.stopCh)
s.running = false
}
}
// checkAll 检查所有网站
func (s *MonitorService) checkAll() {
websites := s.storage.GetWebsites()
var wg sync.WaitGroup
semaphore := make(chan struct{}, 10) // 限制并发数
for _, website := range websites {
for _, urlInfo := range website.URLs {
wg.Add(1)
go func(w models.Website, u models.URLInfo) {
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
s.checkURL(w, u)
}(website, urlInfo)
}
}
wg.Wait()
// 保存记录
s.storage.SaveAll()
}
// checkURL 检查单个URL
func (s *MonitorService) checkURL(website models.Website, urlInfo models.URLInfo) {
result := s.httpClient.CheckWebsite(urlInfo.URL)
record := models.MonitorRecord{
WebsiteID: website.ID,
URLID: urlInfo.ID,
URL: urlInfo.URL,
StatusCode: result.StatusCode,
Latency: result.Latency.Milliseconds(),
IsUp: result.Error == nil && utils.IsSuccessStatus(result.StatusCode),
CheckedAt: time.Now(),
}
if result.Error != nil {
record.Error = result.Error.Error()
}
s.storage.AddRecord(record)
// 更新网站信息标题和Favicon
if result.Title != "" || result.Favicon != "" {
w := s.storage.GetWebsite(website.ID)
if w != nil {
needUpdate := false
if result.Title != "" && w.Title != result.Title {
w.Title = result.Title
needUpdate = true
}
if result.Favicon != "" && w.Favicon != result.Favicon {
w.Favicon = result.Favicon
needUpdate = true
}
if needUpdate {
w.UpdatedAt = time.Now()
s.storage.UpdateWebsite(*w)
}
}
}
log.Printf("检测 [%s] %s - 状态码: %d, 延迟: %dms, 可用: %v",
website.Name, urlInfo.URL, result.StatusCode, result.Latency.Milliseconds(), record.IsUp)
}
// CheckWebsiteNow 立即检查指定网站
func (s *MonitorService) CheckWebsiteNow(websiteID string) {
website := s.storage.GetWebsite(websiteID)
if website == nil {
return
}
for _, urlInfo := range website.URLs {
go s.checkURL(*website, urlInfo)
}
}
// GetWebsiteStatus 获取网站状态
func (s *MonitorService) GetWebsiteStatus(websiteID string) *models.WebsiteStatus {
website := s.storage.GetWebsite(websiteID)
if website == nil {
return nil
}
status := &models.WebsiteStatus{
Website: *website,
URLStatuses: []models.URLStatus{},
}
now := time.Now()
since24h := now.Add(-24 * time.Hour)
since7d := now.Add(-7 * 24 * time.Hour)
var totalUptime24h, totalUptime7d float64
var urlCount int
for _, urlInfo := range website.URLs {
urlStatus := s.getURLStatus(website.ID, urlInfo, since24h, since7d)
status.URLStatuses = append(status.URLStatuses, urlStatus)
totalUptime24h += urlStatus.Uptime24h
totalUptime7d += urlStatus.Uptime7d
urlCount++
}
if urlCount > 0 {
status.Uptime24h = totalUptime24h / float64(urlCount)
status.Uptime7d = totalUptime7d / float64(urlCount)
}
// 获取最后检测时间
for _, urlStatus := range status.URLStatuses {
if urlStatus.CurrentState.CheckedAt.After(status.LastChecked) {
status.LastChecked = urlStatus.CurrentState.CheckedAt
}
}
return status
}
// getURLStatus 获取URL状态
func (s *MonitorService) getURLStatus(websiteID string, urlInfo models.URLInfo, since24h, since7d time.Time) models.URLStatus {
urlStatus := models.URLStatus{
URLInfo: urlInfo,
}
// 获取最新记录
latest := s.storage.GetLatestRecord(websiteID, urlInfo.ID)
if latest != nil {
urlStatus.CurrentState = *latest
}
// 获取24小时记录
records24h := s.storage.GetRecords(websiteID, urlInfo.ID, since24h)
urlStatus.History24h = records24h
// 计算24小时可用率
if len(records24h) > 0 {
upCount := 0
var totalLatency int64
for _, r := range records24h {
if r.IsUp {
upCount++
}
totalLatency += r.Latency
}
urlStatus.Uptime24h = float64(upCount) / float64(len(records24h)) * 100
urlStatus.AvgLatency = totalLatency / int64(len(records24h))
}
// 获取7天记录并按小时统计
records7d := s.storage.GetRecords(websiteID, urlInfo.ID, since7d)
urlStatus.History7d = s.aggregateByHour(records7d)
// 计算7天可用率
if len(records7d) > 0 {
upCount := 0
for _, r := range records7d {
if r.IsUp {
upCount++
}
}
urlStatus.Uptime7d = float64(upCount) / float64(len(records7d)) * 100
}
return urlStatus
}
// aggregateByHour 按小时聚合记录
func (s *MonitorService) aggregateByHour(records []models.MonitorRecord) []models.HourlyStats {
hourlyMap := make(map[string]*models.HourlyStats)
for _, r := range records {
hourKey := r.CheckedAt.Truncate(time.Hour).Format(time.RFC3339)
if _, exists := hourlyMap[hourKey]; !exists {
hourlyMap[hourKey] = &models.HourlyStats{
Hour: r.CheckedAt.Truncate(time.Hour),
}
}
stats := hourlyMap[hourKey]
stats.TotalCount++
if r.IsUp {
stats.UpCount++
}
stats.AvgLatency += r.Latency
}
var result []models.HourlyStats
for _, stats := range hourlyMap {
if stats.TotalCount > 0 {
stats.AvgLatency /= int64(stats.TotalCount)
stats.Uptime = float64(stats.UpCount) / float64(stats.TotalCount) * 100
}
result = append(result, *stats)
}
return result
}
// GetAllWebsiteStatuses 获取所有网站状态
func (s *MonitorService) GetAllWebsiteStatuses() []models.WebsiteStatus {
websites := s.storage.GetWebsites()
var statuses []models.WebsiteStatus
for _, website := range websites {
status := s.GetWebsiteStatus(website.ID)
if status != nil {
statuses = append(statuses, *status)
}
}
return statuses
}

View File

@@ -0,0 +1,127 @@
package services
import (
"time"
"mengyaping-backend/models"
"mengyaping-backend/storage"
"mengyaping-backend/utils"
)
// WebsiteService 网站服务
type WebsiteService struct {
storage *storage.Storage
}
// NewWebsiteService 创建网站服务
func NewWebsiteService() *WebsiteService {
return &WebsiteService{
storage: storage.GetStorage(),
}
}
// CreateWebsite 创建网站
func (s *WebsiteService) CreateWebsite(req models.CreateWebsiteRequest) (*models.Website, error) {
website := models.Website{
ID: utils.GenerateID(),
Name: req.Name,
Group: req.Group,
URLs: make([]models.URLInfo, 0),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
for _, url := range req.URLs {
urlInfo := models.URLInfo{
ID: utils.GenerateShortID(),
URL: url,
}
website.URLs = append(website.URLs, urlInfo)
}
if err := s.storage.AddWebsite(website); err != nil {
return nil, err
}
// 立即检测该网站
go GetMonitorService().CheckWebsiteNow(website.ID)
return &website, nil
}
// GetWebsite 获取网站
func (s *WebsiteService) GetWebsite(id string) *models.Website {
return s.storage.GetWebsite(id)
}
// GetAllWebsites 获取所有网站
func (s *WebsiteService) GetAllWebsites() []models.Website {
return s.storage.GetWebsites()
}
// UpdateWebsite 更新网站
func (s *WebsiteService) UpdateWebsite(id string, req models.UpdateWebsiteRequest) (*models.Website, error) {
website := s.storage.GetWebsite(id)
if website == nil {
return nil, nil
}
if req.Name != "" {
website.Name = req.Name
}
if req.Group != "" {
website.Group = req.Group
}
if len(req.URLs) > 0 {
// 保留已有URL的ID添加新URL
existingURLs := make(map[string]models.URLInfo)
for _, u := range website.URLs {
existingURLs[u.URL] = u
}
newURLs := make([]models.URLInfo, 0)
for _, url := range req.URLs {
if existing, ok := existingURLs[url]; ok {
newURLs = append(newURLs, existing)
} else {
newURLs = append(newURLs, models.URLInfo{
ID: utils.GenerateShortID(),
URL: url,
})
}
}
website.URLs = newURLs
}
website.UpdatedAt = time.Now()
if err := s.storage.UpdateWebsite(*website); err != nil {
return nil, err
}
return website, nil
}
// DeleteWebsite 删除网站
func (s *WebsiteService) DeleteWebsite(id string) error {
return s.storage.DeleteWebsite(id)
}
// GetGroups 获取所有分组
func (s *WebsiteService) GetGroups() []models.Group {
return s.storage.GetGroups()
}
// AddGroup 添加分组
func (s *WebsiteService) AddGroup(name string) (*models.Group, error) {
group := models.Group{
ID: utils.GenerateShortID(),
Name: name,
}
if err := s.storage.AddGroup(group); err != nil {
return nil, err
}
return &group, nil
}

View File

@@ -0,0 +1,285 @@
package storage
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"mengyaping-backend/config"
"mengyaping-backend/models"
)
// Storage 数据存储
type Storage struct {
dataPath string
mu sync.RWMutex
websites []models.Website
records map[string][]models.MonitorRecord // key: websiteID_urlID
groups []models.Group
}
var (
store *Storage
once sync.Once
)
// GetStorage 获取存储单例
func GetStorage() *Storage {
once.Do(func() {
cfg := config.GetConfig()
store = &Storage{
dataPath: cfg.DataPath,
websites: []models.Website{},
records: make(map[string][]models.MonitorRecord),
groups: models.DefaultGroups,
}
store.ensureDataDir()
store.load()
})
return store
}
// ensureDataDir 确保数据目录存在
func (s *Storage) ensureDataDir() {
os.MkdirAll(s.dataPath, 0755)
}
// load 加载数据
func (s *Storage) load() {
s.loadWebsites()
s.loadRecords()
s.loadGroups()
}
// loadWebsites 加载网站数据
func (s *Storage) loadWebsites() {
filePath := filepath.Join(s.dataPath, "websites.json")
data, err := os.ReadFile(filePath)
if err != nil {
return
}
json.Unmarshal(data, &s.websites)
}
// loadRecords 加载监控记录
func (s *Storage) loadRecords() {
filePath := filepath.Join(s.dataPath, "records.json")
data, err := os.ReadFile(filePath)
if err != nil {
return
}
json.Unmarshal(data, &s.records)
// 清理过期记录
s.cleanOldRecords()
}
// loadGroups 加载分组
func (s *Storage) loadGroups() {
filePath := filepath.Join(s.dataPath, "groups.json")
data, err := os.ReadFile(filePath)
if err != nil {
// 使用默认分组
s.groups = models.DefaultGroups
s.saveGroups()
return
}
json.Unmarshal(data, &s.groups)
}
// saveWebsites 保存网站数据
func (s *Storage) saveWebsites() error {
filePath := filepath.Join(s.dataPath, "websites.json")
data, err := json.MarshalIndent(s.websites, "", " ")
if err != nil {
return err
}
return os.WriteFile(filePath, data, 0644)
}
// saveRecords 保存监控记录
func (s *Storage) saveRecords() error {
filePath := filepath.Join(s.dataPath, "records.json")
data, err := json.MarshalIndent(s.records, "", " ")
if err != nil {
return err
}
return os.WriteFile(filePath, data, 0644)
}
// saveGroups 保存分组
func (s *Storage) saveGroups() error {
filePath := filepath.Join(s.dataPath, "groups.json")
data, err := json.MarshalIndent(s.groups, "", " ")
if err != nil {
return err
}
return os.WriteFile(filePath, data, 0644)
}
// cleanOldRecords 清理过期记录
func (s *Storage) cleanOldRecords() {
cfg := config.GetConfig()
cutoff := time.Now().AddDate(0, 0, -cfg.Monitor.HistoryDays)
for key, records := range s.records {
var newRecords []models.MonitorRecord
for _, r := range records {
if r.CheckedAt.After(cutoff) {
newRecords = append(newRecords, r)
}
}
s.records[key] = newRecords
}
}
// GetWebsites 获取所有网站
func (s *Storage) GetWebsites() []models.Website {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]models.Website, len(s.websites))
copy(result, s.websites)
return result
}
// GetWebsite 获取单个网站
func (s *Storage) GetWebsite(id string) *models.Website {
s.mu.RLock()
defer s.mu.RUnlock()
for _, w := range s.websites {
if w.ID == id {
website := w
return &website
}
}
return nil
}
// AddWebsite 添加网站
func (s *Storage) AddWebsite(website models.Website) error {
s.mu.Lock()
defer s.mu.Unlock()
s.websites = append(s.websites, website)
return s.saveWebsites()
}
// UpdateWebsite 更新网站
func (s *Storage) UpdateWebsite(website models.Website) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, w := range s.websites {
if w.ID == website.ID {
s.websites[i] = website
return s.saveWebsites()
}
}
return nil
}
// DeleteWebsite 删除网站
func (s *Storage) DeleteWebsite(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, w := range s.websites {
if w.ID == id {
s.websites = append(s.websites[:i], s.websites[i+1:]...)
// 删除相关记录
for key := range s.records {
if len(key) > len(id) && key[:len(id)] == id {
delete(s.records, key)
}
}
s.saveRecords()
return s.saveWebsites()
}
}
return nil
}
// AddRecord 添加监控记录
func (s *Storage) AddRecord(record models.MonitorRecord) error {
s.mu.Lock()
defer s.mu.Unlock()
key := record.WebsiteID + "_" + record.URLID
s.records[key] = append(s.records[key], record)
// 每100条记录保存一次
if len(s.records[key])%100 == 0 {
return s.saveRecords()
}
return nil
}
// GetRecords 获取监控记录
func (s *Storage) GetRecords(websiteID, urlID string, since time.Time) []models.MonitorRecord {
s.mu.RLock()
defer s.mu.RUnlock()
key := websiteID + "_" + urlID
records := s.records[key]
var result []models.MonitorRecord
for _, r := range records {
if r.CheckedAt.After(since) {
result = append(result, r)
}
}
return result
}
// GetLatestRecord 获取最新记录
func (s *Storage) GetLatestRecord(websiteID, urlID string) *models.MonitorRecord {
s.mu.RLock()
defer s.mu.RUnlock()
key := websiteID + "_" + urlID
records := s.records[key]
if len(records) == 0 {
return nil
}
latest := records[len(records)-1]
return &latest
}
// GetGroups 获取所有分组
func (s *Storage) GetGroups() []models.Group {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]models.Group, len(s.groups))
copy(result, s.groups)
return result
}
// AddGroup 添加分组
func (s *Storage) AddGroup(group models.Group) error {
s.mu.Lock()
defer s.mu.Unlock()
s.groups = append(s.groups, group)
return s.saveGroups()
}
// SaveAll 保存所有数据
func (s *Storage) SaveAll() error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.saveWebsites(); err != nil {
return err
}
if err := s.saveRecords(); err != nil {
return err
}
return s.saveGroups()
}

View File

@@ -0,0 +1,131 @@
package utils
import (
"crypto/tls"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
// HTTPClient HTTP客户端工具
type HTTPClient struct {
client *http.Client
}
// NewHTTPClient 创建HTTP客户端
func NewHTTPClient(timeout time.Duration) *HTTPClient {
return &HTTPClient{
client: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
return nil
},
},
}
}
// CheckResult 检查结果
type CheckResult struct {
StatusCode int
Latency time.Duration
Title string
Favicon string
Error error
}
// CheckWebsite 检查网站
func (c *HTTPClient) CheckWebsite(targetURL string) CheckResult {
result := CheckResult{}
start := time.Now()
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
result.Error = err
return result
}
req.Header.Set("User-Agent", "MengYaPing/1.0 (Website Monitor)")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
resp, err := c.client.Do(req)
if err != nil {
result.Error = err
result.Latency = time.Since(start)
return result
}
defer resp.Body.Close()
result.Latency = time.Since(start)
result.StatusCode = resp.StatusCode
// 读取响应体获取标题
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100)) // 限制100KB
if err == nil {
result.Title = extractTitle(string(body))
result.Favicon = extractFavicon(string(body), targetURL)
}
return result
}
// extractTitle 提取网页标题
func extractTitle(html string) string {
re := regexp.MustCompile(`(?i)<title[^>]*>([^<]+)</title>`)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
return ""
}
// extractFavicon 提取Favicon
func extractFavicon(html string, baseURL string) string {
parsedURL, err := url.Parse(baseURL)
if err != nil {
return ""
}
// 尝试从HTML中提取favicon链接
patterns := []string{
`(?i)<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']+)["']`,
`(?i)<link[^>]*href=["']([^"']+)["'][^>]*rel=["'](?:shortcut )?icon["']`,
`(?i)<link[^>]*rel=["']apple-touch-icon["'][^>]*href=["']([^"']+)["']`,
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
faviconURL := matches[1]
return resolveURL(parsedURL, faviconURL)
}
}
// 默认返回 /favicon.ico
return fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host)
}
// resolveURL 解析相对URL
func resolveURL(base *url.URL, ref string) string {
refURL, err := url.Parse(ref)
if err != nil {
return ref
}
return base.ResolveReference(refURL).String()
}
// IsSuccessStatus 判断是否为成功状态码
func IsSuccessStatus(statusCode int) bool {
return statusCode >= 200 && statusCode < 400
}

View File

@@ -0,0 +1,31 @@
package utils
import (
"crypto/rand"
"encoding/hex"
"time"
)
// GenerateID 生成唯一ID
func GenerateID() string {
timestamp := time.Now().UnixNano()
randomBytes := make([]byte, 4)
rand.Read(randomBytes)
return hex.EncodeToString([]byte{
byte(timestamp >> 56),
byte(timestamp >> 48),
byte(timestamp >> 40),
byte(timestamp >> 32),
byte(timestamp >> 24),
byte(timestamp >> 16),
byte(timestamp >> 8),
byte(timestamp),
}) + hex.EncodeToString(randomBytes)
}
// GenerateShortID 生成短ID
func GenerateShortID() string {
randomBytes := make([]byte, 6)
rand.Read(randomBytes)
return hex.EncodeToString(randomBytes)
}

24
mengyaping-frontend/.gitignore vendored Normal file
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?

View File

@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="萌芽Ping - 网站监控面板" />
<title>萌芽Ping - 网站监控面板</title>
<link rel="apple-touch-icon" href="/logo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3492
mengyaping-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
{
"name": "mengyaping-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"vite": "^7.2.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -0,0 +1,7 @@
import Dashboard from './pages/Dashboard'
function App() {
return <Dashboard />
}
export default App

View File

@@ -0,0 +1,145 @@
import { formatUptime, getUptimeColor } from '../hooks/useMonitor';
// 统计概览卡片
export default function StatsCard({ websites }) {
// 计算统计数据
const stats = {
total: websites?.length || 0,
online: 0,
offline: 0,
avgUptime24h: 0,
avgUptime7d: 0,
avgLatency: 0,
};
if (websites && websites.length > 0) {
let totalUptime24h = 0;
let totalUptime7d = 0;
let totalLatency = 0;
let latencyCount = 0;
websites.forEach(site => {
// 检查所有URL的状态
const hasOnlineUrl = site.url_statuses?.some(us => us.current_state?.is_up);
if (hasOnlineUrl) {
stats.online++;
} else {
stats.offline++;
}
totalUptime24h += site.uptime_24h || 0;
totalUptime7d += site.uptime_7d || 0;
site.url_statuses?.forEach(us => {
if (us.current_state?.latency) {
totalLatency += us.current_state.latency;
latencyCount++;
}
});
});
stats.avgUptime24h = totalUptime24h / stats.total;
stats.avgUptime7d = totalUptime7d / stats.total;
stats.avgLatency = latencyCount > 0 ? Math.round(totalLatency / latencyCount) : 0;
}
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* 监控网站数 */}
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">监控网站</p>
<p className="text-2xl font-bold text-gray-800 mt-1">{stats.total}</p>
</div>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center">
<svg className="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
</div>
<div className="flex items-center mt-3 text-xs">
<span className="text-green-500 flex items-center">
<span className="w-2 h-2 rounded-full bg-green-500 mr-1"></span>
{stats.online} 在线
</span>
<span className="text-gray-300 mx-2">|</span>
<span className="text-red-500 flex items-center">
<span className="w-2 h-2 rounded-full bg-red-500 mr-1"></span>
{stats.offline} 离线
</span>
</div>
</div>
{/* 24小时可用率 */}
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">24h 可用率</p>
<p className={`text-2xl font-bold mt-1 ${getUptimeColor(stats.avgUptime24h)}`}>
{formatUptime(stats.avgUptime24h)}
</p>
</div>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-50 to-emerald-100 flex items-center justify-center">
<svg className="w-6 h-6 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div className="mt-3 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-emerald-400 to-green-500 rounded-full"
style={{ width: `${Math.min(stats.avgUptime24h, 100)}%` }}
/>
</div>
</div>
{/* 7天可用率 */}
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">7d 可用率</p>
<p className={`text-2xl font-bold mt-1 ${getUptimeColor(stats.avgUptime7d)}`}>
{formatUptime(stats.avgUptime7d)}
</p>
</div>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-50 to-green-100 flex items-center justify-center">
<svg className="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
</div>
<div className="mt-3 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-green-400 to-teal-500 rounded-full"
style={{ width: `${Math.min(stats.avgUptime7d, 100)}%` }}
/>
</div>
</div>
{/* 平均延迟 */}
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">平均延迟</p>
<p className="text-2xl font-bold text-gray-800 mt-1">
{stats.avgLatency}
<span className="text-sm font-normal text-gray-500 ml-1">ms</span>
</p>
</div>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-50 to-purple-100 flex items-center justify-center">
<svg className="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
</div>
<div className="flex items-center mt-3 text-xs text-gray-500">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
每5分钟检测一次
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,212 @@
import { useMemo } from 'react';
// 简易折线图组件
export default function UptimeChart({ data, height = 120, showLabels = true }) {
// 处理数据
const chartData = useMemo(() => {
if (!data || data.length === 0) {
// 生成模拟数据点
return Array(24).fill(null).map((_, i) => ({
hour: i,
uptime: null,
avgLatency: 0,
}));
}
// 按时间排序
const sorted = [...data].sort((a, b) =>
new Date(a.hour).getTime() - new Date(b.hour).getTime()
);
return sorted.map(item => ({
hour: new Date(item.hour).getHours(),
uptime: item.uptime,
avgLatency: item.avg_latency,
}));
}, [data]);
// 计算图表尺寸
const padding = { top: 20, right: 20, bottom: showLabels ? 30 : 10, left: showLabels ? 40 : 10 };
const width = 400;
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
// 生成路径
const pathData = useMemo(() => {
const validPoints = chartData.filter(d => d.uptime !== null);
if (validPoints.length === 0) return '';
const xStep = chartWidth / (chartData.length - 1 || 1);
let path = '';
let lastValidIndex = -1;
chartData.forEach((d, i) => {
if (d.uptime !== null) {
const x = i * xStep;
const y = chartHeight - (d.uptime / 100 * chartHeight);
if (lastValidIndex === -1) {
path += `M ${x} ${y}`;
} else {
path += ` L ${x} ${y}`;
}
lastValidIndex = i;
}
});
return path;
}, [chartData, chartWidth, chartHeight]);
// 生成填充区域
const areaPath = useMemo(() => {
if (!pathData) return '';
const validPoints = chartData.filter(d => d.uptime !== null);
if (validPoints.length === 0) return '';
const xStep = chartWidth / (chartData.length - 1 || 1);
const firstValidIndex = chartData.findIndex(d => d.uptime !== null);
const lastValidIndex = chartData.length - 1 - [...chartData].reverse().findIndex(d => d.uptime !== null);
const startX = firstValidIndex * xStep;
const endX = lastValidIndex * xStep;
return `${pathData} L ${endX} ${chartHeight} L ${startX} ${chartHeight} Z`;
}, [pathData, chartData, chartWidth, chartHeight]);
// 获取颜色
const getColor = (uptime) => {
if (uptime >= 99) return '#10b981'; // emerald-500
if (uptime >= 95) return '#34d399'; // emerald-400
if (uptime >= 90) return '#fbbf24'; // amber-400
return '#ef4444'; // red-500
};
// 计算平均可用率
const avgUptime = useMemo(() => {
const validPoints = chartData.filter(d => d.uptime !== null);
if (validPoints.length === 0) return null;
return validPoints.reduce((sum, d) => sum + d.uptime, 0) / validPoints.length;
}, [chartData]);
const color = avgUptime !== null ? getColor(avgUptime) : '#d1d5db';
return (
<div className="w-full">
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-auto"
preserveAspectRatio="xMidYMid meet"
>
<defs>
<linearGradient id="uptimeGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
<stop offset="100%" stopColor={color} stopOpacity="0.05" />
</linearGradient>
</defs>
<g transform={`translate(${padding.left}, ${padding.top})`}>
{/* 网格线 */}
{[0, 25, 50, 75, 100].map(v => (
<g key={v}>
<line
x1={0}
y1={chartHeight - (v / 100 * chartHeight)}
x2={chartWidth}
y2={chartHeight - (v / 100 * chartHeight)}
stroke="#e5e7eb"
strokeDasharray="4 2"
/>
{showLabels && (
<text
x={-8}
y={chartHeight - (v / 100 * chartHeight) + 4}
fill="#9ca3af"
fontSize="10"
textAnchor="end"
>
{v}%
</text>
)}
</g>
))}
{/* 填充区域 */}
{areaPath && (
<path
d={areaPath}
fill="url(#uptimeGradient)"
/>
)}
{/* 折线 */}
{pathData && (
<path
d={pathData}
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{/* 数据点 */}
{chartData.map((d, i) => {
if (d.uptime === null) return null;
const xStep = chartWidth / (chartData.length - 1 || 1);
const x = i * xStep;
const y = chartHeight - (d.uptime / 100 * chartHeight);
return (
<circle
key={i}
cx={x}
cy={y}
r="3"
fill="white"
stroke={getColor(d.uptime)}
strokeWidth="2"
/>
);
})}
{/* X轴时间标签 */}
{showLabels && chartData.length > 0 && (
<>
{[0, Math.floor(chartData.length / 2), chartData.length - 1].map(i => {
if (i >= chartData.length) return null;
const xStep = chartWidth / (chartData.length - 1 || 1);
return (
<text
key={i}
x={i * xStep}
y={chartHeight + 20}
fill="#9ca3af"
fontSize="10"
textAnchor="middle"
>
{chartData[i]?.hour || 0}:00
</text>
);
})}
</>
)}
</g>
{/* 无数据提示 */}
{!pathData && (
<text
x={width / 2}
y={height / 2}
fill="#9ca3af"
fontSize="12"
textAnchor="middle"
>
暂无数据
</text>
)}
</svg>
</div>
);
}

View File

@@ -0,0 +1,207 @@
import { useState } from 'react';
import { formatLatency, formatUptime, formatTime, getStatusColor, getUptimeColor, getLatencyColor } from '../hooks/useMonitor';
// 网站状态卡片组件
export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
const [expanded, setExpanded] = useState(false);
// 获取第一个URL的状态作为主状态
const primaryStatus = website.url_statuses?.[0];
const isUp = primaryStatus?.current_state?.is_up ?? false;
const statusCode = primaryStatus?.current_state?.status_code ?? 0;
const latency = primaryStatus?.current_state?.latency ?? 0;
const primaryUrl = website.website?.urls?.[0]?.url || website.url_statuses?.[0]?.url_info?.url;
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow">
{/* 卡片头部 */}
<div
className="p-3 cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
{/* 第一行:图标、名称、状态 */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2 min-w-0 flex-1">
{/* Favicon */}
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center overflow-hidden flex-shrink-0">
{website.website?.favicon ? (
<img
src={website.website.favicon}
alt=""
className="w-7 h-7 object-contain drop-shadow"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
) : null}
<span
className={`text-base font-bold text-emerald-600 ${website.website?.favicon ? 'hidden' : ''}`}
style={{ display: website.website?.favicon ? 'none' : 'flex' }}
>
{website.website?.name?.[0] || '?'}
</span>
</div>
{/* 网站名称 */}
<h3 className="font-semibold text-gray-800 truncate">
{website.website?.name || '未知网站'}
</h3>
</div>
{/* 展开箭头 */}
<svg
className={`w-4 h-4 text-gray-400 transform transition-transform flex-shrink-0 ${expanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{/* 第二行:状态、延迟、访问按钮 */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
{/* 状态徽章 */}
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(isUp, statusCode)}`}>
{isUp ? `${statusCode}` : '离线'}
</span>
{/* 延迟 */}
<span className={`text-xs font-medium ${getLatencyColor(latency)}`}>
{formatLatency(latency)}
</span>
</div>
{/* 访问按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
if (primaryUrl) {
window.open(primaryUrl, '_blank', 'noopener,noreferrer');
}
}}
disabled={!primaryUrl}
className={`flex items-center space-x-1 px-2.5 py-1 text-xs font-medium rounded-full transition-all flex-shrink-0 ${
primaryUrl
? 'text-white bg-gradient-to-r from-emerald-500 to-green-500 hover:from-emerald-600 hover:to-green-600 shadow-sm hover:shadow'
: 'text-gray-400 bg-gray-100 cursor-not-allowed'
}`}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
<span>访问</span>
</button>
</div>
{/* 网站描述 */}
<p className="text-xs text-gray-500 truncate mb-2">
{website.website?.title || website.website?.urls?.[0]?.url || '-'}
</p>
{/* 可用率条 */}
<div className="mt-3">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span>24h可用率</span>
<span className={getUptimeColor(website.uptime_24h || 0)}>
{formatUptime(website.uptime_24h)}
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-emerald-400 to-green-500 rounded-full transition-all"
style={{ width: `${Math.min(website.uptime_24h || 0, 100)}%` }}
/>
</div>
</div>
</div>
{/* 展开详情 */}
{expanded && (
<div className="border-t border-gray-100 bg-gray-50 p-4">
{/* URL列表 */}
<div className="space-y-3">
{website.url_statuses?.map((urlStatus, index) => (
<div
key={urlStatus.url_info?.id || index}
className="bg-white rounded-lg p-3 border border-gray-100"
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-600 truncate flex-1 mr-2">
{urlStatus.url_info?.url}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
getStatusColor(urlStatus.current_state?.is_up, urlStatus.current_state?.status_code)
}`}>
{urlStatus.current_state?.is_up ? urlStatus.current_state?.status_code : '离线'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<span className="text-gray-400">延迟</span>
<p className={`font-medium ${getLatencyColor(urlStatus.current_state?.latency)}`}>
{formatLatency(urlStatus.current_state?.latency)}
</p>
</div>
<div>
<span className="text-gray-400">24h</span>
<p className={`font-medium ${getUptimeColor(urlStatus.uptime_24h)}`}>
{formatUptime(urlStatus.uptime_24h)}
</p>
</div>
<div>
<span className="text-gray-400">7d</span>
<p className={`font-medium ${getUptimeColor(urlStatus.uptime_7d)}`}>
{formatUptime(urlStatus.uptime_7d)}
</p>
</div>
</div>
</div>
))}
</div>
{/* 操作按钮 */}
<div className="flex justify-end space-x-2 mt-4">
<button
onClick={(e) => {
e.stopPropagation();
onRefresh?.(website.website?.id);
}}
className="px-3 py-1.5 text-xs font-medium text-emerald-600 bg-emerald-50 rounded-lg hover:bg-emerald-100 transition-colors"
>
立即检测
</button>
<button
onClick={(e) => {
e.stopPropagation();
onEdit?.(website);
}}
className="px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
编辑
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (confirm('确定要删除这个网站吗?')) {
onDelete?.(website.website?.id);
}
}}
className="px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
>
删除
</button>
</div>
{/* 最后检测时间 */}
<div className="text-xs text-gray-400 text-right mt-2">
最后检测: {formatTime(website.last_checked)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,223 @@
import { useState, useEffect } from 'react';
import { getGroups, createWebsite, updateWebsite } from '../services/api';
export default function WebsiteModal({ isOpen, onClose, onSuccess, editData }) {
const [formData, setFormData] = useState({
name: '',
group: 'normal',
urls: [''],
});
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (isOpen) {
loadGroups();
if (editData) {
setFormData({
name: editData.website?.name || '',
group: editData.website?.group || 'normal',
urls: editData.website?.urls?.map(u => u.url) || [''],
});
} else {
setFormData({ name: '', group: 'normal', urls: [''] });
}
setError('');
}
}, [isOpen, editData]);
const loadGroups = async () => {
try {
const data = await getGroups();
setGroups(data || []);
} catch (err) {
console.error('加载分组失败:', err);
}
};
const handleAddUrl = () => {
setFormData({ ...formData, urls: [...formData.urls, ''] });
};
const handleRemoveUrl = (index) => {
if (formData.urls.length > 1) {
const newUrls = formData.urls.filter((_, i) => i !== index);
setFormData({ ...formData, urls: newUrls });
}
};
const handleUrlChange = (index, value) => {
const newUrls = [...formData.urls];
newUrls[index] = value;
setFormData({ ...formData, urls: newUrls });
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
// 验证
if (!formData.name.trim()) {
setError('请输入网站名称');
return;
}
const validUrls = formData.urls.filter(url => url.trim());
if (validUrls.length === 0) {
setError('请至少输入一个网站地址');
return;
}
// 验证URL格式
for (const url of validUrls) {
try {
new URL(url);
} catch {
setError(`无效的URL: ${url}`);
return;
}
}
setLoading(true);
try {
const data = {
name: formData.name.trim(),
group: formData.group,
urls: validUrls,
};
if (editData) {
await updateWebsite(editData.website.id, data);
} else {
await createWebsite(data);
}
onSuccess?.();
onClose();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md max-h-[90vh] overflow-hidden">
{/* 标题 */}
<div className="px-6 py-4 border-b border-gray-100 bg-gradient-to-r from-emerald-50 to-green-50">
<h2 className="text-lg font-semibold text-gray-800">
{editData ? '编辑网站' : '添加监控网站'}
</h2>
</div>
{/* 表单 */}
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-100 rounded-lg text-sm text-red-600">
{error}
</div>
)}
{/* 网站名称 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
网站名称 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如:我的博客"
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all"
/>
</div>
{/* 所属分组 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
所属分组
</label>
<select
value={formData.group}
onChange={(e) => setFormData({ ...formData, group: e.target.value })}
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all bg-white"
>
{groups.map(group => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
</div>
{/* 网站地址列表 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
网站地址 <span className="text-red-500">*</span>
</label>
<p className="text-xs text-gray-500 mb-2">
一个网站可以有多个访问地址将分别监控
</p>
<div className="space-y-2">
{formData.urls.map((url, index) => (
<div key={index} className="flex space-x-2">
<input
type="text"
value={url}
onChange={(e) => handleUrlChange(index, e.target.value)}
placeholder="https://example.com"
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all"
/>
{formData.urls.length > 1 && (
<button
type="button"
onClick={() => handleRemoveUrl(index)}
className="px-3 py-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
))}
</div>
<button
type="button"
onClick={handleAddUrl}
className="mt-2 text-sm text-emerald-600 hover:text-emerald-700 flex items-center"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
添加更多地址
</button>
</div>
</form>
{/* 按钮 */}
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50 flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
取消
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-emerald-500 to-green-500 rounded-lg hover:from-emerald-600 hover:to-green-600 transition-all disabled:opacity-50"
>
{loading ? '处理中...' : (editData ? '保存' : '添加')}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useState, useEffect, useCallback } from 'react';
// 自动刷新数据Hook
export function useAutoRefresh(fetchFn, interval = 30000) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
try {
setLoading(true);
const result = await fetchFn();
setData(result);
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [fetchFn]);
useEffect(() => {
refresh();
const timer = setInterval(refresh, interval);
return () => clearInterval(timer);
}, [refresh, interval]);
return { data, loading, error, refresh };
}
// 格式化时间
export function formatTime(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
// 格式化延迟
export function formatLatency(ms) {
if (ms === undefined || ms === null) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
// 格式化可用率
export function formatUptime(uptime) {
if (uptime === undefined || uptime === null) return '-';
return `${uptime.toFixed(2)}%`;
}
// 获取状态颜色类名
export function getStatusColor(isUp, statusCode) {
if (!isUp) return 'text-red-500 bg-red-100';
if (statusCode >= 200 && statusCode < 300) return 'text-green-500 bg-green-100';
if (statusCode >= 300 && statusCode < 400) return 'text-yellow-500 bg-yellow-100';
return 'text-red-500 bg-red-100';
}
// 获取可用率颜色
export function getUptimeColor(uptime) {
if (uptime >= 99) return 'text-green-500';
if (uptime >= 95) return 'text-green-400';
if (uptime >= 90) return 'text-yellow-500';
if (uptime >= 80) return 'text-orange-500';
return 'text-red-500';
}
// 获取延迟颜色
export function getLatencyColor(ms) {
if (ms < 200) return 'text-green-500';
if (ms < 500) return 'text-green-400';
if (ms < 1000) return 'text-yellow-500';
if (ms < 2000) return 'text-orange-500';
return 'text-red-500';
}

View File

@@ -0,0 +1,68 @@
@import "tailwindcss";
:root {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light;
color: #374151;
background-color: #f0fdf4;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #10b981;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #059669;
}
/* 动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out;
}
/* 响应式适配 */
@media (max-width: 640px) {
html {
font-size: 14px;
}
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,274 @@
import { useState, useCallback, useMemo } from 'react';
import WebsiteCard from '../components/WebsiteCard';
import WebsiteModal from '../components/WebsiteModal';
import StatsCard from '../components/StatsCard';
import { useAutoRefresh } from '../hooks/useMonitor';
import { getWebsites, deleteWebsite, checkWebsiteNow, getGroups } from '../services/api';
export default function Dashboard() {
const [modalOpen, setModalOpen] = useState(false);
const [editData, setEditData] = useState(null);
const [selectedGroup, setSelectedGroup] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
// 获取网站数据
const fetchData = useCallback(() => getWebsites(), []);
const { data: websites, loading, error, refresh } = useAutoRefresh(fetchData, 30000);
// 获取分组数据
const fetchGroups = useCallback(() => getGroups(), []);
const { data: groups } = useAutoRefresh(fetchGroups, 60000);
// 处理添加网站
const handleAdd = () => {
setEditData(null);
setModalOpen(true);
};
// 处理编辑网站
const handleEdit = (website) => {
setEditData(website);
setModalOpen(true);
};
// 处理删除网站
const handleDelete = async (id) => {
try {
await deleteWebsite(id);
refresh();
} catch (err) {
alert('删除失败: ' + err.message);
}
};
// 处理立即检测
const handleRefresh = async (id) => {
try {
await checkWebsiteNow(id);
setTimeout(refresh, 2000); // 2秒后刷新数据
} catch (err) {
alert('检测失败: ' + err.message);
}
};
// 按分组和搜索过滤网站
const filteredWebsites = useMemo(() => {
if (!websites) return [];
return websites.filter(site => {
// 分组过滤
if (selectedGroup !== 'all' && site.website?.group !== selectedGroup) {
return false;
}
// 搜索过滤
if (searchTerm) {
const term = searchTerm.toLowerCase();
return (
site.website?.name?.toLowerCase().includes(term) ||
site.website?.title?.toLowerCase().includes(term) ||
site.website?.urls?.some(u => u.url.toLowerCase().includes(term))
);
}
return true;
});
}, [websites, selectedGroup, searchTerm]);
// 按分组分类网站
const groupedWebsites = useMemo(() => {
const grouped = {};
filteredWebsites.forEach(site => {
const groupId = site.website?.group || 'normal';
if (!grouped[groupId]) {
grouped[groupId] = [];
}
grouped[groupId].push(site);
});
return grouped;
}, [filteredWebsites]);
// 获取分组名称
const getGroupName = (groupId) => {
const group = groups?.find(g => g.id === groupId);
return group?.name || groupId;
};
return (
<div className="min-h-screen bg-gradient-to-br from-emerald-50 via-green-50 to-teal-50">
{/* 顶部导航 */}
<header className="bg-white/80 backdrop-blur-md shadow-sm sticky top-0 z-40">
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-xl overflow-hidden shadow-lg shadow-emerald-200">
<img
src="/logo.png"
alt="萌芽Ping"
className="w-full h-full object-cover"
/>
</div>
<div>
<h1 className="text-xl font-bold bg-gradient-to-r from-emerald-600 to-green-600 bg-clip-text text-transparent">
萌芽Ping
</h1>
<p className="text-xs text-gray-500">网站监控面板</p>
</div>
</div>
{/* 添加按钮 */}
<button
onClick={handleAdd}
className="flex items-center px-4 py-2 bg-gradient-to-r from-emerald-500 to-green-500 text-white rounded-lg hover:from-emerald-600 hover:to-green-600 transition-all shadow-md shadow-emerald-200"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="hidden sm:inline">添加监控</span>
</button>
</div>
</div>
</header>
{/* 主内容区 */}
<main className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* 统计概览 */}
<section className="mb-6">
<StatsCard websites={websites} />
</section>
{/* 过滤和搜索 */}
<section className="mb-6">
<div className="flex flex-col sm:flex-row gap-4">
{/* 分组选择 */}
<div className="flex items-center space-x-2 overflow-x-auto pb-2 sm:pb-0">
<button
onClick={() => setSelectedGroup('all')}
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all ${
selectedGroup === 'all'
? 'bg-emerald-500 text-white shadow-md shadow-emerald-200'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
全部
</button>
{groups?.map(group => (
<button
key={group.id}
onClick={() => setSelectedGroup(group.id)}
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all ${
selectedGroup === group.id
? 'bg-emerald-500 text-white shadow-md shadow-emerald-200'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
{group.name}
</button>
))}
</div>
{/* 搜索框 */}
<div className="flex-1 sm:max-w-xs">
<div className="relative">
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="搜索网站..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-all"
/>
</div>
</div>
</div>
</section>
{/* 网站列表 */}
<section>
{loading && !websites ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-500"></div>
<span className="ml-3 text-gray-500">加载中...</span>
</div>
) : error ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 flex items-center justify-center">
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<p className="text-gray-500 mb-4">加载失败: {error}</p>
<button
onClick={refresh}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition-colors"
>
重试
</button>
</div>
) : filteredWebsites.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
<p className="text-gray-500 mb-4">
{searchTerm ? '没有找到匹配的网站' : '还没有添加任何监控网站'}
</p>
{!searchTerm && (
<button
onClick={handleAdd}
className="px-4 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition-colors"
>
添加第一个网站
</button>
)}
</div>
) : (
<div className="space-y-6">
{Object.keys(groupedWebsites).map(groupId => (
<div key={groupId}>
{selectedGroup === 'all' && (
<h2 className="text-sm font-medium text-gray-600 mb-3 flex items-center">
<span className="w-2 h-2 rounded-full bg-emerald-500 mr-2"></span>
{getGroupName(groupId)}
<span className="ml-2 text-gray-400">({groupedWebsites[groupId].length})</span>
</h2>
)}
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4">
{groupedWebsites[groupId].map(website => (
<WebsiteCard
key={website.website?.id}
website={website}
onRefresh={handleRefresh}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
</div>
))}
</div>
)}
</section>
</main>
{/* 底部信息 */}
<footer className="py-6 text-center text-sm text-gray-500">
<p>萌芽Ping © 2026 </p>
</footer>
{/* 添加/编辑弹窗 */}
<WebsiteModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSuccess={refresh}
editData={editData}
/>
</div>
);
}

View File

@@ -0,0 +1,77 @@
// API服务
// 根据环境变量判断使用哪个 API 地址
const API_BASE = import.meta.env.PROD
? 'https://ping.api.shumengya.top/api' // 生产环境
: 'http://localhost:8080/api'; // 开发环境
// 通用请求方法
async function request(url, options = {}) {
const response = await fetch(`${API_BASE}${url}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
const data = await response.json();
if (data.code !== 0) {
throw new Error(data.message || '请求失败');
}
return data.data;
}
// 获取所有网站状态
export async function getWebsites() {
return request('/websites');
}
// 获取单个网站状态
export async function getWebsite(id) {
return request(`/websites/${id}`);
}
// 创建网站
export async function createWebsite(data) {
return request('/websites', {
method: 'POST',
body: JSON.stringify(data),
});
}
// 更新网站
export async function updateWebsite(id, data) {
return request(`/websites/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
// 删除网站
export async function deleteWebsite(id) {
return request(`/websites/${id}`, {
method: 'DELETE',
});
}
// 立即检测网站
export async function checkWebsiteNow(id) {
return request(`/websites/${id}/check`, {
method: 'POST',
});
}
// 获取所有分组
export async function getGroups() {
return request('/groups');
}
// 添加分组
export async function addGroup(name) {
return request('/groups', {
method: 'POST',
body: JSON.stringify({ name }),
});
}

View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
})

9
开启前端.bat Normal file
View File

@@ -0,0 +1,9 @@
@echo off
chcp 65001 >nul
echo ====================================
echo 🌱 萌芽Ping 前端服务 (REACT)
echo ====================================
cd mengyaping-frontend
echo 启动开发服务器...
npm run dev
pause

11
开启后端.bat Normal file
View File

@@ -0,0 +1,11 @@
@echo off
chcp 65001 >nul
echo ====================================
echo 🌱 萌芽Ping 后端服务 (GIN)
echo ====================================
cd mengyaping-backend
echo 正在下载依赖...
go mod tidy
echo 启动服务...
go run main.go
pause

10
构建前端.bat Normal file
View File

@@ -0,0 +1,10 @@
@echo off
chcp 65001 >nul
echo ====================================
echo 构建前端项目 (REACT)
echo ====================================
cd mengyaping-frontend
npm run build
echo.
echo ✅ 构建完成!输出目录: dist
pause