chore: sync local changes (2026-03-12)

This commit is contained in:
2026-03-12 18:58:53 +08:00
parent 74f15c282e
commit d861a9937b
38 changed files with 3570 additions and 2926 deletions

47
AGENTS.md Normal file
View File

@@ -0,0 +1,47 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository is split into two apps:
- `mengyaping-frontend/`: React + Vite UI. Main code is in `src/` (`components/`, `pages/`, `hooks/`, `services/`), with static assets in `public/`.
- `mengyaping-backend/`: Go + Gin API and monitor service. Core folders are `handlers/`, `services/`, `router/`, `models/`, `storage/`, `config/`, and `utils/`.
Runtime data is persisted under `mengyaping-backend/data/` (`websites.json`, `records.json`, `groups.json`, `config.json`). Keep data format changes backward-compatible.
## Build, Test, and Development Commands
Frontend (run inside `mengyaping-frontend/`):
- `npm install`: install dependencies.
- `npm run dev`: start Vite dev server.
- `npm run build`: create production build in `dist/`.
- `npm run lint`: run ESLint checks.
Backend (run inside `mengyaping-backend/`):
- `go mod tidy`: sync Go modules.
- `go run main.go`: start API server (default `0.0.0.0:8080`).
- `go test ./...`: run all backend tests.
- `docker compose up -d --build`: build and run containerized backend.
## Coding Style & Naming Conventions
- Frontend: 2-space indentation, ES module imports, React component files in PascalCase (for example `WebsiteCard.jsx`), hooks in `useXxx.js`, utility/service functions in camelCase.
- Backend: format with `gofmt`; keep package names lowercase; exported identifiers in PascalCase, internal helpers in camelCase.
- Keep handlers thin and place business logic in `services/`.
## Testing Guidelines
There are currently no committed frontend tests and minimal backend test coverage. Add tests for every non-trivial change:
- Backend: `*_test.go` next to implementation; focus on handlers and service logic.
- Frontend: if introducing test tooling, prefer Vitest + Testing Library with `*.test.jsx` naming.
## Commit & Pull Request Guidelines
Current history uses short, imperative commit text (for example `first commit`). Continue with concise, scoped messages such as:
- `feat(frontend): add status filter`
- `fix(backend): validate monitor interval`
Each PR should include:
- Clear summary and impacted area (`frontend`, `backend`, or both).
- Validation steps and commands run.
- Screenshots/GIFs for UI changes.
- Linked issue/ticket when available.
## Security & Configuration Tips
- Do not commit secrets, tokens, or private endpoints.
- Frontend dev API target is `http://localhost:8080/api` in `mengyaping-frontend/src/services/api.js`.
- Commit only sanitized sample data in `mengyaping-backend/data/`.

110
README.md Normal file
View File

@@ -0,0 +1,110 @@
# 萌芽PingMengYaPing
一个轻量、可自部署的网站可用性监控面板。
支持多网站/多 URL 监控、分组管理、实时状态查看、24h/7d 可用率统计与延迟展示。
## 功能特性
- 多网站监控:每个网站可配置多个 URL分别检测
- 自动巡检:默认每 5 分钟检测一次(可配置)
- 状态面板:在线/离线、状态码、响应延迟、最后检测时间
- 可用率统计:按 24 小时与 7 天维度聚合
- 分组与检索:支持分组筛选与关键词搜索
- 手动触发:支持单网站“立即检测”
## 技术栈
- 前端React 19 + Vite 7 + Tailwind CSS 4
- 后端Go 1.25 + Gin
- 存储:本地 JSON 文件(`mengyaping-backend/data/`
- 部署Docker / Docker Compose后端
## 项目结构
```text
.
├─ mengyaping-frontend/ # 前端应用
│ ├─ src/components/ # 卡片、图表、弹窗等组件
│ ├─ src/pages/ # 页面Dashboard
│ ├─ src/services/api.js # API 请求封装
│ └─ public/ # 静态资源logo、favicon
├─ mengyaping-backend/ # 后端服务
│ ├─ handlers/ services/ router/
│ ├─ models/ storage/ config/ utils/
│ └─ data/ # 配置与监控数据JSON
├─ 开启前端.bat
├─ 开启后端.bat
└─ 构建前端.bat
```
## 快速开始(本地开发)
### 1) 启动后端
```bash
cd mengyaping-backend
go mod tidy
go run main.go
```
默认地址:`http://localhost:8080`
### 2) 启动前端
```bash
cd mengyaping-frontend
npm install
npm run dev
```
前端开发地址通常为:`http://localhost:5173`
## 常用命令
```bash
# 前端
cd mengyaping-frontend
npm run dev # 开发模式
npm run build # 生产构建
npm run lint # 代码检查
# 后端
cd mengyaping-backend
go test ./... # 运行测试
```
Windows 用户也可直接使用仓库根目录下的 `开启前端.bat``开启后端.bat``构建前端.bat`
## API 概览
基础前缀:`/api`
- `GET /health`:健康检查
- `GET /websites`:获取全部网站状态
- `GET /websites/:id`:获取单网站状态
- `POST /websites`:创建网站
- `PUT /websites/:id`:更新网站
- `DELETE /websites/:id`:删除网站
- `POST /websites/:id/check`:立即检测
- `GET /groups`:获取分组
- `POST /groups`:新增分组
## 配置说明
后端支持环境变量配置(如 `SERVER_PORT``MONITOR_INTERVAL``MONITOR_TIMEOUT` 等),并会读取 `mengyaping-backend/data/config.json`
当前实现中,`config.json` 的值会覆盖环境变量同名项。
## Docker 部署(后端)
```bash
cd mengyaping-backend
docker compose up -d --build
```
`docker-compose.yml` 默认映射端口 `6161 -> 8080`
## 展示建议GitHub
- 建议在仓库中新增 `docs/images/` 并放置页面截图
- 可在本 README 顶部补充截图、动图或在线演示链接,提升展示效果

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,302 +1,418 @@
package services package services
import ( import (
"log" "log"
"sync" "sort"
"time" "sync"
"time"
"mengyaping-backend/config"
"mengyaping-backend/models" "mengyaping-backend/config"
"mengyaping-backend/storage" "mengyaping-backend/models"
"mengyaping-backend/utils" "mengyaping-backend/storage"
) "mengyaping-backend/utils"
)
// MonitorService 监控服务
type MonitorService struct { // MonitorService 监控服务
httpClient *utils.HTTPClient type MonitorService struct {
storage *storage.Storage httpClient *utils.HTTPClient
stopCh chan struct{} storage *storage.Storage
running bool stopCh chan struct{}
mu sync.Mutex running bool
} mu sync.Mutex
}
var (
monitorService *MonitorService var (
monitorOnce sync.Once monitorService *MonitorService
) monitorOnce sync.Once
)
// GetMonitorService 获取监控服务单例
func GetMonitorService() *MonitorService { // GetMonitorService 获取监控服务单例
monitorOnce.Do(func() { func GetMonitorService() *MonitorService {
cfg := config.GetConfig() monitorOnce.Do(func() {
monitorService = &MonitorService{ cfg := config.GetConfig()
httpClient: utils.NewHTTPClient(cfg.Monitor.Timeout), monitorService = &MonitorService{
storage: storage.GetStorage(), httpClient: utils.NewHTTPClient(cfg.Monitor.Timeout),
stopCh: make(chan struct{}), storage: storage.GetStorage(),
} stopCh: make(chan struct{}),
}) }
return monitorService })
} return monitorService
}
// Start 启动监控服务
func (s *MonitorService) Start() { // Start 启动监控服务
s.mu.Lock() func (s *MonitorService) Start() {
if s.running { s.mu.Lock()
s.mu.Unlock() if s.running {
return s.mu.Unlock()
} return
s.running = true }
s.mu.Unlock() s.running = true
s.mu.Unlock()
log.Println("监控服务已启动")
log.Println("监控服务已启动")
// 立即执行一次检测
go s.checkAll() // 立即执行一次检测
go s.checkAll()
// 定时检测
cfg := config.GetConfig() // 定时检测
ticker := time.NewTicker(cfg.Monitor.Interval) cfg := config.GetConfig()
defer ticker.Stop() ticker := time.NewTicker(cfg.Monitor.Interval)
defer ticker.Stop()
for {
select { for {
case <-ticker.C: select {
go s.checkAll() case <-ticker.C:
case <-s.stopCh: go s.checkAll()
log.Println("监控服务已停止") case <-s.stopCh:
return log.Println("监控服务已停止")
} return
} }
} }
}
// Stop 停止监控服务
func (s *MonitorService) Stop() { // Stop 停止监控服务
s.mu.Lock() func (s *MonitorService) Stop() {
defer s.mu.Unlock() s.mu.Lock()
defer s.mu.Unlock()
if s.running {
close(s.stopCh) if s.running {
s.running = false close(s.stopCh)
} s.running = false
} }
}
// checkAll 检查所有网站
func (s *MonitorService) checkAll() { // checkAll 检查所有网站(错峰执行,避免并发暴涨)
websites := s.storage.GetWebsites() func (s *MonitorService) checkAll() {
websites := s.storage.GetWebsites()
var wg sync.WaitGroup semaphore := make(chan struct{}, 3) // 最多 3 个并发检测
semaphore := make(chan struct{}, 10) // 限制并发数
for i, website := range websites {
for _, website := range websites { // 每个网站之间间隔 1 秒,把检测分散开
for _, urlInfo := range website.URLs { if i > 0 {
wg.Add(1) time.Sleep(1 * time.Second)
go func(w models.Website, u models.URLInfo) { }
defer wg.Done()
semaphore <- struct{}{} var wg sync.WaitGroup
defer func() { <-semaphore }() for _, urlInfo := range website.URLs {
wg.Add(1)
s.checkURL(w, u) go func(w models.Website, u models.URLInfo) {
}(website, urlInfo) defer wg.Done()
} semaphore <- struct{}{}
} defer func() { <-semaphore }()
s.checkURL(w, u)
wg.Wait() }(website, urlInfo)
}
// 保存记录 wg.Wait()
s.storage.SaveAll() }
}
// 检测完毕后,逐个解析 DNS
// checkURL 检查单个URL s.resolveAllWebsiteIPs(websites)
func (s *MonitorService) checkURL(website models.Website, urlInfo models.URLInfo) {
result := s.httpClient.CheckWebsite(urlInfo.URL) // 保存记录
s.storage.SaveAll()
record := models.MonitorRecord{ log.Printf("本轮检测完成,共 %d 个网站", len(websites))
WebsiteID: website.ID, }
URLID: urlInfo.ID,
URL: urlInfo.URL, // resolveAllWebsiteIPs 逐个解析所有网站域名 IP每次都刷新
StatusCode: result.StatusCode, func (s *MonitorService) resolveAllWebsiteIPs(websites []models.Website) {
Latency: result.Latency.Milliseconds(), for i, website := range websites {
IsUp: result.Error == nil && utils.IsSuccessStatus(result.StatusCode), if len(website.URLs) == 0 {
CheckedAt: time.Now(), continue
} }
if i > 0 {
if result.Error != nil { time.Sleep(500 * time.Millisecond)
record.Error = result.Error.Error() }
} s.resolveWebsiteIP(website)
}
s.storage.AddRecord(record) }
// 更新网站信息标题和Favicon // resolveWebsiteIP 解析单个网站的域名 IP
if result.Title != "" || result.Favicon != "" { func (s *MonitorService) resolveWebsiteIP(website models.Website) {
w := s.storage.GetWebsite(website.ID) if len(website.URLs) == 0 {
if w != nil { return
needUpdate := false }
if result.Title != "" && w.Title != result.Title {
w.Title = result.Title ips, err := utils.ResolveDomainIPs(website.URLs[0].URL)
needUpdate = true if err != nil {
} log.Printf("DNS解析失败 [%s]: %v", website.Name, err)
if result.Favicon != "" && w.Favicon != result.Favicon { return
w.Favicon = result.Favicon }
needUpdate = true
} if len(ips) == 0 {
if needUpdate { return
w.UpdatedAt = time.Now() }
s.storage.UpdateWebsite(*w)
} w := s.storage.GetWebsite(website.ID)
} if w == nil {
} return
}
log.Printf("检测 [%s] %s - 状态码: %d, 延迟: %dms, 可用: %v",
website.Name, urlInfo.URL, result.StatusCode, result.Latency.Milliseconds(), record.IsUp) w.IPAddresses = ips
} w.UpdatedAt = time.Now()
s.storage.UpdateWebsite(*w)
// CheckWebsiteNow 立即检查指定网站 log.Printf("DNS解析 [%s] → %v", website.Name, ips)
func (s *MonitorService) CheckWebsiteNow(websiteID string) { }
website := s.storage.GetWebsite(websiteID)
if website == nil { // checkURL 检查单个URL带重试
return func (s *MonitorService) checkURL(website models.Website, urlInfo models.URLInfo) {
} cfg := config.GetConfig()
maxRetries := cfg.Monitor.RetryCount
for _, urlInfo := range website.URLs {
go s.checkURL(*website, urlInfo) var result utils.CheckResult
}
} for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
// GetWebsiteStatus 获取网站状态 time.Sleep(time.Duration(attempt) * 2 * time.Second)
func (s *MonitorService) GetWebsiteStatus(websiteID string) *models.WebsiteStatus { log.Printf("重试 [%s] %s - 第 %d 次重试", website.Name, urlInfo.URL, attempt)
website := s.storage.GetWebsite(websiteID) }
if website == nil {
return nil result = s.httpClient.CheckWebsiteStatus(urlInfo.URL)
} if result.Error == nil && utils.IsSuccessStatus(result.StatusCode) {
break
status := &models.WebsiteStatus{ }
Website: *website, }
URLStatuses: []models.URLStatus{},
} record := models.MonitorRecord{
WebsiteID: website.ID,
now := time.Now() URLID: urlInfo.ID,
since24h := now.Add(-24 * time.Hour) URL: urlInfo.URL,
since7d := now.Add(-7 * 24 * time.Hour) StatusCode: result.StatusCode,
Latency: result.Latency.Milliseconds(),
var totalUptime24h, totalUptime7d float64 IsUp: result.Error == nil && utils.IsSuccessStatus(result.StatusCode),
var urlCount int CheckedAt: time.Now(),
}
for _, urlInfo := range website.URLs {
urlStatus := s.getURLStatus(website.ID, urlInfo, since24h, since7d) if result.Error != nil {
status.URLStatuses = append(status.URLStatuses, urlStatus) record.Error = result.Error.Error()
}
totalUptime24h += urlStatus.Uptime24h
totalUptime7d += urlStatus.Uptime7d s.storage.AddRecord(record)
urlCount++
} // 仅当网站无标题时才做完整检测来获取元数据
if record.IsUp && website.Title == "" {
if urlCount > 0 { fullResult := s.httpClient.CheckWebsite(urlInfo.URL)
status.Uptime24h = totalUptime24h / float64(urlCount) if fullResult.Title != "" {
status.Uptime7d = totalUptime7d / float64(urlCount) w := s.storage.GetWebsite(website.ID)
} if w != nil {
w.Title = fullResult.Title
// 获取最后检测时间 w.UpdatedAt = time.Now()
for _, urlStatus := range status.URLStatuses { s.storage.UpdateWebsite(*w)
if urlStatus.CurrentState.CheckedAt.After(status.LastChecked) { }
status.LastChecked = urlStatus.CurrentState.CheckedAt }
} }
}
log.Printf("检测 [%s] %s - 状态码: %d, 延迟: %dms, 可用: %v",
return status website.Name, urlInfo.URL, result.StatusCode, result.Latency.Milliseconds(), record.IsUp)
} }
// getURLStatus 获取URL状态 // CheckWebsiteNow 立即检查指定网站(状态 + DNS等待完成后保存
func (s *MonitorService) getURLStatus(websiteID string, urlInfo models.URLInfo, since24h, since7d time.Time) models.URLStatus { func (s *MonitorService) CheckWebsiteNow(websiteID string) {
urlStatus := models.URLStatus{ website := s.storage.GetWebsite(websiteID)
URLInfo: urlInfo, if website == nil {
} return
}
// 获取最新记录
latest := s.storage.GetLatestRecord(websiteID, urlInfo.ID) // 逐个检测该网站的所有 URL
if latest != nil { for _, urlInfo := range website.URLs {
urlStatus.CurrentState = *latest s.checkURL(*website, urlInfo)
} }
// 获取24小时记录 // 刷新 DNS
records24h := s.storage.GetRecords(websiteID, urlInfo.ID, since24h) s.resolveWebsiteIP(*website)
urlStatus.History24h = records24h
s.storage.SaveAll()
// 计算24小时可用率 }
if len(records24h) > 0 {
upCount := 0 // GetWebsiteStatus 获取网站状态
var totalLatency int64 func (s *MonitorService) GetWebsiteStatus(websiteID string) *models.WebsiteStatus {
for _, r := range records24h { website := s.storage.GetWebsite(websiteID)
if r.IsUp { if website == nil {
upCount++ return nil
} }
totalLatency += r.Latency
} status := &models.WebsiteStatus{
urlStatus.Uptime24h = float64(upCount) / float64(len(records24h)) * 100 Website: *website,
urlStatus.AvgLatency = totalLatency / int64(len(records24h)) URLStatuses: []models.URLStatus{},
} }
// 获取7天记录并按小时统计 now := time.Now()
records7d := s.storage.GetRecords(websiteID, urlInfo.ID, since7d) since24h := now.Add(-24 * time.Hour)
urlStatus.History7d = s.aggregateByHour(records7d) since7d := now.Add(-7 * 24 * time.Hour)
since90d := now.Add(-90 * 24 * time.Hour)
// 计算7天可用率
if len(records7d) > 0 { var totalUptime24h, totalUptime7d float64
upCount := 0 var urlCount int
for _, r := range records7d { var allRecords90d []models.MonitorRecord
if r.IsUp {
upCount++ for _, urlInfo := range website.URLs {
} urlStatus := s.getURLStatus(website.ID, urlInfo, since24h, since7d)
} status.URLStatuses = append(status.URLStatuses, urlStatus)
urlStatus.Uptime7d = float64(upCount) / float64(len(records7d)) * 100
} totalUptime24h += urlStatus.Uptime24h
totalUptime7d += urlStatus.Uptime7d
return urlStatus urlCount++
}
records90d := s.storage.GetRecords(website.ID, urlInfo.ID, since90d)
// aggregateByHour 按小时聚合记录 allRecords90d = append(allRecords90d, records90d...)
func (s *MonitorService) aggregateByHour(records []models.MonitorRecord) []models.HourlyStats { }
hourlyMap := make(map[string]*models.HourlyStats)
if urlCount > 0 {
for _, r := range records { status.Uptime24h = totalUptime24h / float64(urlCount)
hourKey := r.CheckedAt.Truncate(time.Hour).Format(time.RFC3339) status.Uptime7d = totalUptime7d / float64(urlCount)
}
if _, exists := hourlyMap[hourKey]; !exists {
hourlyMap[hourKey] = &models.HourlyStats{ // 90 天逐日统计
Hour: r.CheckedAt.Truncate(time.Hour), status.DailyHistory = s.aggregateByDay(allRecords90d)
} if len(allRecords90d) > 0 {
} upCount := 0
for _, r := range allRecords90d {
stats := hourlyMap[hourKey] if r.IsUp {
stats.TotalCount++ upCount++
if r.IsUp { }
stats.UpCount++ }
} status.Uptime90d = float64(upCount) / float64(len(allRecords90d)) * 100
stats.AvgLatency += r.Latency }
}
// 获取最后检测时间
var result []models.HourlyStats for _, urlStatus := range status.URLStatuses {
for _, stats := range hourlyMap { if urlStatus.CurrentState.CheckedAt.After(status.LastChecked) {
if stats.TotalCount > 0 { status.LastChecked = urlStatus.CurrentState.CheckedAt
stats.AvgLatency /= int64(stats.TotalCount) }
stats.Uptime = float64(stats.UpCount) / float64(stats.TotalCount) * 100 }
}
result = append(result, *stats) return status
} }
return result // getURLStatus 获取URL状态
} func (s *MonitorService) getURLStatus(websiteID string, urlInfo models.URLInfo, since24h, since7d time.Time) models.URLStatus {
urlStatus := models.URLStatus{
// GetAllWebsiteStatuses 获取所有网站状态 URLInfo: urlInfo,
func (s *MonitorService) GetAllWebsiteStatuses() []models.WebsiteStatus { }
websites := s.storage.GetWebsites()
var statuses []models.WebsiteStatus // 获取最新记录
latest := s.storage.GetLatestRecord(websiteID, urlInfo.ID)
for _, website := range websites { if latest != nil {
status := s.GetWebsiteStatus(website.ID) urlStatus.CurrentState = *latest
if status != nil { }
statuses = append(statuses, *status)
} // 获取24小时记录
} records24h := s.storage.GetRecords(websiteID, urlInfo.ID, since24h)
urlStatus.History24h = records24h
return statuses
} // 计算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
}
// aggregateByDay 按天聚合记录
func (s *MonitorService) aggregateByDay(records []models.MonitorRecord) []models.DailyStats {
dayMap := make(map[string]*models.DailyStats)
for _, r := range records {
dayTime := time.Date(r.CheckedAt.Year(), r.CheckedAt.Month(), r.CheckedAt.Day(), 0, 0, 0, 0, r.CheckedAt.Location())
dayKey := dayTime.Format("2006-01-02")
if _, exists := dayMap[dayKey]; !exists {
dayMap[dayKey] = &models.DailyStats{
Date: dayTime,
}
}
stats := dayMap[dayKey]
stats.TotalCount++
if r.IsUp {
stats.UpCount++
}
stats.AvgLatency += r.Latency
}
result := make([]models.DailyStats, 0, len(dayMap))
for _, stats := range dayMap {
if stats.TotalCount > 0 {
stats.AvgLatency /= int64(stats.TotalCount)
stats.Uptime = float64(stats.UpCount) / float64(stats.TotalCount) * 100
}
result = append(result, *stats)
}
sort.Slice(result, func(i, j int) bool {
return result[i].Date.Before(result[j].Date)
})
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

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

@@ -1,285 +1,302 @@
package storage package storage
import ( import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"time" "time"
"mengyaping-backend/config" "mengyaping-backend/config"
"mengyaping-backend/models" "mengyaping-backend/models"
) )
// Storage 数据存储 // Storage 数据存储
type Storage struct { type Storage struct {
dataPath string dataPath string
mu sync.RWMutex mu sync.RWMutex
websites []models.Website websites []models.Website
records map[string][]models.MonitorRecord // key: websiteID_urlID records map[string][]models.MonitorRecord // key: websiteID_urlID
groups []models.Group groups []models.Group
} }
var ( var (
store *Storage store *Storage
once sync.Once once sync.Once
) )
// GetStorage 获取存储单例 // GetStorage 获取存储单例
func GetStorage() *Storage { func GetStorage() *Storage {
once.Do(func() { once.Do(func() {
cfg := config.GetConfig() cfg := config.GetConfig()
store = &Storage{ store = &Storage{
dataPath: cfg.DataPath, dataPath: cfg.DataPath,
websites: []models.Website{}, websites: []models.Website{},
records: make(map[string][]models.MonitorRecord), records: make(map[string][]models.MonitorRecord),
groups: models.DefaultGroups, groups: models.DefaultGroups,
} }
store.ensureDataDir() store.ensureDataDir()
store.load() store.load()
}) })
return store return store
} }
// ensureDataDir 确保数据目录存在 // ensureDataDir 确保数据目录存在
func (s *Storage) ensureDataDir() { func (s *Storage) ensureDataDir() {
os.MkdirAll(s.dataPath, 0755) os.MkdirAll(s.dataPath, 0755)
} }
// load 加载数据 // load 加载数据
func (s *Storage) load() { func (s *Storage) load() {
s.loadWebsites() s.loadWebsites()
s.loadRecords() s.loadRecords()
s.loadGroups() s.loadGroups()
} }
// loadWebsites 加载网站数据 // loadWebsites 加载网站数据
func (s *Storage) loadWebsites() { func (s *Storage) loadWebsites() {
filePath := filepath.Join(s.dataPath, "websites.json") filePath := filepath.Join(s.dataPath, "websites.json")
data, err := os.ReadFile(filePath) data, err := os.ReadFile(filePath)
if err != nil { if err != nil {
return return
} }
json.Unmarshal(data, &s.websites) json.Unmarshal(data, &s.websites)
} s.migrateWebsiteGroups()
}
// loadRecords 加载监控记录
func (s *Storage) loadRecords() { // migrateWebsiteGroups 将旧的单分组字段迁移到多分组数组
filePath := filepath.Join(s.dataPath, "records.json") func (s *Storage) migrateWebsiteGroups() {
data, err := os.ReadFile(filePath) migrated := false
if err != nil { for i := range s.websites {
return w := &s.websites[i]
} if len(w.Groups) == 0 && w.Group != "" {
json.Unmarshal(data, &s.records) w.Groups = []string{w.Group}
w.Group = ""
// 清理过期记录 migrated = true
s.cleanOldRecords() }
} }
if migrated {
// loadGroups 加载分组 s.saveWebsites()
func (s *Storage) loadGroups() { }
filePath := filepath.Join(s.dataPath, "groups.json") }
data, err := os.ReadFile(filePath)
if err != nil { // loadRecords 加载监控记录
// 使用默认分组 func (s *Storage) loadRecords() {
s.groups = models.DefaultGroups filePath := filepath.Join(s.dataPath, "records.json")
s.saveGroups() data, err := os.ReadFile(filePath)
return if err != nil {
} return
json.Unmarshal(data, &s.groups) }
} json.Unmarshal(data, &s.records)
// saveWebsites 保存网站数据 // 清理过期记录
func (s *Storage) saveWebsites() error { s.cleanOldRecords()
filePath := filepath.Join(s.dataPath, "websites.json") }
data, err := json.MarshalIndent(s.websites, "", " ")
if err != nil { // loadGroups 加载分组
return err func (s *Storage) loadGroups() {
} filePath := filepath.Join(s.dataPath, "groups.json")
return os.WriteFile(filePath, data, 0644) data, err := os.ReadFile(filePath)
} if err != nil {
// 使用默认分组
// saveRecords 保存监控记录 s.groups = models.DefaultGroups
func (s *Storage) saveRecords() error { s.saveGroups()
filePath := filepath.Join(s.dataPath, "records.json") return
data, err := json.MarshalIndent(s.records, "", " ") }
if err != nil { json.Unmarshal(data, &s.groups)
return err }
}
return os.WriteFile(filePath, data, 0644) // saveWebsites 保存网站数据
} func (s *Storage) saveWebsites() error {
filePath := filepath.Join(s.dataPath, "websites.json")
// saveGroups 保存分组 data, err := json.MarshalIndent(s.websites, "", " ")
func (s *Storage) saveGroups() error { if err != nil {
filePath := filepath.Join(s.dataPath, "groups.json") return err
data, err := json.MarshalIndent(s.groups, "", " ") }
if err != nil { return os.WriteFile(filePath, data, 0644)
return err }
}
return os.WriteFile(filePath, data, 0644) // saveRecords 保存监控记录
} func (s *Storage) saveRecords() error {
filePath := filepath.Join(s.dataPath, "records.json")
// cleanOldRecords 清理过期记录 data, err := json.MarshalIndent(s.records, "", " ")
func (s *Storage) cleanOldRecords() { if err != nil {
cfg := config.GetConfig() return err
cutoff := time.Now().AddDate(0, 0, -cfg.Monitor.HistoryDays) }
return os.WriteFile(filePath, data, 0644)
for key, records := range s.records { }
var newRecords []models.MonitorRecord
for _, r := range records { // saveGroups 保存分组
if r.CheckedAt.After(cutoff) { func (s *Storage) saveGroups() error {
newRecords = append(newRecords, r) filePath := filepath.Join(s.dataPath, "groups.json")
} data, err := json.MarshalIndent(s.groups, "", " ")
} if err != nil {
s.records[key] = newRecords return err
} }
} return os.WriteFile(filePath, data, 0644)
}
// GetWebsites 获取所有网站
func (s *Storage) GetWebsites() []models.Website { // cleanOldRecords 清理过期记录
s.mu.RLock() func (s *Storage) cleanOldRecords() {
defer s.mu.RUnlock() cfg := config.GetConfig()
cutoff := time.Now().AddDate(0, 0, -cfg.Monitor.HistoryDays)
result := make([]models.Website, len(s.websites))
copy(result, s.websites) for key, records := range s.records {
return result var newRecords []models.MonitorRecord
} for _, r := range records {
if r.CheckedAt.After(cutoff) {
// GetWebsite 获取单个网站 newRecords = append(newRecords, r)
func (s *Storage) GetWebsite(id string) *models.Website { }
s.mu.RLock() }
defer s.mu.RUnlock() s.records[key] = newRecords
}
for _, w := range s.websites { }
if w.ID == id {
website := w // GetWebsites 获取所有网站
return &website func (s *Storage) GetWebsites() []models.Website {
} s.mu.RLock()
} defer s.mu.RUnlock()
return nil
} result := make([]models.Website, len(s.websites))
copy(result, s.websites)
// AddWebsite 添加网站 return result
func (s *Storage) AddWebsite(website models.Website) error { }
s.mu.Lock()
defer s.mu.Unlock() // GetWebsite 获取单个网站
func (s *Storage) GetWebsite(id string) *models.Website {
s.websites = append(s.websites, website) s.mu.RLock()
return s.saveWebsites() defer s.mu.RUnlock()
}
for _, w := range s.websites {
// UpdateWebsite 更新网站 if w.ID == id {
func (s *Storage) UpdateWebsite(website models.Website) error { website := w
s.mu.Lock() return &website
defer s.mu.Unlock() }
}
for i, w := range s.websites { return nil
if w.ID == website.ID { }
s.websites[i] = website
return s.saveWebsites() // AddWebsite 添加网站
} func (s *Storage) AddWebsite(website models.Website) error {
} s.mu.Lock()
return nil defer s.mu.Unlock()
}
s.websites = append(s.websites, website)
// DeleteWebsite 删除网站 return s.saveWebsites()
func (s *Storage) DeleteWebsite(id string) error { }
s.mu.Lock()
defer s.mu.Unlock() // UpdateWebsite 更新网站
func (s *Storage) UpdateWebsite(website models.Website) error {
for i, w := range s.websites { s.mu.Lock()
if w.ID == id { defer s.mu.Unlock()
s.websites = append(s.websites[:i], s.websites[i+1:]...)
// 删除相关记录 for i, w := range s.websites {
for key := range s.records { if w.ID == website.ID {
if len(key) > len(id) && key[:len(id)] == id { s.websites[i] = website
delete(s.records, key) return s.saveWebsites()
} }
} }
s.saveRecords() return nil
return s.saveWebsites() }
}
} // DeleteWebsite 删除网站
return nil func (s *Storage) DeleteWebsite(id string) error {
} s.mu.Lock()
defer s.mu.Unlock()
// AddRecord 添加监控记录
func (s *Storage) AddRecord(record models.MonitorRecord) error { for i, w := range s.websites {
s.mu.Lock() if w.ID == id {
defer s.mu.Unlock() s.websites = append(s.websites[:i], s.websites[i+1:]...)
// 删除相关记录
key := record.WebsiteID + "_" + record.URLID for key := range s.records {
s.records[key] = append(s.records[key], record) if len(key) > len(id) && key[:len(id)] == id {
delete(s.records, key)
// 每100条记录保存一次 }
if len(s.records[key])%100 == 0 { }
return s.saveRecords() s.saveRecords()
} return s.saveWebsites()
return nil }
} }
return nil
// GetRecords 获取监控记录 }
func (s *Storage) GetRecords(websiteID, urlID string, since time.Time) []models.MonitorRecord {
s.mu.RLock() // AddRecord 添加监控记录
defer s.mu.RUnlock() func (s *Storage) AddRecord(record models.MonitorRecord) error {
s.mu.Lock()
key := websiteID + "_" + urlID defer s.mu.Unlock()
records := s.records[key]
key := record.WebsiteID + "_" + record.URLID
var result []models.MonitorRecord s.records[key] = append(s.records[key], record)
for _, r := range records {
if r.CheckedAt.After(since) { // 每100条记录保存一次
result = append(result, r) if len(s.records[key])%100 == 0 {
} return s.saveRecords()
} }
return result return nil
} }
// GetLatestRecord 获取最新记录 // GetRecords 获取监控记录
func (s *Storage) GetLatestRecord(websiteID, urlID string) *models.MonitorRecord { func (s *Storage) GetRecords(websiteID, urlID string, since time.Time) []models.MonitorRecord {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
key := websiteID + "_" + urlID key := websiteID + "_" + urlID
records := s.records[key] records := s.records[key]
if len(records) == 0 { var result []models.MonitorRecord
return nil for _, r := range records {
} if r.CheckedAt.After(since) {
result = append(result, r)
latest := records[len(records)-1] }
return &latest }
} return result
}
// GetGroups 获取所有分组
func (s *Storage) GetGroups() []models.Group { // GetLatestRecord 获取最新记录
s.mu.RLock() func (s *Storage) GetLatestRecord(websiteID, urlID string) *models.MonitorRecord {
defer s.mu.RUnlock() s.mu.RLock()
defer s.mu.RUnlock()
result := make([]models.Group, len(s.groups))
copy(result, s.groups) key := websiteID + "_" + urlID
return result records := s.records[key]
}
if len(records) == 0 {
// AddGroup 添加分组 return nil
func (s *Storage) AddGroup(group models.Group) error { }
s.mu.Lock()
defer s.mu.Unlock() latest := records[len(records)-1]
return &latest
s.groups = append(s.groups, group) }
return s.saveGroups()
} // GetGroups 获取所有分组
func (s *Storage) GetGroups() []models.Group {
// SaveAll 保存所有数据 s.mu.RLock()
func (s *Storage) SaveAll() error { defer s.mu.RUnlock()
s.mu.Lock()
defer s.mu.Unlock() result := make([]models.Group, len(s.groups))
copy(result, s.groups)
if err := s.saveWebsites(); err != nil { return result
return err }
}
if err := s.saveRecords(); err != nil { // AddGroup 添加分组
return err func (s *Storage) AddGroup(group models.Group) error {
} s.mu.Lock()
return s.saveGroups() 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,60 @@
package utils
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"time"
)
const dnsAPIBase = "https://cf-dns.smyhub.com/api/dns?domain="
type dnsResponse struct {
Status string `json:"status"`
IPv4 []string `json:"ipv4"`
IPv6 []string `json:"ipv6"`
}
// ResolveDomainIPs 通过 DNS API 解析域名的 IPv4 + IPv6 地址
func ResolveDomainIPs(rawURL string) ([]string, error) {
parsed, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
hostname := parsed.Hostname()
if hostname == "" {
return nil, fmt.Errorf("no hostname in URL")
}
if net.ParseIP(hostname) != nil {
return []string{hostname}, nil
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(dnsAPIBase + hostname)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*10))
if err != nil {
return nil, err
}
var dnsResp dnsResponse
if err := json.Unmarshal(body, &dnsResp); err != nil {
return nil, err
}
if dnsResp.Status != "success" {
return nil, fmt.Errorf("DNS lookup failed for %s", hostname)
}
ips := append(dnsResp.IPv4, dnsResp.IPv6...)
return ips, nil
}

View File

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

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

View File

@@ -5,8 +5,12 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="萌芽Ping - 网站监控面板" /> <meta name="description" content="萌芽Ping - 网站监控面板" />
<meta name="theme-color" content="#10b981" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<title>萌芽Ping - 网站监控面板</title> <title>萌芽Ping - 网站监控面板</title>
<link rel="apple-touch-icon" href="/logo.png" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">

View File

@@ -5,9 +5,11 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:local": "vite --host 0.0.0.0 --port 5173 --strictPort",
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"preview:local": "vite preview --host 0.0.0.0 --port 4173 --strictPort"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

View File

@@ -0,0 +1,32 @@
{
"name": "萌芽Ping 网站监控",
"short_name": "萌芽Ping",
"description": "轻量网站可用性监控面板",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ecfdf5",
"theme_color": "#10b981",
"lang": "zh-CN",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@@ -0,0 +1,70 @@
const CACHE_NAME = 'mengyaping-shell-v1'
const SHELL_FILES = [
'/',
'/index.html',
'/manifest.webmanifest',
'/favicon.ico',
'/icons/icon-192.png',
'/icons/icon-512.png',
'/icons/icon-512-maskable.png',
]
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_FILES))
)
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames
.filter((cacheName) => cacheName !== CACHE_NAME)
.map((cacheName) => caches.delete(cacheName))
)
)
)
self.clients.claim()
})
self.addEventListener('fetch', (event) => {
const { request } = event
if (request.method !== 'GET') {
return
}
const url = new URL(request.url)
// Only cache same-origin requests, leave API calls untouched.
if (url.origin !== self.location.origin) {
return
}
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match('/index.html'))
)
return
}
event.respondWith(
caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse
}
return fetch(request).then((networkResponse) => {
if (!networkResponse || networkResponse.status !== 200) {
return networkResponse
}
const responseClone = networkResponse.clone()
caches.open(CACHE_NAME).then((cache) => cache.put(request, responseClone))
return networkResponse
})
})
)
})

View File

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

View File

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

View File

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

View File

@@ -1,207 +1,266 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { formatLatency, formatUptime, formatTime, getStatusColor, getUptimeColor, getLatencyColor } from '../hooks/useMonitor'; import { formatLatency, formatUptime, formatTime, getStatusColor, getUptimeColor, getLatencyColor } from '../hooks/useMonitor';
// 网站状态卡片组件 const HISTORY_DAYS = 90;
export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
const [expanded, setExpanded] = useState(false); function getBarColor(bar) {
if (bar.uptime === null) return 'bg-gray-200';
// 获取第一个URL的状态作为主状态 if (bar.uptime >= 99) return 'bg-emerald-400';
const primaryStatus = website.url_statuses?.[0]; if (bar.uptime >= 95) return 'bg-yellow-400';
const isUp = primaryStatus?.current_state?.is_up ?? false; if (bar.uptime >= 80) return 'bg-orange-400';
const statusCode = primaryStatus?.current_state?.status_code ?? 0; return 'bg-red-500';
const latency = primaryStatus?.current_state?.latency ?? 0; }
const primaryUrl = website.website?.urls?.[0]?.url || website.url_statuses?.[0]?.url_info?.url;
function getBarHoverColor(bar) {
return ( if (bar.uptime === null) return 'hover:bg-gray-300';
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow"> if (bar.uptime >= 99) return 'hover:bg-emerald-500';
{/* 卡片头部 */} if (bar.uptime >= 95) return 'hover:bg-yellow-500';
<div if (bar.uptime >= 80) return 'hover:bg-orange-500';
className="p-3 cursor-pointer" return 'hover:bg-red-600';
onClick={() => setExpanded(!expanded)} }
>
{/* 第一行:图标、名称、状态 */} export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
<div className="flex items-center justify-between mb-2"> const [expanded, setExpanded] = useState(false);
<div className="flex items-center space-x-2 min-w-0 flex-1"> const [hoveredBar, setHoveredBar] = useState(null);
{/* 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"> const primaryStatus = website.url_statuses?.[0];
{website.website?.favicon ? ( const isUp = primaryStatus?.current_state?.is_up ?? false;
<img const statusCode = primaryStatus?.current_state?.status_code ?? 0;
src={website.website.favicon} const latency = primaryStatus?.current_state?.latency ?? 0;
alt="" const primaryUrl = website.website?.urls?.[0]?.url || website.url_statuses?.[0]?.url_info?.url;
className="w-7 h-7 object-contain drop-shadow"
onError={(e) => { const dailyBars = useMemo(() => {
e.target.style.display = 'none'; const bars = [];
e.target.nextSibling.style.display = 'flex'; const today = new Date();
}} today.setHours(0, 0, 0, 0);
/>
) : null} const statsMap = {};
<span (website.daily_history || []).forEach(stat => {
className={`text-base font-bold text-emerald-600 ${website.website?.favicon ? 'hidden' : ''}`} const d = new Date(stat.date);
style={{ display: website.website?.favicon ? 'none' : 'flex' }} const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
> statsMap[key] = stat;
{website.website?.name?.[0] || '?'} });
</span>
</div> for (let i = HISTORY_DAYS - 1; i >= 0; i--) {
const date = new Date(today);
{/* 网站名称 */} date.setDate(date.getDate() - i);
<h3 className="font-semibold text-gray-800 truncate"> const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
{website.website?.name || '未知网站'} const stat = statsMap[key];
</h3> bars.push({
</div> date: key,
uptime: stat ? stat.uptime : null,
{/* 展开箭头 */} totalCount: stat?.total_count ?? 0,
<svg avgLatency: stat?.avg_latency ?? 0,
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" return bars;
> }, [website.daily_history]);
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> const statusDisplay = (() => {
</div> if (!primaryStatus?.current_state?.checked_at || primaryStatus.current_state.checked_at === '0001-01-01T00:00:00Z') {
return { text: '等待检测', color: 'text-gray-400' };
{/* 第二行:状态、延迟、访问按钮 */} }
<div className="flex items-center justify-between mb-2"> if (isUp) {
<div className="flex items-center space-x-2"> return { text: '运行正常', color: 'text-emerald-500' };
{/* 状态徽章 */} }
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(isUp, statusCode)}`}> return { text: '服务异常', color: 'text-red-500' };
{isUp ? `${statusCode}` : '离线'} })();
</span>
const uptime90d = website.uptime_90d;
{/* 延迟 */} const hasUptimeData = uptime90d != null && uptime90d > 0;
<span className={`text-xs font-medium ${getLatencyColor(latency)}`}>
{formatLatency(latency)} return (
</span> <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow">
</div> {/* 主体区域 */}
<div
{/* 访问按钮 */} className="p-3 sm:p-4 cursor-pointer select-none"
<button onClick={() => setExpanded(!expanded)}
onClick={(e) => { >
e.stopPropagation(); {/* 头部:图标 + 名称 + 状态 + 访问按钮 */}
if (primaryUrl) { <div className="flex items-center justify-between mb-2">
window.open(primaryUrl, '_blank', 'noopener,noreferrer'); <div className="flex items-center space-x-2.5 min-w-0 flex-1">
} <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">
}} {primaryUrl ? (
disabled={!primaryUrl} <img
className={`flex items-center space-x-1 px-2.5 py-1 text-xs font-medium rounded-full transition-all flex-shrink-0 ${ src={`https://cf-favicon.pages.dev/api/favicon?url=${encodeURIComponent(primaryUrl)}`}
primaryUrl alt=""
? 'text-white bg-gradient-to-r from-emerald-500 to-green-500 hover:from-emerald-600 hover:to-green-600 shadow-sm hover:shadow' className="w-7 h-7 object-contain drop-shadow"
: 'text-gray-400 bg-gray-100 cursor-not-allowed' onError={(e) => {
}`} e.target.style.display = 'none';
> e.target.nextSibling.style.display = 'flex';
<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> ) : null}
<span>访问</span> <span
</button> className={`text-base font-bold text-emerald-600 ${primaryUrl ? 'hidden' : ''}`}
</div> style={{ display: primaryUrl ? 'none' : 'flex' }}
>
{/* 网站描述 */} {website.website?.name?.[0] || '?'}
<p className="text-xs text-gray-500 truncate mb-2"> </span>
{website.website?.title || website.website?.urls?.[0]?.url || '-'} </div>
</p> <div className="min-w-0 flex-1">
<h3 className="font-semibold text-gray-800 truncate">
{/* 可用率条 */} {website.website?.name || '未知网站'}
<div className="mt-3"> </h3>
<div className="flex justify-between text-xs text-gray-500 mb-1"> <span className={`text-xs font-medium ${statusDisplay.color}`}>
<span>24h可用率</span> {statusDisplay.text}
<span className={getUptimeColor(website.uptime_24h || 0)}> </span>
{formatUptime(website.uptime_24h)} </div>
</span> </div>
</div> {primaryUrl && (
<div className="h-2 bg-gray-100 rounded-full overflow-hidden"> <button
<div onClick={(e) => {
className="h-full bg-gradient-to-r from-emerald-400 to-green-500 rounded-full transition-all" e.stopPropagation();
style={{ width: `${Math.min(website.uptime_24h || 0, 100)}%` }} window.open(primaryUrl, '_blank', 'noopener,noreferrer');
/> }}
</div> className="flex items-center space-x-1 px-2.5 py-1 text-xs font-medium text-white bg-gradient-to-r from-emerald-500 to-green-500 rounded-full hover:from-emerald-600 hover:to-green-600 transition-all shadow-sm hover:shadow flex-shrink-0 ml-2"
</div> >
</div> <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>
{expanded && ( <span>访问</span>
<div className="border-t border-gray-100 bg-gray-50 p-4"> </button>
{/* URL列表 */} )}
<div className="space-y-3"> </div>
{website.url_statuses?.map((urlStatus, index) => (
<div {/* 90 天可用率竖条图 */}
key={urlStatus.url_info?.id || index} <div className="relative">
className="bg-white rounded-lg p-3 border border-gray-100" <div className="flex items-stretch gap-[1px] h-[26px]">
> {dailyBars.map((bar, i) => (
<div className="flex items-center justify-between mb-2"> <div
<span className="text-sm text-gray-600 truncate flex-1 mr-2"> key={i}
{urlStatus.url_info?.url} className={`flex-1 rounded-[1px] transition-colors ${getBarColor(bar)} ${getBarHoverColor(bar)}`}
</span> onMouseEnter={() => setHoveredBar(i)}
<span className={`px-2 py-0.5 rounded text-xs font-medium ${ onMouseLeave={() => setHoveredBar(null)}
getStatusColor(urlStatus.current_state?.is_up, urlStatus.current_state?.status_code) />
}`}> ))}
{urlStatus.current_state?.is_up ? urlStatus.current_state?.status_code : '离线'} </div>
</span>
</div> {/* 悬浮提示 */}
{hoveredBar !== null && dailyBars[hoveredBar] && (
<div className="grid grid-cols-3 gap-2 text-xs"> <div
<div> className="absolute -top-10 px-2 py-1 bg-gray-800 text-white text-[10px] rounded shadow-lg whitespace-nowrap pointer-events-none z-10"
<span className="text-gray-400">延迟</span> style={{
<p className={`font-medium ${getLatencyColor(urlStatus.current_state?.latency)}`}> left: `${(hoveredBar / HISTORY_DAYS) * 100}%`,
{formatLatency(urlStatus.current_state?.latency)} transform: 'translateX(-50%)',
</p> }}
</div> >
<div> {dailyBars[hoveredBar].date}
<span className="text-gray-400">24h</span> {' · '}
<p className={`font-medium ${getUptimeColor(urlStatus.uptime_24h)}`}> {dailyBars[hoveredBar].uptime !== null
{formatUptime(urlStatus.uptime_24h)} ? `${dailyBars[hoveredBar].uptime.toFixed(1)}%`
</p> : '无数据'}
</div> </div>
<div> )}
<span className="text-gray-400">7d</span> </div>
<p className={`font-medium ${getUptimeColor(urlStatus.uptime_7d)}`}>
{formatUptime(urlStatus.uptime_7d)} {/* 底部标签 */}
</p> <div className="flex justify-between items-center text-[10px] text-gray-400 mt-1.5">
</div> <span>{HISTORY_DAYS} 天前</span>
</div> <span className="font-medium text-gray-500">
</div> {hasUptimeData ? `${uptime90d.toFixed(2)}% 可用率` : '-'}
))} </span>
</div> <span>今天</span>
</div>
{/* 操作按钮 */}
<div className="flex justify-end space-x-2 mt-4"> {/* 状态 / 延迟 / 24h */}
<button <div className="flex items-center gap-3 mt-2 text-[10px]">
onClick={(e) => { <span className="text-gray-400">状态 <span className={`font-semibold ${getStatusColor(isUp, statusCode)}`}>{isUp ? statusCode : '离线'}</span></span>
e.stopPropagation(); <span className="text-gray-400">延迟 <span className={`font-semibold ${getLatencyColor(latency)}`}>{formatLatency(latency)}</span></span>
onRefresh?.(website.website?.id); <span className="text-gray-400">24h <span className={`font-semibold ${getUptimeColor(website.uptime_24h || 0)}`}>{formatUptime(website.uptime_24h)}</span></span>
}} </div>
className="px-3 py-1.5 text-xs font-medium text-emerald-600 bg-emerald-50 rounded-lg hover:bg-emerald-100 transition-colors"
> {/* IP 地址IPv4 / IPv6 分开显示) */}
立即检测 {website.website?.ip_addresses?.length > 0 && (() => {
</button> const ipv4 = website.website.ip_addresses.filter(ip => !ip.includes(':'));
<button const ipv6 = website.website.ip_addresses.filter(ip => ip.includes(':'));
onClick={(e) => { return (ipv4.length > 0 || ipv6.length > 0) && (
e.stopPropagation(); <div className="mt-2 space-y-1">
onEdit?.(website); {ipv4.length > 0 && (
}} <div className="flex items-center gap-1.5 flex-wrap">
className="px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors" <span className="text-[10px] text-gray-400 w-7 flex-shrink-0">IPv4</span>
> {ipv4.map((ip, i) => (
编辑 <span key={i} className="px-1.5 py-0.5 bg-gray-50 text-gray-600 rounded text-[10px] font-mono">{ip}</span>
</button> ))}
<button </div>
onClick={(e) => { )}
e.stopPropagation(); {ipv6.length > 0 && (
if (confirm('确定要删除这个网站吗?')) { <div className="flex items-center gap-1.5 flex-wrap">
onDelete?.(website.website?.id); <span className="text-[10px] text-gray-400 w-7 flex-shrink-0">IPv6</span>
} {ipv6.map((ip, i) => (
}} <span key={i} className="px-1.5 py-0.5 bg-gray-50 text-gray-600 rounded text-[10px] font-mono truncate max-w-full">{ip}</span>
className="px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100 transition-colors" ))}
> </div>
删除 )}
</button> </div>
</div> );
})()}
{/* 最后检测时间 */} </div>
<div className="text-xs text-gray-400 text-right mt-2">
最后检测: {formatTime(website.last_checked)} {/* 展开详情 */}
</div> {expanded && (
</div> <div className="border-t border-gray-100 bg-gray-50/80 p-3 sm:p-4">
)} {/* URL 列表 */}
</div> <div className="space-y-2">
); {website.url_statuses?.map((urlStatus, index) => (
} <div
key={urlStatus.url_info?.id || index}
className="bg-white rounded-lg p-2.5 border border-gray-100"
>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600 truncate flex-1 mr-2">
{urlStatus.url_info?.url}
</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] 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>
))}
</div>
{/* 操作按钮 */}
<div className="flex items-center justify-between mt-3">
<span className="text-[10px] text-gray-400">
{formatTime(website.last_checked)}
</span>
<div className="flex space-x-1.5">
<button
onClick={(e) => {
e.stopPropagation();
onRefresh?.(website.website?.id);
}}
className="px-2.5 py-1 text-[10px] font-medium text-emerald-600 bg-emerald-50 rounded-md hover:bg-emerald-100 transition-colors"
>
检测
</button>
<button
onClick={(e) => {
e.stopPropagation();
onEdit?.(website);
}}
className="px-2.5 py-1 text-[10px] font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
>
编辑
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (confirm('确定要删除这个网站吗?')) {
onDelete?.(website.website?.id);
}
}}
className="px-2.5 py-1 text-[10px] font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100 transition-colors"
>
删除
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,223 +1,254 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { getGroups, createWebsite, updateWebsite } from '../services/api'; import { getGroups, createWebsite, updateWebsite } from '../services/api';
export default function WebsiteModal({ isOpen, onClose, onSuccess, editData }) { export default function WebsiteModal({ isOpen, onClose, onSuccess, editData }) {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
group: 'normal', groups: [],
urls: [''], urls: [''],
}); });
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
loadGroups(); loadGroups();
if (editData) { if (editData) {
setFormData({ const editGroups = editData.website?.groups
name: editData.website?.name || '', || (editData.website?.group ? [editData.website.group] : []);
group: editData.website?.group || 'normal', setFormData({
urls: editData.website?.urls?.map(u => u.url) || [''], name: editData.website?.name || '',
}); groups: editGroups,
} else { urls: editData.website?.urls?.map(u => u.url) || [''],
setFormData({ name: '', group: 'normal', urls: [''] }); });
} } else {
setError(''); setFormData({ name: '', groups: [], urls: [''] });
} }
}, [isOpen, editData]); setError('');
}
const loadGroups = async () => { }, [isOpen, editData]);
try {
const data = await getGroups(); const loadGroups = async () => {
setGroups(data || []); try {
} catch (err) { const data = await getGroups();
console.error('加载分组失败:', err); setGroups(data || []);
} } catch (err) {
}; console.error('加载分组失败:', err);
}
const handleAddUrl = () => { };
setFormData({ ...formData, urls: [...formData.urls, ''] });
}; const handleAddUrl = () => {
setFormData({ ...formData, urls: [...formData.urls, ''] });
const handleRemoveUrl = (index) => { };
if (formData.urls.length > 1) {
const newUrls = formData.urls.filter((_, i) => i !== index); const handleRemoveUrl = (index) => {
setFormData({ ...formData, urls: newUrls }); 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; const handleUrlChange = (index, value) => {
setFormData({ ...formData, urls: newUrls }); const newUrls = [...formData.urls];
}; newUrls[index] = value;
setFormData({ ...formData, urls: newUrls });
const handleSubmit = async (e) => { };
e.preventDefault();
setError(''); const handleSubmit = async (e) => {
e.preventDefault();
// 验证 setError('');
if (!formData.name.trim()) {
setError('请输入网站名称'); // 验证
return; if (!formData.name.trim()) {
} setError('请输入网站名称');
return;
const validUrls = formData.urls.filter(url => url.trim()); }
if (validUrls.length === 0) {
setError('请至少输入一个网站地址'); const validUrls = formData.urls.filter(url => url.trim());
return; if (validUrls.length === 0) {
} setError('请至少输入一个网站地址');
return;
// 验证URL格式 }
for (const url of validUrls) {
try { // 验证URL格式
new URL(url); for (const url of validUrls) {
} catch { try {
setError(`无效的URL: ${url}`); new URL(url);
return; } catch {
} setError(`无效的URL: ${url}`);
} return;
}
setLoading(true); }
try {
const data = { if (formData.groups.length === 0) {
name: formData.name.trim(), setError('请至少选择一个分组');
group: formData.group, return;
urls: validUrls, }
};
setLoading(true);
if (editData) { try {
await updateWebsite(editData.website.id, data); const data = {
} else { name: formData.name.trim(),
await createWebsite(data); groups: formData.groups,
} urls: validUrls,
};
onSuccess?.();
onClose(); if (editData) {
} catch (err) { await updateWebsite(editData.website.id, data);
setError(err.message); } else {
} finally { await createWebsite(data);
setLoading(false); }
}
}; onSuccess?.();
onClose();
if (!isOpen) return null; } catch (err) {
setError(err.message);
return ( } finally {
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> setLoading(false);
<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"> if (!isOpen) return null;
{editData ? '编辑网站' : '添加监控网站'}
</h2> return (
</div> <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">
{/* 表单 */} {/* 标题 */}
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]"> <div className="px-6 py-4 border-b border-gray-100 bg-gradient-to-r from-emerald-50 to-green-50">
{error && ( <h2 className="text-lg font-semibold text-gray-800">
<div className="mb-4 p-3 bg-red-50 border border-red-100 rounded-lg text-sm text-red-600"> {editData ? '编辑网站' : '添加监控网站'}
{error} </h2>
</div> </div>
)}
{/* 表单 */}
{/* 网站名称 */} <form onSubmit={handleSubmit} className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
<div className="mb-4"> {error && (
<label className="block text-sm font-medium text-gray-700 mb-1"> <div className="mb-4 p-3 bg-red-50 border border-red-100 rounded-lg text-sm text-red-600">
网站名称 <span className="text-red-500">*</span> {error}
</label> </div>
<input )}
type="text"
value={formData.name} {/* 网站名称 */}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} <div className="mb-4">
placeholder="例如:我的博客" <label className="block text-sm font-medium text-gray-700 mb-1">
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" 网站名称 <span className="text-red-500">*</span>
/> </label>
</div> <input
type="text"
{/* 所属分组 */} value={formData.name}
<div className="mb-4"> onChange={(e) => setFormData({ ...formData, name: e.target.value })}
<label className="block text-sm font-medium text-gray-700 mb-1"> 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"
</label> />
<select </div>
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" <div className="mb-4">
> <label className="block text-sm font-medium text-gray-700 mb-1">
{groups.map(group => ( 所属分组 <span className="text-xs text-gray-400 font-normal">可多选</span>
<option key={group.id} value={group.id}>{group.name}</option> </label>
))} <div className="flex flex-wrap gap-2 p-3 border border-gray-200 rounded-lg bg-white">
</select> {groups.map(group => {
</div> const checked = formData.groups.includes(group.id);
return (
{/* 网站地址列表 */} <label
<div className="mb-4"> key={group.id}
<label className="block text-sm font-medium text-gray-700 mb-1"> className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm cursor-pointer transition-all select-none ${
网站地址 <span className="text-red-500">*</span> checked
</label> ? 'bg-emerald-100 text-emerald-700 ring-1 ring-emerald-300'
<p className="text-xs text-gray-500 mb-2"> : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
一个网站可以有多个访问地址将分别监控 }`}
</p> >
<input
<div className="space-y-2"> type="checkbox"
{formData.urls.map((url, index) => ( className="sr-only"
<div key={index} className="flex space-x-2"> checked={checked}
<input onChange={() => {
type="text" const next = checked
value={url} ? formData.groups.filter(id => id !== group.id)
onChange={(e) => handleUrlChange(index, e.target.value)} : [...formData.groups, group.id];
placeholder="https://example.com" setFormData({ ...formData, groups: next });
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 && ( {checked && (
<button <svg className="w-3.5 h-3.5 mr-1 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
type="button" <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
onClick={() => handleRemoveUrl(index)} </svg>
className="px-3 py-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors" )}
> {group.name}
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> </label>
<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>
</div>
))} {/* 网站地址列表 */}
</div> <div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
<button 网站地址 <span className="text-red-500">*</span>
type="button" </label>
onClick={handleAddUrl} <p className="text-xs text-gray-500 mb-2">
className="mt-2 text-sm text-emerald-600 hover:text-emerald-700 flex items-center" 一个网站可以有多个访问地址将分别监控
> </p>
<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" /> <div className="space-y-2">
</svg> {formData.urls.map((url, index) => (
添加更多地址 <div key={index} className="flex space-x-2">
</button> <input
</div> type="text"
</form> value={url}
onChange={(e) => handleUrlChange(index, e.target.value)}
{/* 按钮 */} placeholder="https://example.com"
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50 flex justify-end space-x-3"> 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"
<button />
type="button" {formData.urls.length > 1 && (
onClick={onClose} <button
disabled={loading} type="button"
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" onClick={() => handleRemoveUrl(index)}
> className="px-3 py-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
取消 >
</button> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button <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" />
onClick={handleSubmit} </svg>
disabled={loading} </button>
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" )}
> </div>
{loading ? '处理中...' : (editData ? '保存' : '添加')} ))}
</button> </div>
</div>
</div> <button
</div> 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

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

View File

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

View File

@@ -3,6 +3,14 @@ import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.jsx' import App from './App.jsx'
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch((err) => {
console.error('Service worker registration failed:', err)
})
})
}
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<App /> <App />

View File

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

View File

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

View File

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

View File

@@ -5,4 +5,14 @@ import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
},
preview: {
host: '0.0.0.0',
port: 4173,
strictPort: true,
},
}) })

View File

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

View File

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

View File

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