chore: sync local changes (2026-03-12)
This commit is contained in:
47
AGENTS.md
Normal file
47
AGENTS.md
Normal 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
110
README.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# 萌芽Ping(MengYaPing)
|
||||||
|
|
||||||
|
一个轻量、可自部署的网站可用性监控面板。
|
||||||
|
支持多网站/多 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 顶部补充截图、动图或在线演示链接,提升展示效果
|
||||||
|
|
||||||
@@ -43,10 +43,10 @@ func GetConfig() *Config {
|
|||||||
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"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import (
|
|||||||
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"` // 所属分组列表(支持多分组)
|
||||||
|
Group string `json:"group,omitempty"` // 已废弃,仅用于旧数据兼容
|
||||||
URLs []URLInfo `json:"urls"` // 网站访问地址列表
|
URLs []URLInfo `json:"urls"` // 网站访问地址列表
|
||||||
|
IPAddresses []string `json:"ip_addresses,omitempty"` // 域名解析的IP地址
|
||||||
Favicon string `json:"favicon"` // 网站图标URL
|
Favicon string `json:"favicon"` // 网站图标URL
|
||||||
Title string `json:"title"` // 网站标题
|
Title string `json:"title"` // 网站标题
|
||||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||||
@@ -39,8 +41,10 @@ type MonitorRecord struct {
|
|||||||
type WebsiteStatus struct {
|
type WebsiteStatus struct {
|
||||||
Website Website `json:"website"`
|
Website Website `json:"website"`
|
||||||
URLStatuses []URLStatus `json:"url_statuses"`
|
URLStatuses []URLStatus `json:"url_statuses"`
|
||||||
|
DailyHistory []DailyStats `json:"daily_history"` // 90天逐日统计
|
||||||
Uptime24h float64 `json:"uptime_24h"` // 24小时可用率
|
Uptime24h float64 `json:"uptime_24h"` // 24小时可用率
|
||||||
Uptime7d float64 `json:"uptime_7d"` // 7天可用率
|
Uptime7d float64 `json:"uptime_7d"` // 7天可用率
|
||||||
|
Uptime90d float64 `json:"uptime_90d"` // 90天可用率
|
||||||
LastChecked time.Time `json:"last_checked"` // 最后检测时间
|
LastChecked time.Time `json:"last_checked"` // 最后检测时间
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +68,15 @@ type HourlyStats struct {
|
|||||||
Uptime float64 `json:"uptime"`
|
Uptime float64 `json:"uptime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DailyStats 每日统计
|
||||||
|
type DailyStats struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
TotalCount int `json:"total_count"`
|
||||||
|
UpCount int `json:"up_count"`
|
||||||
|
AvgLatency int64 `json:"avg_latency"`
|
||||||
|
Uptime float64 `json:"uptime"`
|
||||||
|
}
|
||||||
|
|
||||||
// Group 分组
|
// Group 分组
|
||||||
type Group struct {
|
type Group struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -79,13 +92,15 @@ var DefaultGroups = []Group{
|
|||||||
// CreateWebsiteRequest 创建网站请求
|
// CreateWebsiteRequest 创建网站请求
|
||||||
type CreateWebsiteRequest struct {
|
type CreateWebsiteRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Group string `json:"group" binding:"required"`
|
Groups []string `json:"groups" binding:"required,min=1"`
|
||||||
|
Group string `json:"group"`
|
||||||
URLs []string `json:"urls" binding:"required,min=1"`
|
URLs []string `json:"urls" binding:"required,min=1"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateWebsiteRequest 更新网站请求
|
// UpdateWebsiteRequest 更新网站请求
|
||||||
type UpdateWebsiteRequest struct {
|
type UpdateWebsiteRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Groups []string `json:"groups"`
|
||||||
Group string `json:"group"`
|
Group string `json:"group"`
|
||||||
URLs []string `json:"urls"`
|
URLs []string `json:"urls"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -80,35 +81,96 @@ func (s *MonitorService) Stop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkAll 检查所有网站
|
// checkAll 检查所有网站(错峰执行,避免并发暴涨)
|
||||||
func (s *MonitorService) checkAll() {
|
func (s *MonitorService) checkAll() {
|
||||||
websites := s.storage.GetWebsites()
|
websites := s.storage.GetWebsites()
|
||||||
|
semaphore := make(chan struct{}, 3) // 最多 3 个并发检测
|
||||||
|
|
||||||
|
for i, website := range websites {
|
||||||
|
// 每个网站之间间隔 1 秒,把检测分散开
|
||||||
|
if i > 0 {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
semaphore := make(chan struct{}, 10) // 限制并发数
|
|
||||||
|
|
||||||
for _, website := range websites {
|
|
||||||
for _, urlInfo := range website.URLs {
|
for _, urlInfo := range website.URLs {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(w models.Website, u models.URLInfo) {
|
go func(w models.Website, u models.URLInfo) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
semaphore <- struct{}{}
|
semaphore <- struct{}{}
|
||||||
defer func() { <-semaphore }()
|
defer func() { <-semaphore }()
|
||||||
|
|
||||||
s.checkURL(w, u)
|
s.checkURL(w, u)
|
||||||
}(website, urlInfo)
|
}(website, urlInfo)
|
||||||
}
|
}
|
||||||
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
// 检测完毕后,逐个解析 DNS
|
||||||
|
s.resolveAllWebsiteIPs(websites)
|
||||||
|
|
||||||
// 保存记录
|
// 保存记录
|
||||||
s.storage.SaveAll()
|
s.storage.SaveAll()
|
||||||
|
log.Printf("本轮检测完成,共 %d 个网站", len(websites))
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkURL 检查单个URL
|
// resolveAllWebsiteIPs 逐个解析所有网站域名 IP(每次都刷新)
|
||||||
|
func (s *MonitorService) resolveAllWebsiteIPs(websites []models.Website) {
|
||||||
|
for i, website := range websites {
|
||||||
|
if len(website.URLs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i > 0 {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
s.resolveWebsiteIP(website)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveWebsiteIP 解析单个网站的域名 IP
|
||||||
|
func (s *MonitorService) resolveWebsiteIP(website models.Website) {
|
||||||
|
if len(website.URLs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ips, err := utils.ResolveDomainIPs(website.URLs[0].URL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("DNS解析失败 [%s]: %v", website.Name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ips) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w := s.storage.GetWebsite(website.ID)
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.IPAddresses = ips
|
||||||
|
w.UpdatedAt = time.Now()
|
||||||
|
s.storage.UpdateWebsite(*w)
|
||||||
|
log.Printf("DNS解析 [%s] → %v", website.Name, ips)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkURL 检查单个URL(带重试)
|
||||||
func (s *MonitorService) checkURL(website models.Website, urlInfo models.URLInfo) {
|
func (s *MonitorService) checkURL(website models.Website, urlInfo models.URLInfo) {
|
||||||
result := s.httpClient.CheckWebsite(urlInfo.URL)
|
cfg := config.GetConfig()
|
||||||
|
maxRetries := cfg.Monitor.RetryCount
|
||||||
|
|
||||||
|
var result utils.CheckResult
|
||||||
|
|
||||||
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
time.Sleep(time.Duration(attempt) * 2 * time.Second)
|
||||||
|
log.Printf("重试 [%s] %s - 第 %d 次重试", website.Name, urlInfo.URL, attempt)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = s.httpClient.CheckWebsiteStatus(urlInfo.URL)
|
||||||
|
if result.Error == nil && utils.IsSuccessStatus(result.StatusCode) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
record := models.MonitorRecord{
|
record := models.MonitorRecord{
|
||||||
WebsiteID: website.ID,
|
WebsiteID: website.ID,
|
||||||
@@ -126,20 +188,13 @@ func (s *MonitorService) checkURL(website models.Website, urlInfo models.URLInfo
|
|||||||
|
|
||||||
s.storage.AddRecord(record)
|
s.storage.AddRecord(record)
|
||||||
|
|
||||||
// 更新网站信息(标题和Favicon)
|
// 仅当网站无标题时才做完整检测来获取元数据
|
||||||
if result.Title != "" || result.Favicon != "" {
|
if record.IsUp && website.Title == "" {
|
||||||
|
fullResult := s.httpClient.CheckWebsite(urlInfo.URL)
|
||||||
|
if fullResult.Title != "" {
|
||||||
w := s.storage.GetWebsite(website.ID)
|
w := s.storage.GetWebsite(website.ID)
|
||||||
if w != nil {
|
if w != nil {
|
||||||
needUpdate := false
|
w.Title = fullResult.Title
|
||||||
if result.Title != "" && w.Title != result.Title {
|
|
||||||
w.Title = result.Title
|
|
||||||
needUpdate = true
|
|
||||||
}
|
|
||||||
if result.Favicon != "" && w.Favicon != result.Favicon {
|
|
||||||
w.Favicon = result.Favicon
|
|
||||||
needUpdate = true
|
|
||||||
}
|
|
||||||
if needUpdate {
|
|
||||||
w.UpdatedAt = time.Now()
|
w.UpdatedAt = time.Now()
|
||||||
s.storage.UpdateWebsite(*w)
|
s.storage.UpdateWebsite(*w)
|
||||||
}
|
}
|
||||||
@@ -150,16 +205,22 @@ func (s *MonitorService) checkURL(website models.Website, urlInfo models.URLInfo
|
|||||||
website.Name, urlInfo.URL, result.StatusCode, result.Latency.Milliseconds(), record.IsUp)
|
website.Name, urlInfo.URL, result.StatusCode, result.Latency.Milliseconds(), record.IsUp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckWebsiteNow 立即检查指定网站
|
// CheckWebsiteNow 立即检查指定网站(状态 + DNS,等待完成后保存)
|
||||||
func (s *MonitorService) CheckWebsiteNow(websiteID string) {
|
func (s *MonitorService) CheckWebsiteNow(websiteID string) {
|
||||||
website := s.storage.GetWebsite(websiteID)
|
website := s.storage.GetWebsite(websiteID)
|
||||||
if website == nil {
|
if website == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 逐个检测该网站的所有 URL
|
||||||
for _, urlInfo := range website.URLs {
|
for _, urlInfo := range website.URLs {
|
||||||
go s.checkURL(*website, urlInfo)
|
s.checkURL(*website, urlInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 刷新 DNS
|
||||||
|
s.resolveWebsiteIP(*website)
|
||||||
|
|
||||||
|
s.storage.SaveAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWebsiteStatus 获取网站状态
|
// GetWebsiteStatus 获取网站状态
|
||||||
@@ -177,9 +238,11 @@ func (s *MonitorService) GetWebsiteStatus(websiteID string) *models.WebsiteStatu
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
since24h := now.Add(-24 * time.Hour)
|
since24h := now.Add(-24 * time.Hour)
|
||||||
since7d := now.Add(-7 * 24 * time.Hour)
|
since7d := now.Add(-7 * 24 * time.Hour)
|
||||||
|
since90d := now.Add(-90 * 24 * time.Hour)
|
||||||
|
|
||||||
var totalUptime24h, totalUptime7d float64
|
var totalUptime24h, totalUptime7d float64
|
||||||
var urlCount int
|
var urlCount int
|
||||||
|
var allRecords90d []models.MonitorRecord
|
||||||
|
|
||||||
for _, urlInfo := range website.URLs {
|
for _, urlInfo := range website.URLs {
|
||||||
urlStatus := s.getURLStatus(website.ID, urlInfo, since24h, since7d)
|
urlStatus := s.getURLStatus(website.ID, urlInfo, since24h, since7d)
|
||||||
@@ -188,6 +251,9 @@ func (s *MonitorService) GetWebsiteStatus(websiteID string) *models.WebsiteStatu
|
|||||||
totalUptime24h += urlStatus.Uptime24h
|
totalUptime24h += urlStatus.Uptime24h
|
||||||
totalUptime7d += urlStatus.Uptime7d
|
totalUptime7d += urlStatus.Uptime7d
|
||||||
urlCount++
|
urlCount++
|
||||||
|
|
||||||
|
records90d := s.storage.GetRecords(website.ID, urlInfo.ID, since90d)
|
||||||
|
allRecords90d = append(allRecords90d, records90d...)
|
||||||
}
|
}
|
||||||
|
|
||||||
if urlCount > 0 {
|
if urlCount > 0 {
|
||||||
@@ -195,6 +261,18 @@ func (s *MonitorService) GetWebsiteStatus(websiteID string) *models.WebsiteStatu
|
|||||||
status.Uptime7d = totalUptime7d / float64(urlCount)
|
status.Uptime7d = totalUptime7d / float64(urlCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 90 天逐日统计
|
||||||
|
status.DailyHistory = s.aggregateByDay(allRecords90d)
|
||||||
|
if len(allRecords90d) > 0 {
|
||||||
|
upCount := 0
|
||||||
|
for _, r := range allRecords90d {
|
||||||
|
if r.IsUp {
|
||||||
|
upCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status.Uptime90d = float64(upCount) / float64(len(allRecords90d)) * 100
|
||||||
|
}
|
||||||
|
|
||||||
// 获取最后检测时间
|
// 获取最后检测时间
|
||||||
for _, urlStatus := range status.URLStatuses {
|
for _, urlStatus := range status.URLStatuses {
|
||||||
if urlStatus.CurrentState.CheckedAt.After(status.LastChecked) {
|
if urlStatus.CurrentState.CheckedAt.After(status.LastChecked) {
|
||||||
@@ -286,6 +364,44 @@ func (s *MonitorService) aggregateByHour(records []models.MonitorRecord) []model
|
|||||||
return result
|
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 获取所有网站状态
|
// GetAllWebsiteStatuses 获取所有网站状态
|
||||||
func (s *MonitorService) GetAllWebsiteStatuses() []models.WebsiteStatus {
|
func (s *MonitorService) GetAllWebsiteStatuses() []models.WebsiteStatus {
|
||||||
websites := s.storage.GetWebsites()
|
websites := s.storage.GetWebsites()
|
||||||
|
|||||||
@@ -22,10 +22,15 @@ func NewWebsiteService() *WebsiteService {
|
|||||||
|
|
||||||
// CreateWebsite 创建网站
|
// CreateWebsite 创建网站
|
||||||
func (s *WebsiteService) CreateWebsite(req models.CreateWebsiteRequest) (*models.Website, error) {
|
func (s *WebsiteService) CreateWebsite(req models.CreateWebsiteRequest) (*models.Website, error) {
|
||||||
|
groups := req.Groups
|
||||||
|
if len(groups) == 0 && req.Group != "" {
|
||||||
|
groups = []string{req.Group}
|
||||||
|
}
|
||||||
|
|
||||||
website := models.Website{
|
website := models.Website{
|
||||||
ID: utils.GenerateID(),
|
ID: utils.GenerateID(),
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Group: req.Group,
|
Groups: groups,
|
||||||
URLs: make([]models.URLInfo, 0),
|
URLs: make([]models.URLInfo, 0),
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
@@ -39,6 +44,13 @@ func (s *WebsiteService) CreateWebsite(req models.CreateWebsiteRequest) (*models
|
|||||||
website.URLs = append(website.URLs, urlInfo)
|
website.URLs = append(website.URLs, urlInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 创建前先解析域名 IP
|
||||||
|
if len(req.URLs) > 0 {
|
||||||
|
if ips, err := utils.ResolveDomainIPs(req.URLs[0]); err == nil {
|
||||||
|
website.IPAddresses = ips
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.storage.AddWebsite(website); err != nil {
|
if err := s.storage.AddWebsite(website); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -69,8 +81,10 @@ func (s *WebsiteService) UpdateWebsite(id string, req models.UpdateWebsiteReques
|
|||||||
if req.Name != "" {
|
if req.Name != "" {
|
||||||
website.Name = req.Name
|
website.Name = req.Name
|
||||||
}
|
}
|
||||||
if req.Group != "" {
|
if len(req.Groups) > 0 {
|
||||||
website.Group = req.Group
|
website.Groups = req.Groups
|
||||||
|
} else if req.Group != "" {
|
||||||
|
website.Groups = []string{req.Group}
|
||||||
}
|
}
|
||||||
if len(req.URLs) > 0 {
|
if len(req.URLs) > 0 {
|
||||||
// 保留已有URL的ID,添加新URL
|
// 保留已有URL的ID,添加新URL
|
||||||
@@ -91,6 +105,7 @@ func (s *WebsiteService) UpdateWebsite(id string, req models.UpdateWebsiteReques
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
website.URLs = newURLs
|
website.URLs = newURLs
|
||||||
|
website.IPAddresses = nil // URL 变更后清空 IP,等下次检测重新解析
|
||||||
}
|
}
|
||||||
|
|
||||||
website.UpdatedAt = time.Now()
|
website.UpdatedAt = time.Now()
|
||||||
|
|||||||
@@ -61,6 +61,23 @@ func (s *Storage) loadWebsites() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
json.Unmarshal(data, &s.websites)
|
json.Unmarshal(data, &s.websites)
|
||||||
|
s.migrateWebsiteGroups()
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateWebsiteGroups 将旧的单分组字段迁移到多分组数组
|
||||||
|
func (s *Storage) migrateWebsiteGroups() {
|
||||||
|
migrated := false
|
||||||
|
for i := range s.websites {
|
||||||
|
w := &s.websites[i]
|
||||||
|
if len(w.Groups) == 0 && w.Group != "" {
|
||||||
|
w.Groups = []string{w.Group}
|
||||||
|
w.Group = ""
|
||||||
|
migrated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if migrated {
|
||||||
|
s.saveWebsites()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadRecords 加载监控记录
|
// loadRecords 加载监控记录
|
||||||
|
|||||||
60
mengyaping-backend/utils/dns.go
Normal file
60
mengyaping-backend/utils/dns.go
Normal 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
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -18,12 +19,23 @@ type HTTPClient struct {
|
|||||||
|
|
||||||
// NewHTTPClient 创建HTTP客户端
|
// NewHTTPClient 创建HTTP客户端
|
||||||
func NewHTTPClient(timeout time.Duration) *HTTPClient {
|
func NewHTTPClient(timeout time.Duration) *HTTPClient {
|
||||||
|
transport := &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 10,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ResponseHeaderTimeout: timeout,
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
}
|
||||||
|
|
||||||
return &HTTPClient{
|
return &HTTPClient{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Transport: &http.Transport{
|
Transport: transport,
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
||||||
},
|
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
if len(via) >= 10 {
|
if len(via) >= 10 {
|
||||||
return fmt.Errorf("too many redirects")
|
return fmt.Errorf("too many redirects")
|
||||||
@@ -43,7 +55,40 @@ type CheckResult struct {
|
|||||||
Error error
|
Error error
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckWebsite 检查网站
|
// CheckWebsiteStatus 轻量级状态检测(不读取页面内容)
|
||||||
|
func (c *HTTPClient) CheckWebsiteStatus(targetURL string) CheckResult {
|
||||||
|
result := CheckResult{}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", targetURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "MengYaPing/1.0 (Website Monitor)")
|
||||||
|
req.Header.Set("Accept", "*/*")
|
||||||
|
req.Header.Set("Connection", "close")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err
|
||||||
|
result.Latency = time.Since(start)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
result.Latency = time.Since(start)
|
||||||
|
result.StatusCode = resp.StatusCode
|
||||||
|
|
||||||
|
// 丢弃少量 body 以便连接正确释放
|
||||||
|
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckWebsite 完整检查(读取页面提取标题等元数据)
|
||||||
func (c *HTTPClient) CheckWebsite(targetURL string) CheckResult {
|
func (c *HTTPClient) CheckWebsite(targetURL string) CheckResult {
|
||||||
result := CheckResult{}
|
result := CheckResult{}
|
||||||
|
|
||||||
@@ -69,8 +114,7 @@ func (c *HTTPClient) CheckWebsite(targetURL string) CheckResult {
|
|||||||
result.Latency = time.Since(start)
|
result.Latency = time.Since(start)
|
||||||
result.StatusCode = resp.StatusCode
|
result.StatusCode = resp.StatusCode
|
||||||
|
|
||||||
// 读取响应体获取标题
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100))
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100)) // 限制100KB
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
result.Title = extractTitle(string(body))
|
result.Title = extractTitle(string(body))
|
||||||
result.Favicon = extractFavicon(string(body), targetURL)
|
result.Favicon = extractFavicon(string(body), targetURL)
|
||||||
@@ -96,7 +140,6 @@ func extractFavicon(html string, baseURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试从HTML中提取favicon链接
|
|
||||||
patterns := []string{
|
patterns := []string{
|
||||||
`(?i)<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']+)["']`,
|
`(?i)<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']+)["']`,
|
||||||
`(?i)<link[^>]*href=["']([^"']+)["'][^>]*rel=["'](?:shortcut )?icon["']`,
|
`(?i)<link[^>]*href=["']([^"']+)["'][^>]*rel=["'](?:shortcut )?icon["']`,
|
||||||
@@ -112,7 +155,6 @@ func extractFavicon(html string, baseURL string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认返回 /favicon.ico
|
|
||||||
return fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host)
|
return fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
BIN
mengyaping-frontend/public/icons/icon-192.png
Normal file
BIN
mengyaping-frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
BIN
mengyaping-frontend/public/icons/icon-512-maskable.png
Normal file
BIN
mengyaping-frontend/public/icons/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 460 KiB |
BIN
mengyaping-frontend/public/icons/icon-512.png
Normal file
BIN
mengyaping-frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 460 KiB |
32
mengyaping-frontend/public/manifest.webmanifest
Normal file
32
mengyaping-frontend/public/manifest.webmanifest
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
70
mengyaping-frontend/public/sw.js
Normal file
70
mengyaping-frontend/public/sw.js
Normal 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,32 +1,89 @@
|
|||||||
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;
|
||||||
|
|
||||||
|
function getBarColor(bar) {
|
||||||
|
if (bar.uptime === null) return 'bg-gray-200';
|
||||||
|
if (bar.uptime >= 99) return 'bg-emerald-400';
|
||||||
|
if (bar.uptime >= 95) return 'bg-yellow-400';
|
||||||
|
if (bar.uptime >= 80) return 'bg-orange-400';
|
||||||
|
return 'bg-red-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBarHoverColor(bar) {
|
||||||
|
if (bar.uptime === null) return 'hover:bg-gray-300';
|
||||||
|
if (bar.uptime >= 99) return 'hover:bg-emerald-500';
|
||||||
|
if (bar.uptime >= 95) return 'hover:bg-yellow-500';
|
||||||
|
if (bar.uptime >= 80) return 'hover:bg-orange-500';
|
||||||
|
return 'hover:bg-red-600';
|
||||||
|
}
|
||||||
|
|
||||||
export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [hoveredBar, setHoveredBar] = useState(null);
|
||||||
|
|
||||||
// 获取第一个URL的状态作为主状态
|
|
||||||
const primaryStatus = website.url_statuses?.[0];
|
const primaryStatus = website.url_statuses?.[0];
|
||||||
const isUp = primaryStatus?.current_state?.is_up ?? false;
|
const isUp = primaryStatus?.current_state?.is_up ?? false;
|
||||||
const statusCode = primaryStatus?.current_state?.status_code ?? 0;
|
const statusCode = primaryStatus?.current_state?.status_code ?? 0;
|
||||||
const latency = primaryStatus?.current_state?.latency ?? 0;
|
const latency = primaryStatus?.current_state?.latency ?? 0;
|
||||||
const primaryUrl = website.website?.urls?.[0]?.url || website.url_statuses?.[0]?.url_info?.url;
|
const primaryUrl = website.website?.urls?.[0]?.url || website.url_statuses?.[0]?.url_info?.url;
|
||||||
|
|
||||||
|
const dailyBars = useMemo(() => {
|
||||||
|
const bars = [];
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const statsMap = {};
|
||||||
|
(website.daily_history || []).forEach(stat => {
|
||||||
|
const d = new Date(stat.date);
|
||||||
|
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
statsMap[key] = stat;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = HISTORY_DAYS - 1; i >= 0; i--) {
|
||||||
|
const date = new Date(today);
|
||||||
|
date.setDate(date.getDate() - i);
|
||||||
|
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
const stat = statsMap[key];
|
||||||
|
bars.push({
|
||||||
|
date: key,
|
||||||
|
uptime: stat ? stat.uptime : null,
|
||||||
|
totalCount: stat?.total_count ?? 0,
|
||||||
|
avgLatency: stat?.avg_latency ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return bars;
|
||||||
|
}, [website.daily_history]);
|
||||||
|
|
||||||
|
const statusDisplay = (() => {
|
||||||
|
if (!primaryStatus?.current_state?.checked_at || primaryStatus.current_state.checked_at === '0001-01-01T00:00:00Z') {
|
||||||
|
return { text: '等待检测', color: 'text-gray-400' };
|
||||||
|
}
|
||||||
|
if (isUp) {
|
||||||
|
return { text: '运行正常', color: 'text-emerald-500' };
|
||||||
|
}
|
||||||
|
return { text: '服务异常', color: 'text-red-500' };
|
||||||
|
})();
|
||||||
|
|
||||||
|
const uptime90d = website.uptime_90d;
|
||||||
|
const hasUptimeData = uptime90d != null && uptime90d > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow">
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow">
|
||||||
{/* 卡片头部 */}
|
{/* 主体区域 */}
|
||||||
<div
|
<div
|
||||||
className="p-3 cursor-pointer"
|
className="p-3 sm:p-4 cursor-pointer select-none"
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
>
|
>
|
||||||
{/* 第一行:图标、名称、状态 */}
|
{/* 头部:图标 + 名称 + 状态 + 访问按钮 */}
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
<div className="flex items-center space-x-2.5 min-w-0 flex-1">
|
||||||
{/* Favicon */}
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center overflow-hidden flex-shrink-0">
|
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center overflow-hidden flex-shrink-0">
|
||||||
{website.website?.favicon ? (
|
{primaryUrl ? (
|
||||||
<img
|
<img
|
||||||
src={website.website.favicon}
|
src={`https://cf-favicon.pages.dev/api/favicon?url=${encodeURIComponent(primaryUrl)}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-7 h-7 object-contain drop-shadow"
|
className="w-7 h-7 object-contain drop-shadow"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
@@ -36,150 +93,156 @@ export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<span
|
<span
|
||||||
className={`text-base font-bold text-emerald-600 ${website.website?.favicon ? 'hidden' : ''}`}
|
className={`text-base font-bold text-emerald-600 ${primaryUrl ? 'hidden' : ''}`}
|
||||||
style={{ display: website.website?.favicon ? 'none' : 'flex' }}
|
style={{ display: primaryUrl ? 'none' : 'flex' }}
|
||||||
>
|
>
|
||||||
{website.website?.name?.[0] || '?'}
|
{website.website?.name?.[0] || '?'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
{/* 网站名称 */}
|
|
||||||
<h3 className="font-semibold text-gray-800 truncate">
|
<h3 className="font-semibold text-gray-800 truncate">
|
||||||
{website.website?.name || '未知网站'}
|
{website.website?.name || '未知网站'}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
<span className={`text-xs font-medium ${statusDisplay.color}`}>
|
||||||
|
{statusDisplay.text}
|
||||||
{/* 展开箭头 */}
|
|
||||||
<svg
|
|
||||||
className={`w-4 h-4 text-gray-400 transform transition-transform flex-shrink-0 ${expanded ? 'rotate-180' : ''}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 第二行:状态、延迟、访问按钮 */}
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{/* 状态徽章 */}
|
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(isUp, statusCode)}`}>
|
|
||||||
{isUp ? `${statusCode}` : '离线'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* 延迟 */}
|
|
||||||
<span className={`text-xs font-medium ${getLatencyColor(latency)}`}>
|
|
||||||
{formatLatency(latency)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* 访问按钮 */}
|
{primaryUrl && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (primaryUrl) {
|
|
||||||
window.open(primaryUrl, '_blank', 'noopener,noreferrer');
|
window.open(primaryUrl, '_blank', 'noopener,noreferrer');
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={!primaryUrl}
|
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"
|
||||||
className={`flex items-center space-x-1 px-2.5 py-1 text-xs font-medium rounded-full transition-all flex-shrink-0 ${
|
|
||||||
primaryUrl
|
|
||||||
? 'text-white bg-gradient-to-r from-emerald-500 to-green-500 hover:from-emerald-600 hover:to-green-600 shadow-sm hover:shadow'
|
|
||||||
: 'text-gray-400 bg-gray-100 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span>访问</span>
|
<span>访问</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 网站描述 */}
|
{/* 90 天可用率竖条图 */}
|
||||||
<p className="text-xs text-gray-500 truncate mb-2">
|
<div className="relative">
|
||||||
{website.website?.title || website.website?.urls?.[0]?.url || '-'}
|
<div className="flex items-stretch gap-[1px] h-[26px]">
|
||||||
</p>
|
{dailyBars.map((bar, i) => (
|
||||||
|
|
||||||
{/* 可用率条 */}
|
|
||||||
<div className="mt-3">
|
|
||||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
|
||||||
<span>24h可用率</span>
|
|
||||||
<span className={getUptimeColor(website.uptime_24h || 0)}>
|
|
||||||
{formatUptime(website.uptime_24h)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
|
||||||
<div
|
<div
|
||||||
className="h-full bg-gradient-to-r from-emerald-400 to-green-500 rounded-full transition-all"
|
key={i}
|
||||||
style={{ width: `${Math.min(website.uptime_24h || 0, 100)}%` }}
|
className={`flex-1 rounded-[1px] transition-colors ${getBarColor(bar)} ${getBarHoverColor(bar)}`}
|
||||||
|
onMouseEnter={() => setHoveredBar(i)}
|
||||||
|
onMouseLeave={() => setHoveredBar(null)}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 悬浮提示 */}
|
||||||
|
{hoveredBar !== null && dailyBars[hoveredBar] && (
|
||||||
|
<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"
|
||||||
|
style={{
|
||||||
|
left: `${(hoveredBar / HISTORY_DAYS) * 100}%`,
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dailyBars[hoveredBar].date}
|
||||||
|
{' · '}
|
||||||
|
{dailyBars[hoveredBar].uptime !== null
|
||||||
|
? `${dailyBars[hoveredBar].uptime.toFixed(1)}%`
|
||||||
|
: '无数据'}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部标签 */}
|
||||||
|
<div className="flex justify-between items-center text-[10px] text-gray-400 mt-1.5">
|
||||||
|
<span>{HISTORY_DAYS} 天前</span>
|
||||||
|
<span className="font-medium text-gray-500">
|
||||||
|
{hasUptimeData ? `${uptime90d.toFixed(2)}% 可用率` : '-'}
|
||||||
|
</span>
|
||||||
|
<span>今天</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态 / 延迟 / 24h */}
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-[10px]">
|
||||||
|
<span className="text-gray-400">状态 <span className={`font-semibold ${getStatusColor(isUp, statusCode)}`}>{isUp ? statusCode : '离线'}</span></span>
|
||||||
|
<span className="text-gray-400">延迟 <span className={`font-semibold ${getLatencyColor(latency)}`}>{formatLatency(latency)}</span></span>
|
||||||
|
<span className="text-gray-400">24h <span className={`font-semibold ${getUptimeColor(website.uptime_24h || 0)}`}>{formatUptime(website.uptime_24h)}</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* IP 地址(IPv4 / IPv6 分开显示) */}
|
||||||
|
{website.website?.ip_addresses?.length > 0 && (() => {
|
||||||
|
const ipv4 = website.website.ip_addresses.filter(ip => !ip.includes(':'));
|
||||||
|
const ipv6 = website.website.ip_addresses.filter(ip => ip.includes(':'));
|
||||||
|
return (ipv4.length > 0 || ipv6.length > 0) && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{ipv4.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ipv6.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 展开详情 */}
|
{/* 展开详情 */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="border-t border-gray-100 bg-gray-50 p-4">
|
<div className="border-t border-gray-100 bg-gray-50/80 p-3 sm:p-4">
|
||||||
{/* URL列表 */}
|
{/* URL 列表 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{website.url_statuses?.map((urlStatus, index) => (
|
{website.url_statuses?.map((urlStatus, index) => (
|
||||||
<div
|
<div
|
||||||
key={urlStatus.url_info?.id || index}
|
key={urlStatus.url_info?.id || index}
|
||||||
className="bg-white rounded-lg p-3 border border-gray-100"
|
className="bg-white rounded-lg p-2.5 border border-gray-100"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-gray-600 truncate flex-1 mr-2">
|
<span className="text-xs text-gray-600 truncate flex-1 mr-2">
|
||||||
{urlStatus.url_info?.url}
|
{urlStatus.url_info?.url}
|
||||||
</span>
|
</span>
|
||||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||||
getStatusColor(urlStatus.current_state?.is_up, urlStatus.current_state?.status_code)
|
getStatusColor(urlStatus.current_state?.is_up, urlStatus.current_state?.status_code)
|
||||||
}`}>
|
}`}>
|
||||||
{urlStatus.current_state?.is_up ? urlStatus.current_state?.status_code : '离线'}
|
{urlStatus.current_state?.is_up ? urlStatus.current_state?.status_code : '离线'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-400">延迟</span>
|
|
||||||
<p className={`font-medium ${getLatencyColor(urlStatus.current_state?.latency)}`}>
|
|
||||||
{formatLatency(urlStatus.current_state?.latency)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-400">24h</span>
|
|
||||||
<p className={`font-medium ${getUptimeColor(urlStatus.uptime_24h)}`}>
|
|
||||||
{formatUptime(urlStatus.uptime_24h)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-400">7d</span>
|
|
||||||
<p className={`font-medium ${getUptimeColor(urlStatus.uptime_7d)}`}>
|
|
||||||
{formatUptime(urlStatus.uptime_7d)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className="flex justify-end space-x-2 mt-4">
|
<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
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRefresh?.(website.website?.id);
|
onRefresh?.(website.website?.id);
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 text-xs font-medium text-emerald-600 bg-emerald-50 rounded-lg hover:bg-emerald-100 transition-colors"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEdit?.(website);
|
onEdit?.(website);
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
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>
|
||||||
@@ -190,15 +253,11 @@ export default function WebsiteCard({ website, onRefresh, onEdit, onDelete }) {
|
|||||||
onDelete?.(website.website?.id);
|
onDelete?.(website.website?.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 最后检测时间 */}
|
|
||||||
<div className="text-xs text-gray-400 text-right mt-2">
|
|
||||||
最后检测: {formatTime(website.last_checked)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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([]);
|
||||||
@@ -15,13 +15,15 @@ export default function WebsiteModal({ isOpen, onClose, onSuccess, editData }) {
|
|||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
loadGroups();
|
loadGroups();
|
||||||
if (editData) {
|
if (editData) {
|
||||||
|
const editGroups = editData.website?.groups
|
||||||
|
|| (editData.website?.group ? [editData.website.group] : []);
|
||||||
setFormData({
|
setFormData({
|
||||||
name: editData.website?.name || '',
|
name: editData.website?.name || '',
|
||||||
group: editData.website?.group || 'normal',
|
groups: editGroups,
|
||||||
urls: editData.website?.urls?.map(u => u.url) || [''],
|
urls: editData.website?.urls?.map(u => u.url) || [''],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setFormData({ name: '', group: 'normal', urls: [''] });
|
setFormData({ name: '', groups: [], urls: [''] });
|
||||||
}
|
}
|
||||||
setError('');
|
setError('');
|
||||||
}
|
}
|
||||||
@@ -79,11 +81,16 @@ export default function WebsiteModal({ isOpen, onClose, onSuccess, editData }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (formData.groups.length === 0) {
|
||||||
|
setError('请至少选择一个分组');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = {
|
const data = {
|
||||||
name: formData.name.trim(),
|
name: formData.name.trim(),
|
||||||
group: formData.group,
|
groups: formData.groups,
|
||||||
urls: validUrls,
|
urls: validUrls,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,20 +143,44 @@ export default function WebsiteModal({ isOpen, onClose, onSuccess, editData }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 所属分组 */}
|
{/* 所属分组(多选) */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
所属分组
|
所属分组 <span className="text-xs text-gray-400 font-normal">(可多选)</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<div className="flex flex-wrap gap-2 p-3 border border-gray-200 rounded-lg bg-white">
|
||||||
value={formData.group}
|
{groups.map(group => {
|
||||||
onChange={(e) => setFormData({ ...formData, group: e.target.value })}
|
const checked = formData.groups.includes(group.id);
|
||||||
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"
|
return (
|
||||||
|
<label
|
||||||
|
key={group.id}
|
||||||
|
className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm cursor-pointer transition-all select-none ${
|
||||||
|
checked
|
||||||
|
? 'bg-emerald-100 text-emerald-700 ring-1 ring-emerald-300'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{groups.map(group => (
|
<input
|
||||||
<option key={group.id} value={group.id}>{group.name}</option>
|
type="checkbox"
|
||||||
))}
|
className="sr-only"
|
||||||
</select>
|
checked={checked}
|
||||||
|
onChange={() => {
|
||||||
|
const next = checked
|
||||||
|
? formData.groups.filter(id => id !== group.id)
|
||||||
|
: [...formData.groups, group.id];
|
||||||
|
setFormData({ ...formData, groups: next });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{checked && (
|
||||||
|
<svg className="w-3.5 h-3.5 mr-1 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{group.name}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 网站地址列表 */}
|
{/* 网站地址列表 */}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -57,10 +57,13 @@ export default function Dashboard() {
|
|||||||
if (!websites) return [];
|
if (!websites) return [];
|
||||||
|
|
||||||
return websites.filter(site => {
|
return websites.filter(site => {
|
||||||
// 分组过滤
|
// 分组过滤(支持多分组)
|
||||||
if (selectedGroup !== 'all' && site.website?.group !== selectedGroup) {
|
if (selectedGroup !== 'all') {
|
||||||
|
const siteGroups = site.website?.groups || (site.website?.group ? [site.website.group] : []);
|
||||||
|
if (!siteGroups.includes(selectedGroup)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// 搜索过滤
|
// 搜索过滤
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
const term = searchTerm.toLowerCase();
|
const term = searchTerm.toLowerCase();
|
||||||
@@ -74,20 +77,23 @@ export default function Dashboard() {
|
|||||||
});
|
});
|
||||||
}, [websites, selectedGroup, searchTerm]);
|
}, [websites, selectedGroup, searchTerm]);
|
||||||
|
|
||||||
// 按分组分类网站
|
// 按分组分类网站(一个网站可出现在多个分组下)
|
||||||
const groupedWebsites = useMemo(() => {
|
const groupedWebsites = useMemo(() => {
|
||||||
const grouped = {};
|
const grouped = {};
|
||||||
|
|
||||||
filteredWebsites.forEach(site => {
|
filteredWebsites.forEach(site => {
|
||||||
const groupId = site.website?.group || 'normal';
|
const siteGroups = site.website?.groups || (site.website?.group ? [site.website.group] : ['normal']);
|
||||||
|
siteGroups.forEach(groupId => {
|
||||||
|
if (selectedGroup !== 'all' && groupId !== selectedGroup) return;
|
||||||
if (!grouped[groupId]) {
|
if (!grouped[groupId]) {
|
||||||
grouped[groupId] = [];
|
grouped[groupId] = [];
|
||||||
}
|
}
|
||||||
grouped[groupId].push(site);
|
grouped[groupId].push(site);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return grouped;
|
return grouped;
|
||||||
}, [filteredWebsites]);
|
}, [filteredWebsites, selectedGroup]);
|
||||||
|
|
||||||
// 获取分组名称
|
// 获取分组名称
|
||||||
const getGroupName = (groupId) => {
|
const getGroupName = (groupId) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user