Complete admin dashboard with paginated user pet trees and walk relations
ci-cd / verify (push) Successful in 1m40s
ci-cd / deploy (push) Skipped

This commit is contained in:
2026-10-04 11:45:15 +08:00
parent 3655a6a89e
commit 6c37785ce1
26 changed files with 3155 additions and 601 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

+4 -4
View File
@@ -2,11 +2,11 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/png" href="/brand-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>肉垫计划 · 管理端</title> <title>肉垫计划 · 管理后台</title>
<script type="module" crossorigin src="/assets/index-COwxdvZQ.js"></script> <script type="module" crossorigin src="/assets/index-DkKuSusU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BQA5NViF.css"> <link rel="stylesheet" crossorigin href="/assets/index-B39ofd9P.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -1
View File
@@ -39,7 +39,7 @@ func TestAdminAtRootAndAPIRoutes(t *testing.T) {
}{ }{
{path: "/", status: http.StatusOK, body: "<div id=\"root\"></div>"}, {path: "/", status: http.StatusOK, body: "<div id=\"root\"></div>"},
{path: script, status: http.StatusOK}, {path: script, status: http.StatusOK},
{path: "/favicon.svg", status: http.StatusOK}, {path: "/brand-logo.png", status: http.StatusOK},
{path: "/api/ping", status: http.StatusOK, body: "pong"}, {path: "/api/ping", status: http.StatusOK, body: "pong"},
{path: "/api/missing", status: http.StatusNotFound}, {path: "/api/missing", status: http.StatusNotFound},
{path: "/assets/missing.js", status: http.StatusNotFound}, {path: "/assets/missing.js", status: http.StatusNotFound},
+396 -32
View File
@@ -1,98 +1,462 @@
package httpapi package httpapi
import ( import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/model" "github.com/sundynix/pets-be/internal/model"
"net/http" "gorm.io/gorm"
) )
type adminPage struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
Items any `json:"items"`
}
func pageParams(c *gin.Context) (adminPage, bool) {
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
size, sizeErr := strconv.Atoi(c.DefaultQuery("page_size", "10"))
if err != nil || sizeErr != nil || page < 1 || page > 1000000 || size < 1 || size > 100 || len([]rune(c.Query("q"))) > 128 {
fail(c, http.StatusBadRequest, "分页或搜索参数无效")
return adminPage{}, false
}
return adminPage{Page: page, PageSize: size}, true
}
func adminRows[T any](c *gin.Context, query *gorm.DB, order string) ([]T, adminPage, bool) {
page, valid := pageParams(c)
if !valid {
return nil, page, false
}
if err := query.Count(&page.Total).Error; err != nil {
fail(c, 500, "读取记录数量失败")
return nil, page, false
}
rows := make([]T, 0)
if err := query.Order(order).Offset((page.Page - 1) * page.PageSize).Limit(page.PageSize).Find(&rows).Error; err != nil {
fail(c, 500, "读取记录失败")
return nil, page, false
}
return rows, page, true
}
func search(query *gorm.DB, c *gin.Context, columns ...string) *gorm.DB {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
return query
}
// Only allow literal substring search. Columns are constants supplied by handlers.
value := "%" + strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(q) + "%"
clauses := make([]string, 0, len(columns))
args := make([]any, 0, len(columns))
for _, column := range columns {
clauses = append(clauses, column+" ILIKE ?")
args = append(args, value)
}
return query.Where("("+strings.Join(clauses, " OR ")+")", args...)
}
func filterStatus(c *gin.Context, query *gorm.DB, column string, allowed ...string) (*gorm.DB, bool) {
status := c.Query("status")
if status == "" {
return query, true
}
for _, value := range allowed {
if status == value {
return query.Where(column+" = ?", status), true
}
}
fail(c, 400, "筛选状态无效")
return query, false
}
func (a *API) userNames(ids []string) (map[string]string, error) {
names := map[string]string{}
if len(ids) == 0 {
return names, nil
}
var users []model.User
err := a.db.Select("id", "nickname").Where("id IN ?", ids).Find(&users).Error
for _, user := range users {
names[user.ID] = user.Nickname
}
return names, err
}
func (a *API) adminStats(c *gin.Context) { func (a *API) adminStats(c *gin.Context) {
counts := gin.H{} counts := gin.H{}
for _, pair := range []struct { for _, pair := range []struct {
name string name string
value any value any
}{{"users", &model.User{}}, {"pets", &model.Pet{}}, {"walks", &model.Walk{}}, {"meetups", &model.Meetup{}}} { }{{"users", &model.User{}}, {"pets", &model.Pet{}}, {"walks", &model.Walk{}}, {"meetups", &model.Meetup{}}, {"tasks", &model.CareTask{}}, {"files", &model.File{}}} {
var n int64 var n int64
a.db.Model(pair.value).Count(&n) if err := a.db.Model(pair.value).Count(&n).Error; err != nil {
fail(c, 500, "读取统计失败")
return
}
counts[pair.name] = n counts[pair.name] = n
} }
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for _, pair := range []struct {
name string
query *gorm.DB
}{
{"today_walks", a.db.Model(&model.Walk{}).Where("started_at >= ?", today)},
{"active_walks", a.db.Model(&model.Walk{}).Where("status IN ?", []string{"active", "paused"})},
{"upcoming_meetups", a.db.Model(&model.Meetup{}).Where("status = ? AND starts_at > ?", "open", now)},
{"today_completions", a.db.Model(&model.TaskCompletion{}).Where("date = ?", today)},
} {
var n int64
if pair.query.Count(&n).Error != nil {
fail(c, 500, "读取统计失败")
return
}
counts[pair.name] = n
}
var metrics struct {
Distance float64
Duration int64
Storage int64
}
if a.db.Model(&model.Walk{}).Select("COALESCE(SUM(distance_meters),0) AS distance, COALESCE(SUM(duration_seconds),0) AS duration").Scan(&metrics).Error != nil || a.db.Model(&model.File{}).Select("COALESCE(SUM(size),0) AS storage").Scan(&metrics).Error != nil {
fail(c, 500, "读取统计失败")
return
}
counts["distance_meters"] = metrics.Distance
counts["duration_seconds"] = metrics.Duration
counts["storage_bytes"] = metrics.Storage
type dayCount struct {
Date string `json:"date"`
Count int64 `json:"count"`
}
var grouped []dayCount
since := today.AddDate(0, 0, -6)
if a.db.Model(&model.Walk{}).Select("to_char(started_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, COUNT(*) AS count").Where("started_at >= ?", since).Group("date").Scan(&grouped).Error != nil {
fail(c, 500, "读取趋势失败")
return
}
daily := make([]dayCount, 0, 7)
for day := since; !day.After(today); day = day.AddDate(0, 0, 1) {
entry := dayCount{Date: day.Format("2006-01-02")}
for _, row := range grouped {
if row.Date == entry.Date {
entry.Count = row.Count
}
}
daily = append(daily, entry)
}
counts["daily_walks"] = daily
ok(c, counts) ok(c, counts)
} }
func (a *API) adminUsers(c *gin.Context) { func (a *API) adminUsers(c *gin.Context) {
var rows []model.User query := search(a.db.Model(&model.User{}), c, "nickname", "id::text")
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil { switch c.Query("status") {
fail(c, 500, "读取用户失败") case "":
case "enabled":
query = query.Where("disabled = false")
case "disabled":
query = query.Where("disabled = true")
default:
fail(c, 400, "筛选状态无效")
return return
} }
rows, page, valid := adminRows[model.User](c, query, "created_at DESC, id DESC")
if !valid {
return
}
ids := []string{}
for _, row := range rows {
ids = append(ids, row.ID)
}
var totals []struct {
UserID string
Count int64
}
if len(ids) > 0 && a.db.Model(&model.Pet{}).Select("user_id, COUNT(*) AS count").Where("user_id IN ?", ids).Group("user_id").Scan(&totals).Error != nil {
fail(c, 500, "读取宠物数量失败")
return
}
counts := map[string]int64{}
for _, row := range totals {
counts[row.UserID] = row.Count
}
items := make([]gin.H, 0, len(rows)) items := make([]gin.H, 0, len(rows))
for _, u := range rows { for _, u := range rows {
v := a.userView(u) view := a.userView(u)
v["disabled"] = u.Disabled view["disabled"] = u.Disabled
items = append(items, v) view["pet_count"] = counts[u.ID]
items = append(items, view)
}
page.Items = items
ok(c, page)
}
func (a *API) adminUserPets(c *gin.Context) {
id := c.Param("id")
if !validID(id) {
fail(c, 400, "用户 ID 无效")
return
}
var user model.User
if err := a.db.First(&user, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
fail(c, 404, "用户不存在")
} else {
fail(c, 500, "读取用户失败")
}
return
}
var rows []model.Pet
if a.db.Where("user_id = ?", id).Order("archived ASC, created_at DESC, id DESC").Find(&rows).Error != nil {
fail(c, 500, "读取宠物失败")
return
}
items := make([]gin.H, 0, len(rows))
for _, pet := range rows {
view := a.petView(pet)
view["archived"] = pet.Archived
view["user_id"] = id
view["user_name"] = user.Nickname
items = append(items, view)
} }
ok(c, items) ok(c, items)
} }
func (a *API) adminPets(c *gin.Context) { func (a *API) adminPets(c *gin.Context) {
var rows []model.Pet query := search(a.db.Model(&model.Pet{}), c, "name", "breed", "id::text", "user_id::text")
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil { switch c.Query("status") {
fail(c, 500, "读取宠物失败") case "":
case "active":
query = query.Where("archived = false")
case "archived":
query = query.Where("archived = true")
default:
fail(c, 400, "筛选状态无效")
return
}
rows, page, valid := adminRows[model.Pet](c, query, "created_at DESC, id DESC")
if !valid {
return
}
ids := []string{}
for _, p := range rows {
ids = append(ids, p.UserID)
}
names, err := a.userNames(ids)
if err != nil {
fail(c, 500, "读取主人信息失败")
return return
} }
items := make([]gin.H, 0, len(rows)) items := make([]gin.H, 0, len(rows))
for _, p := range rows { for _, p := range rows {
v := a.petView(p) v := a.petView(p)
v["user_id"] = p.UserID v["user_id"] = p.UserID
v["user_name"] = names[p.UserID]
v["archived"] = p.Archived v["archived"] = p.Archived
items = append(items, v) items = append(items, v)
} }
ok(c, items) page.Items = items
ok(c, page)
} }
func (a *API) adminWalks(c *gin.Context) { func (a *API) adminWalks(c *gin.Context) {
var rows []model.Walk query := search(a.db.Model(&model.Walk{}), c, "id::text", "user_id::text")
if a.db.Order("started_at DESC").Limit(200).Find(&rows).Error != nil { query, valid := filterStatus(c, query, "status", "active", "paused", "finished")
fail(c, 500, "读取遛狗失败") if !valid {
return return
} }
ok(c, rows) rows, page, valid := adminRows[model.Walk](c, query, "started_at DESC, id DESC")
if !valid {
return
}
ids := []string{}
walkIDs := []string{}
for _, w := range rows {
ids = append(ids, w.UserID)
walkIDs = append(walkIDs, w.ID)
}
names, err := a.userNames(ids)
if err != nil {
fail(c, 500, "读取用户失败")
return
}
var links []struct {
WalkID string
PetName string
}
if len(walkIDs) > 0 && a.db.Model(&model.WalkPet{}).Select("sundynix_walk_pet.walk_id, p.name AS pet_name").Joins("JOIN sundynix_pet p ON p.id = sundynix_walk_pet.pet_id").Where("walk_id IN ?", walkIDs).Order("p.created_at ASC").Scan(&links).Error != nil {
fail(c, 500, "读取同行宠物失败")
return
}
pets := map[string][]string{}
for _, link := range links {
pets[link.WalkID] = append(pets[link.WalkID], link.PetName)
}
items := make([]gin.H, 0, len(rows))
for _, w := range rows {
items = append(items, gin.H{"id": w.ID, "user_id": w.UserID, "user_name": names[w.UserID], "pet_names": pets[w.ID], "status": w.Status, "started_at": w.StartedAt, "ended_at": w.EndedAt, "duration_seconds": w.DurationSeconds, "distance_meters": w.DistanceMeters, "share_enabled": w.ShareEnabled})
}
page.Items = items
ok(c, page)
}
func (a *API) adminWalkDetail(c *gin.Context) {
if !validID(c.Param("id")) {
fail(c, 400, "记录 ID 无效")
return
}
var walk model.Walk
if err := a.db.First(&walk, "id = ?", c.Param("id")).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
fail(c, 404, "遛狗记录不存在")
} else {
fail(c, 500, "读取记录失败")
}
return
}
var points []model.WalkPoint
if a.db.Where("walk_id = ?", walk.ID).Order("recorded_at ASC, id ASC").Find(&points).Error != nil {
fail(c, 500, "读取足迹失败")
return
}
ok(c, gin.H{"id": walk.ID, "points": points})
} }
func (a *API) adminMeetups(c *gin.Context) { func (a *API) adminMeetups(c *gin.Context) {
var rows []model.Meetup query := search(a.db.Model(&model.Meetup{}), c, "title", "place_name", "id::text")
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil { query, valid := filterStatus(c, query, "status", "open", "cancelled")
fail(c, 500, "读取约遛失败") if !valid {
return
}
rows, page, valid := adminRows[model.Meetup](c, query, "created_at DESC, id DESC")
if !valid {
return
}
ids := []string{}
for _, m := range rows {
ids = append(ids, m.HostUserID)
}
names, err := a.userNames(ids)
if err != nil {
fail(c, 500, "读取发起人失败")
return return
} }
items := make([]gin.H, 0, len(rows)) items := make([]gin.H, 0, len(rows))
for _, m := range rows { for _, m := range rows {
items = append(items, a.meetupView(m)) v := a.meetupView(m)
v["user_name"] = names[m.HostUserID]
items = append(items, v)
} }
ok(c, items) page.Items = items
ok(c, page)
}
func (a *API) adminTasks(c *gin.Context) {
query := search(a.db.Model(&model.CareTask{}), c, "title", "id::text", "user_id::text", "pet_id::text")
query, valid := filterStatus(c, query, "category", "food", "water", "walk", "medicine", "grooming", "other")
if !valid {
return
}
rows, page, valid := adminRows[model.CareTask](c, query, "created_at DESC, id DESC")
if !valid {
return
}
ids, petIDs, taskIDs := []string{}, []string{}, []string{}
for _, t := range rows {
ids = append(ids, t.UserID)
petIDs = append(petIDs, t.PetID)
taskIDs = append(taskIDs, t.ID)
}
names, err := a.userNames(ids)
if err != nil {
fail(c, 500, "读取用户失败")
return
}
var pets []model.Pet
if len(petIDs) > 0 && a.db.Select("id", "name").Where("id IN ?", petIDs).Find(&pets).Error != nil {
fail(c, 500, "读取宠物失败")
return
}
petNames := map[string]string{}
for _, p := range pets {
petNames[p.ID] = p.Name
}
var completions []model.TaskCompletion
if len(taskIDs) > 0 && a.db.Where("task_id IN ? AND date = ?", taskIDs, time.Now().Format("2006-01-02")).Find(&completions).Error != nil {
fail(c, 500, "读取任务完成情况失败")
return
}
done := map[string]bool{}
for _, completion := range completions {
done[completion.TaskID] = true
}
items := make([]gin.H, 0, len(rows))
for _, t := range rows {
items = append(items, gin.H{"id": t.ID, "user_id": t.UserID, "user_name": names[t.UserID], "pet_id": t.PetID, "pet_name": petNames[t.PetID], "title": t.Title, "category": t.Category, "time_of_day": t.TimeOfDay, "repeat_rule": t.RepeatRule, "reminder_enabled": t.ReminderEnabled, "completed": done[t.ID], "created_at": t.CreatedAt})
}
page.Items = items
ok(c, page)
}
func (a *API) adminFiles(c *gin.Context) {
query := search(a.db.Model(&model.File{}), c, "name", "md5", "id::text")
rows, page, valid := adminRows[model.File](c, query, "created_at DESC, id DESC")
if !valid {
return
}
items := make([]gin.H, 0, len(rows))
for _, file := range rows {
v := a.fileView(file)
v["created_at"] = file.CreatedAt
v["bucket"] = file.Bucket
v["object_key"] = file.ObjectKey
items = append(items, v)
}
page.Items = items
ok(c, page)
} }
func (a *API) adminSetUserDisabled(c *gin.Context) { func (a *API) adminSetUserDisabled(c *gin.Context) {
var in struct { var in struct {
Disabled bool `json:"disabled"` Disabled *bool `json:"disabled"`
} }
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil { if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || in.Disabled == nil {
fail(c, 400, "参数无效") fail(c, 400, "参数无效")
return return
} }
r := a.db.Model(&model.User{}).Where("id = ?", c.Param("id")).Update("disabled", in.Disabled) err := a.db.Transaction(func(tx *gorm.DB) error {
if r.Error != nil || r.RowsAffected == 0 { result := tx.Model(&model.User{}).Where("id = ?", c.Param("id")).Update("disabled", *in.Disabled)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
// Disabling an account also revokes any publicly shared locations.
if *in.Disabled {
return tx.Model(&model.Walk{}).Where("user_id = ?", c.Param("id")).Updates(map[string]any{"share_enabled": false, "share_expires_at": nil}).Error
}
return nil
})
if errors.Is(err, gorm.ErrRecordNotFound) {
fail(c, 404, "用户不存在") fail(c, 404, "用户不存在")
return return
} }
ok(c, gin.H{"disabled": in.Disabled}) if err != nil {
fail(c, 500, "更新用户失败")
return
}
ok(c, gin.H{"disabled": *in.Disabled})
} }
func (a *API) adminSetMeetupStatus(c *gin.Context) { func (a *API) adminSetMeetupStatus(c *gin.Context) {
var in struct { var in struct {
Status string `json:"status"` Status string `json:"status"`
} }
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || (in.Status != "open" && in.Status != "cancelled") { if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || in.Status != "cancelled" {
fail(c, http.StatusBadRequest, "状态无效") fail(c, 400, "状态无效")
return return
} }
r := a.db.Model(&model.Meetup{}).Where("id = ?", c.Param("id")).Update("status", in.Status) result := a.db.Model(&model.Meetup{}).Where("id = ? AND status = ?", c.Param("id"), "open").Update("status", in.Status)
if r.Error != nil || r.RowsAffected == 0 { if result.Error != nil {
fail(c, 404, "约遛不存在") fail(c, 500, "更新约遛失败")
return
}
if result.RowsAffected == 0 {
fail(c, 409, "约遛不存在或已取消")
return return
} }
ok(c, gin.H{"status": in.Status}) ok(c, gin.H{"status": in.Status})
+231
View File
@@ -0,0 +1,231 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/database"
"github.com/sundynix/pets-be/internal/model"
)
func TestAdminPageValidation(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, query := range []string{"", "?page=2&page_size=10", "?page=0", "?page_size=101", "?page=abc", "?page=-1", "?page_size=0"} {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/admin/users"+query, nil)
page, valid := pageParams(context)
expected := query == "" || query == "?page=2&page_size=10"
if valid != expected {
t.Errorf("%s: valid=%v", query, valid)
}
if query == "" && (page.Page != 1 || page.PageSize != 10) {
t.Fatalf("default pagination: %+v", page)
}
if !valid && recorder.Code != 400 {
t.Errorf("%s: status=%d", query, recorder.Code)
}
}
}
func TestAdminListsRequireAdminRole(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.JWT.Secret = "test-secret"
api := New(nil, nil, cfg)
router := gin.New()
api.Register(router)
token, err := api.token(uuid.NewString(), "user")
if err != nil {
t.Fatal(err)
}
for _, route := range []string{"users", "users/" + uuid.NewString() + "/pets", "pets", "walks", "walks/" + uuid.NewString(), "meetups", "tasks", "files", "stats"} {
for _, auth := range []bool{false, true} {
request := httptest.NewRequest(http.MethodGet, "/api/admin/"+route, nil)
if auth {
request.Header.Set("Authorization", "Bearer "+token)
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
expected := http.StatusUnauthorized
if auth {
expected = http.StatusForbidden
}
if recorder.Code != expected {
t.Errorf("%s auth=%v: status=%d", route, auth, recorder.Code)
}
}
}
}
// Opt-in read-only verification against the configured PostgreSQL; never seeds or changes data.
func TestAdminPostgresReadOnly(t *testing.T) {
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
t.Skip("set PETS_ADMIN_INTEGRATION=1 for PostgreSQL verification")
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
db, err := database.Open(cfg.Postgres, true)
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
gin.SetMode(gin.TestMode)
api := New(db, nil, cfg)
router := gin.New()
api.Register(router)
token, err := api.token(uuid.NewString(), "admin")
if err != nil {
t.Fatal(err)
}
read := func(path string) json.RawMessage {
t.Helper()
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != 200 {
t.Fatalf("GET %s: %d %s", path, recorder.Code, recorder.Body.String())
}
var body struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
return body.Data
}
read("/api/admin/stats")
for _, section := range []string{"users", "pets", "walks", "meetups", "tasks", "files"} {
var page struct {
Items []struct {
ID string `json:"id"`
UserID string `json:"user_id"`
PetNames []string `json:"pet_names"`
} `json:"items"`
Total int64 `json:"total"`
PageSize int `json:"page_size"`
Page int `json:"page"`
}
if err := json.Unmarshal(read("/api/admin/"+section+"?page=1&page_size=10"), &page); err != nil {
t.Fatal(err)
}
if page.Page != 1 || page.PageSize != 10 || len(page.Items) > 10 || page.Total < int64(len(page.Items)) {
t.Fatalf("invalid %s pagination: %+v", section, page)
}
read("/api/admin/" + section + "?page=2&page_size=10")
read("/api/admin/" + section + "?q=%25%27%5F")
if len(page.Items) > 0 && section == "users" {
read("/api/admin/users/" + page.Items[0].ID + "/pets")
}
if len(page.Items) > 0 && section == "walks" {
read("/api/admin/walks/" + page.Items[0].ID)
}
}
}
// Fixtures live inside a transaction and are always rolled back.
func TestAdminPaginationAndWalkRelations(t *testing.T) {
gin.SetMode(gin.TestMode)
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
t.Skip("PostgreSQL integration disabled")
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
db, err := database.Open(cfg.Postgres, true)
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
tx := db.Begin()
if tx.Error != nil {
t.Fatal(tx.Error)
}
defer tx.Rollback()
prefix := "admin-test-" + uuid.NewString()
users := make([]model.User, 11)
for i := range users {
users[i] = model.User{Nickname: prefix, OpenID: uuid.NewString()}
}
if tx.Create(&users).Error != nil {
t.Fatal("create temporary users")
}
pets := []model.Pet{{UserID: users[0].ID, Name: "奶盖", Species: "dog"}, {UserID: users[0].ID, Name: "布丁", Species: "dog"}}
if tx.Create(&pets).Error != nil {
t.Fatal("create temporary pets")
}
now := time.Now()
walk := model.Walk{UserID: users[0].ID, Status: "finished", StartedAt: now, SegmentStartedAt: now, ShareToken: uuid.NewString()}
if err := tx.Create(&walk).Error; err != nil {
t.Fatal(err)
}
for _, pet := range pets {
if err := tx.Create(&model.WalkPet{WalkID: walk.ID, PetID: pet.ID}).Error; err != nil {
t.Fatal(err)
}
}
api := New(tx, nil, cfg)
router := gin.New()
api.Register(router)
token, _ := api.token(uuid.NewString(), "admin")
read := func(path string) json.RawMessage {
t.Helper()
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != 200 {
t.Fatalf("%s: %d %s", path, recorder.Code, recorder.Body.String())
}
var body struct {
Data json.RawMessage `json:"data"`
}
json.Unmarshal(recorder.Body.Bytes(), &body)
return body.Data
}
for _, sample := range []struct {
page string
size int
}{{"1", 10}, {"2", 1}} {
var result struct {
Items []json.RawMessage `json:"items"`
Total int64 `json:"total"`
}
if err := json.Unmarshal(read("/api/admin/users?q="+prefix+"&page="+sample.page), &result); err != nil {
t.Fatal(err)
}
if result.Total != 11 || len(result.Items) != sample.size {
t.Fatalf("pagination got %d rows / %d total", len(result.Items), result.Total)
}
}
var children []struct {
Name string `json:"name"`
}
json.Unmarshal(read("/api/admin/users/"+users[0].ID+"/pets"), &children)
if len(children) != 2 {
t.Fatalf("user tree lost pets: %+v", children)
}
var result struct {
Items []struct {
UserID string `json:"user_id"`
UserName string `json:"user_name"`
PetNames []string `json:"pet_names"`
} `json:"items"`
}
json.Unmarshal(read("/api/admin/walks?q="+walk.ID), &result)
if len(result.Items) != 1 || result.Items[0].UserID != users[0].ID || result.Items[0].UserName != prefix || len(result.Items[0].PetNames) != 2 {
t.Fatalf("walk relation incomplete: %+v", result)
}
}
+4
View File
@@ -73,9 +73,13 @@ func (a *API) Register(r *gin.Engine) {
admin.Use(a.requireRole("admin")) admin.Use(a.requireRole("admin"))
admin.GET("/stats", a.adminStats) admin.GET("/stats", a.adminStats)
admin.GET("/users", a.adminUsers) admin.GET("/users", a.adminUsers)
admin.GET("/users/:id/pets", a.adminUserPets)
admin.GET("/pets", a.adminPets) admin.GET("/pets", a.adminPets)
admin.GET("/walks", a.adminWalks) admin.GET("/walks", a.adminWalks)
admin.GET("/walks/:id", a.adminWalkDetail)
admin.GET("/meetups", a.adminMeetups) admin.GET("/meetups", a.adminMeetups)
admin.GET("/tasks", a.adminTasks)
admin.GET("/files", a.adminFiles)
admin.PUT("/users/:id/disabled", a.adminSetUserDisabled) admin.PUT("/users/:id/disabled", a.adminSetUserDisabled)
admin.PUT("/meetups/:id/status", a.adminSetMeetupStatus) admin.PUT("/meetups/:id/status", a.adminSetMeetupStatus)
} }
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/png" href="/brand-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>肉垫计划 · 管理端</title> <title>肉垫计划 · 管理后台</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

+293 -248
View File
@@ -1,64 +1,102 @@
import { useEffect, useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { import {
Users,
PawPrint,
Route,
CalendarDays, CalendarDays,
CheckCheck,
Files,
LayoutDashboard,
LoaderCircle,
LogOut, LogOut,
PawPrint,
RefreshCw, RefreshCw,
Route,
Users,
X,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { api, clearToken, getToken, setToken } from "@/lib/api"; import {
Dialog,
type Tab = "overview" | "users" | "pets" | "walks" | "meetups"; DialogContent,
const labels: Record<Tab, string> = { DialogHeader,
overview: "总览", DialogTitle,
users: "用户", } from "@/components/ui/dialog";
pets: "宠物", import {
walks: "遛狗记录", api,
meetups: "约遛活动", clearToken,
}; getToken,
setToken,
type Row,
type Section,
type Stats,
} from "@/lib/api";
import { labels } from "@/components/admin/format";
import Overview from "@/components/admin/Overview";
import Records from "@/components/admin/Records";
import Details from "@/components/admin/Details";
const icons = { const icons = {
overview: PawPrint, overview: LayoutDashboard,
users: Users, users: Users,
pets: PawPrint, pets: PawPrint,
walks: Route, walks: Route,
meetups: CalendarDays, meetups: CalendarDays,
tasks: CheckCheck,
files: Files,
}; };
const fmt = (v: string) => (v ? new Date(v).toLocaleString("zh-CN") : "—");
export default function App() { export default function App() {
const [token, auth] = useState(getToken()), const [token, setAuth] = useState(getToken()),
[username, setUsername] = useState("sundynix"), [username, setUsername] = useState("sundynix"),
[password, setPassword] = useState(""); [password, setPassword] = useState("");
const [tab, setTab] = useState<Tab>("overview"), const [tab, setTab] = useState<Section | "overview">("overview"),
[items, setItems] = useState<any[]>([]), [revision, refresh] = useState(0),
[stats, setStats] = useState<Record<string, number>>({}); [stats, setStats] = useState<Stats | null>(null);
const [error, setError] = useState(""), const [error, setError] = useState(""),
[busy, setBusy] = useState(false); [busy, setBusy] = useState(false),
async function load() { [actionBusy, setActionBusy] = useState(false),
[notice, setNotice] = useState("");
const [detail, setDetail] = useState<{ section: Section; row: Row } | null>(
null,
),
[confirm, setConfirm] = useState<{ section: Section; row: Row } | null>(
null,
);
useEffect(() => {
const expired = () => {
setAuth("");
setDetail(null);
setConfirm(null);
setError("登录已过期,请重新登录");
};
window.addEventListener("admin-auth-expired", expired);
return () => window.removeEventListener("admin-auth-expired", expired);
}, []);
useEffect(() => {
if (!token || tab !== "overview") return;
const controller = new AbortController();
setBusy(true); setBusy(true);
setError(""); setError("");
try { api
if (tab === "overview") setStats(await api.stats()); .stats(controller.signal)
else setItems(await api[tab]()); .then((value) => {
} catch (e) { if (!controller.signal.aborted) setStats(value);
setError((e as Error).message); })
} finally { .catch((e) => {
setBusy(false); if (!controller.signal.aborted) setError(e.message);
} })
} .finally(() => {
useEffect(() => { if (!controller.signal.aborted) setBusy(false);
if (token) void load(); });
}, [tab, token]); return () => controller.abort();
}, [tab, token, revision]);
async function login(e: FormEvent) { async function login(e: FormEvent) {
e.preventDefault(); e.preventDefault();
if (busy) return;
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
const r = await api.login(username, password); const value = await api.login(username.trim(), password);
setToken(r.token); setToken(value.token);
auth(r.token); setAuth(value.token);
setPassword("");
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
} finally { } finally {
@@ -67,55 +105,117 @@ export default function App() {
} }
function logout() { function logout() {
clearToken(); clearToken();
auth(""); setAuth("");
setPassword(""); setPassword("");
setStats(null);
setDetail(null);
setConfirm(null);
setError("");
setNotice("");
} }
async function toggle(row: any) { function navigate(section: Section | "overview") {
setBusy(false);
setTab(section);
setError("");
setNotice("");
}
async function applyAction() {
if (!confirm || actionBusy) return;
setActionBusy(true);
setError("");
try { try {
await api.setUserDisabled(row.id, !row.disabled); if (confirm.section === "users")
await load(); await api.setUserDisabled(confirm.row.id, !confirm.row.disabled);
} catch (e) { else await api.cancelMeetup(confirm.row.id);
setError((e as Error).message); setNotice(
} confirm.section === "users"
} ? confirm.row.disabled
async function cancel(row: any) { ? "用户已启用"
if (!window.confirm(`取消「${row.title}」?`)) return; : "用户已停用,位置分享已关闭"
try { : "活动已取消",
await api.setMeetupStatus(row.id, "cancelled"); );
await load(); setConfirm(null);
refresh((value) => value + 1);
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
} finally {
setActionBusy(false);
} }
} }
const actionTitle =
confirm?.section === "users"
? confirm.row.disabled
? "启用用户"
: "停用用户"
: "取消约遛活动";
if (!token) if (!token)
return ( return (
<main className="login-page"> <main className="login-page">
<section className="login-story">
<img src="/brand-logo.png" alt="肉垫计划" />
<p className="eyebrow">PAWPRINT PLAN</p>
<h1>
每一段陪伴,
<br />
都有迹可循。
</h1>
<p>
让宠物的日常更有温度,
<br />
让每一次出门都值得记录。
</p>
<div className="story-tags">
<span>
<PawPrint size={16} />
宠物档案
</span>
<span>
<Route size={16} />
快乐出门
</span>
<span>
<CheckCheck size={16} />
日常照顾
</span>
</div>
</section>
<form className="login-card" onSubmit={login}> <form className="login-card" onSubmit={login}>
<div className="login-mark">🐾</div> <span className="login-mark">
<p className="eyebrow">肉垫计划 · 管理端</p> <PawPrint size={26} />
<h1>欢迎回来</h1> </span>
<p className="muted">照顾每一只小短腿的快乐日常。</p> <p className="eyebrow">肉垫计划 · 管理后台</p>
<label> <h2>欢迎回来</h2>
用户名 <p className="muted">今天也一起,照顾好每一个小日常。</p>
<label htmlFor="username">管理员账号</label>
<Input <Input
id="username"
autoComplete="username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
required required
maxLength={64}
/> />
</label> <label htmlFor="password">密码</label>
<label>
密码
<Input <Input
id="password"
type="password" type="password"
autoComplete="current-password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
/> />
</label> {error && (
{error && <p className="error">{error}</p>} <p className="error-banner" role="alert">
<Button disabled={busy} className="w-full"> {error}
登录管理后台 </p>
)}
<Button disabled={busy} className="w-full login-submit">
{busy && <LoaderCircle size={16} className="animate-spin" />}
{busy ? "正在登录" : "进入管理后台"}
</Button> </Button>
<small className="login-footnote">
肉垫计划 · 给陪伴一个温暖的地方
</small>
</form> </form>
</main> </main>
); );
@@ -123,219 +223,164 @@ export default function App() {
<div className="app-shell"> <div className="app-shell">
<aside className="sidebar"> <aside className="sidebar">
<div className="brand"> <div className="brand">
<span>🐾</span> <img src="/brand-logo.png" alt="" />
<div> <div>
<strong>肉垫计划</strong> <strong>肉垫计划</strong>
<small>ADMIN STUDIO</small> <small>PAWPRINT PLAN</small>
</div> </div>
</div> </div>
<p className="side-caption">工作台</p> <p className="side-caption">陪伴工作台</p>
<nav> <nav aria-label="管理导航">
{(Object.keys(labels) as Tab[]).map((key) => { {(Object.keys(labels) as (Section | "overview")[]).map((key) => {
const Icon = icons[key]; const Icon = icons[key];
return ( return (
<button <button
key={key} key={key}
className={tab === key ? "nav-item active" : "nav-item"} className={`nav-item ${tab === key ? "active" : ""}`}
onClick={() => setTab(key)} aria-current={tab === key ? "page" : undefined}
title={labels[key]}
onClick={() => navigate(key)}
> >
<Icon size={18} /> <Icon size={19} />
{labels[key]} <span>{labels[key]}</span>
{tab === key && <span className="nav-dot" />}
</button> </button>
); );
})} })}
</nav> </nav>
<div className="side-note">
<PawPrint size={22} />
<p>
陪伴它们的每一天,
<br />
都值得记录。
</p>
</div>
<div className="side-bottom"> <div className="side-bottom">
<span>让每次出门都值得记录</span> <span className="admin-indicator">管理员</span>
<button onClick={logout}> <Button variant="ghost" onClick={logout} title="退出登录">
<LogOut size={16} /> 退出登录 <LogOut size={16} />
</button> <span>退出登录</span>
</Button>
</div> </div>
</aside> </aside>
<main className="content"> <main className="content">
<header className="topbar"> <header className="topbar">
<div> <div>
<p className="eyebrow">PAWPRINT DASHBOARD</p> <p className="eyebrow">
肉垫计划 / {tab === "overview" ? "今天的日常" : "数据管理"}
</p>
<h1>{labels[tab]}</h1> <h1>{labels[tab]}</h1>
</div> </div>
<Button variant="outline" onClick={() => void load()} disabled={busy}> <div className="topbar-actions">
<RefreshCw size={16} /> 刷新 <span className="today-date">
{new Date().toLocaleDateString("zh-CN", {
month: "long",
day: "numeric",
weekday: "long",
})}
</span>
<Button
variant="outline"
disabled={busy || actionBusy}
onClick={() => refresh((value) => value + 1)}
>
<RefreshCw size={16} className={busy ? "animate-spin" : ""} />
刷新
</Button> </Button>
<Button
variant="ghost"
className="mobile-logout"
size="icon"
onClick={logout}
aria-label="退出登录"
>
<LogOut size={18} />
</Button>
</div>
</header> </header>
{error && <div className="error-banner">{error}</div>} {error && !confirm && (
<div className="error-banner" role="alert">
{error}
<Button variant="ghost" size="sm" onClick={() => setError("")}>
关闭
</Button>
</div>
)}
{notice && (
<div className="notice" role="status">
<CheckCheck size={16} />
{notice}
<button aria-label="关闭提示" onClick={() => setNotice("")}>
<X size={16} />
</button>
</div>
)}
{tab === "overview" ? ( {tab === "overview" ? (
<> <Overview stats={stats} navigate={navigate} />
<div className="hero">
<div>
<p>每一段陪伴,都有迹可循</p>
<h2>你好,铲屎官管理者 👋</h2>
<span>这里汇总肉垫计划的日常活动。</span>
</div>
<div className="hero-paw">🐕</div>
</div>
<div className="stat-grid">
{(
[
["users", "注册用户", "👥"],
["pets", "宠物档案", "🐶"],
["walks", "遛狗记录", "🐾"],
["meetups", "约遛活动", "🌿"],
] as const
).map(([key, label, icon]) => (
<article className="stat-card" key={key}>
<div className="stat-icon">{icon}</div>
<span>{label}</span>
<strong>{stats[key] ?? "—"}</strong>
</article>
))}
</div>
</>
) : ( ) : (
<section className="table-card"> <Records
<div className="table-head"> key={tab}
<h2>{labels[tab]}</h2> section={tab}
<p>最近 {items.length} 条记录</p> revision={revision}
</div> action={(section, row) => {
<div className="table-scroll"> setError("");
<table> setConfirm({ section, row });
<thead> }}
<tr> detail={(section, row) => setDetail({ section, row })}
{tab === "users" ? ( actionBusy={actionBusy}
<> />
<th>用户</th>
<th>注册时间</th>
<th>状态</th>
<th>操作</th>
</>
) : tab === "pets" ? (
<>
<th>宠物</th>
<th>品种</th>
<th>主人 ID</th>
<th>状态</th>
</>
) : tab === "walks" ? (
<>
<th>记录 ID</th>
<th>用户 ID</th>
<th>开始时间</th>
<th>距离</th>
<th>状态</th>
</>
) : (
<>
<th>活动</th>
<th>地点</th>
<th>时间</th>
<th>人数</th>
<th>状态</th>
<th>操作</th>
</>
)}
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={row.id}>
{tab === "users" ? (
<>
<td>
<b>{row.nickname}</b>
<small>{row.id}</small>
</td>
<td>{fmt(row.created_at)}</td>
<td>
<span
className={
"pill " + (row.disabled ? "red" : "green")
}
>
{row.disabled ? "已停用" : "正常"}
</span>
</td>
<td>
<Button
variant="outline"
size="sm"
onClick={() => void toggle(row)}
>
{row.disabled ? "启用" : "停用"}
</Button>
</td>
</>
) : tab === "pets" ? (
<>
<td>
<b>{row.name}</b>
<small>{row.id}</small>
</td>
<td>{row.breed || row.species}</td>
<td className="mono">{row.user_id}</td>
<td>
<span
className={
"pill " + (row.archived ? "red" : "green")
}
>
{row.archived ? "已归档" : "在用"}
</span>
</td>
</>
) : tab === "walks" ? (
<>
<td className="mono">{row.id}</td>
<td className="mono">{row.user_id}</td>
<td>{fmt(row.started_at)}</td>
<td>{(row.distance_meters / 1000).toFixed(2)} km</td>
<td>
<span className="pill green">{row.status}</span>
</td>
</>
) : (
<>
<td>
<b>{row.title}</b>
<small>{row.id}</small>
</td>
<td>{row.place_name}</td>
<td>{fmt(row.starts_at)}</td>
<td>
{row.participants}/{row.max_people}
</td>
<td>
<span
className={
"pill " +
(row.status === "open" ? "green" : "red")
}
>
{row.status}
</span>
</td>
<td>
{row.status === "open" && (
<Button
variant="outline"
size="sm"
onClick={() => void cancel(row)}
>
取消
</Button>
)}
</td>
</>
)}
</tr>
))}
</tbody>
</table>
{items.length === 0 && (
<div className="empty">还没有记录,新的足迹会出现在这里 🐾</div>
)}
</div>
</section>
)} )}
<footer className="page-footer">
肉垫计划 · 把每一份陪伴,认真记下来
</footer>
</main> </main>
<Details value={detail} close={() => setDetail(null)} />
<Dialog
open={!!confirm}
onOpenChange={(open) => {
if (!open && !actionBusy) {
setConfirm(null);
setError("");
}
}}
>
<DialogContent aria-describedby="action-description">
<DialogHeader>
<DialogTitle>{actionTitle}</DialogTitle>
</DialogHeader>
<p id="action-description" className="confirm-copy">
{confirm?.section === "users"
? confirm.row.disabled
? `确认恢复「${confirm.row.nickname || "该用户"}」的访问权限?`
: `停用「${confirm.row.nickname || "该用户"}」后,将无法使用小程序接口,现有位置分享也会关闭。`
: `确认取消「${confirm?.row.title}」?取消后活动将停止接受报名。`}
</p>
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
<div className="dialog-actions">
<Button
variant="outline"
disabled={actionBusy}
onClick={() => {
setConfirm(null);
setError("");
}}
>
暂不操作
</Button>
<Button disabled={actionBusy} onClick={() => void applyAction()}>
{actionBusy && (
<LoaderCircle size={16} className="animate-spin" />
)}
确认{actionTitle}
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
@@ -0,0 +1,260 @@
import { useEffect, useState } from "react";
import { ExternalLink, LoaderCircle, Route } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { api, type Point, type Row, type Section } from "@/lib/api";
import { Identity, Status } from "./common";
import { bytes, categories, duration, fmt } from "./format";
function Track({ points }: { points: Point[] }) {
const valid = points.filter(
(p) => Number.isFinite(p.latitude) && Number.isFinite(p.longitude),
);
if (!valid.length)
return (
<div className="empty compact">
<Route size={28} />
这条记录尚未采集足迹
</div>
);
const origin = valid[0];
const coords = valid.map((p) => [
(p.longitude - origin.longitude) *
Math.cos((origin.latitude * Math.PI) / 180),
p.latitude - origin.latitude,
]);
const xs = coords.map((p) => p[0]),
ys = coords.map((p) => p[1]);
const minX = Math.min(...xs),
maxX = Math.max(...xs),
minY = Math.min(...ys),
maxY = Math.max(...ys);
const scale = Math.min(
440 / (maxX - minX || 1e-6),
200 / (maxY - minY || 1e-6),
);
const normalized = coords.map(([x, y]) => [
250 + (x - (minX + maxX) / 2) * scale,
120 - (y - (minY + maxY) / 2) * scale,
]);
const [first, last] = [normalized[0], normalized[normalized.length - 1]];
return (
<div className="track">
<svg viewBox="0 0 500 240" role="img" aria-label="遛狗足迹路线示意">
<defs>
<pattern
id="grid"
width="25"
height="25"
patternUnits="userSpaceOnUse"
>
<path
d="M 25 0 L 0 0 0 25"
fill="none"
stroke="#eaded3"
strokeWidth="1"
/>
</pattern>
</defs>
<rect width="500" height="240" fill="url(#grid)" />
<polyline
points={normalized.map((p) => p.join(",")).join(" ")}
fill="none"
stroke="#b95738"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx={first[0]}
cy={first[1]}
r="7"
fill="#8aa2bd"
stroke="white"
strokeWidth="3"
/>
<circle
cx={last[0]}
cy={last[1]}
r="7"
fill="#b95738"
stroke="white"
strokeWidth="3"
/>
</svg>
<p>足迹路线示意 · 蓝色起点 / 橙色终点 · {valid.length} 个位置点</p>
</div>
);
}
export default function Details({
value,
close,
}: {
value: { section: Section; row: Row } | null;
close: () => void;
}) {
const [points, setPoints] = useState<Point[]>([]),
[busy, setBusy] = useState(false),
[error, setError] = useState("");
useEffect(() => {
setPoints([]);
setError("");
setBusy(false);
if (!value || value.section !== "walks") return;
const controller = new AbortController();
setBusy(true);
api
.walk(value.row.id, controller.signal)
.then((result) => {
if (!controller.signal.aborted) setPoints(result.points);
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setBusy(false);
});
return () => controller.abort();
}, [value]);
const row = value?.row,
section = value?.section;
const entries: [string, string | number | undefined][] = !row
? []
: section === "pets"
? [
["主人", row.user_name],
["主人 ID", row.user_id],
["品种", row.breed || row.species],
[
"性别",
row.gender === "male"
? "男孩"
: row.gender === "female"
? "女孩"
: "未填写",
],
["生日", row.birth_date],
["体重", row.weight_kg ? `${row.weight_kg} kg` : "未填写"],
["毛色", row.coat],
["过敏提醒", row.allergies],
["照顾备注", row.care_notes],
["创建时间", fmt(row.created_at)],
]
: section === "walks"
? [
["遛狗人", row.user_name],
["用户 ID", row.user_id],
["同行宠物", row.pet_names?.join("、") || "—"],
["开始时间", fmt(row.started_at)],
["结束时间", fmt(row.ended_at)],
["距离", `${((row.distance_meters || 0) / 1000).toFixed(2)} km`],
["已记录时长", duration(row.duration_seconds)],
["位置分享", row.share_enabled ? "已开启" : "未开启"],
]
: section === "meetups"
? [
["发起人", row.user_name],
["集合地点", row.place_name],
["活动时间", fmt(row.starts_at)],
["参与人数", `${row.participants}/${row.max_people}`],
["活动说明", row.description],
["坐标", `${row.longitude}, ${row.latitude}`],
]
: section === "tasks"
? [
["用户", row.user_name],
["宠物", row.pet_name],
["类别", categories[row.category || ""] || row.category],
["计划时间", row.time_of_day],
["重复规则", row.repeat_rule === "daily" ? "每天" : "单次"],
["创建时间", fmt(row.created_at)],
]
: [
["文件类型", row.content_type],
["大小", bytes(row.size)],
["MD5", row.md5],
["存储桶", row.bucket],
["对象路径", row.object_key],
["文件 ID", row.id],
["上传时间", fmt(row.created_at)],
];
return (
<Dialog
open={!!value}
onOpenChange={(open) => {
if (!open) close();
}}
>
<DialogContent className="detail-dialog" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>
{row?.name ||
row?.title ||
(section === "walks" ? "遛狗详情" : "记录详情")}
</DialogTitle>
</DialogHeader>
{row && (
<>
<div className="detail-summary">
<Identity
name={row.name || row.title || row.user_name}
id={row.id}
url={row.avatar_url}
/>
<Status row={row} section={section!} />
</div>
{section === "files" && row.url && (
<>
<img
className="file-preview"
src={row.url}
alt={row.name || "文件预览"}
/>
<a
href={row.url}
target="_blank"
rel="noreferrer"
className="file-link"
>
查看原图
<ExternalLink size={14} />
</a>
</>
)}
<dl className="detail-grid">
{entries.map(([label, text]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{text || "—"}</dd>
</div>
))}
</dl>
{section === "walks" &&
(busy ? (
<div className="empty compact">
<LoaderCircle className="animate-spin" />
加载足迹中
</div>
) : error ? (
<p className="error-banner" role="alert">
{error}
</p>
) : (
<Track points={points} />
))}
<div className="dialog-actions">
<Button variant="outline" onClick={close}>
关闭
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,145 @@
import {
CalendarDays,
CheckCheck,
Files,
PawPrint,
Route,
Users,
ArrowUpRight,
} from "lucide-react";
import type { Section, Stats } from "@/lib/api";
import { bytes, labels } from "./format";
const metrics = [
{ key: "users", icon: Users, sub: "每一位养宠伙伴" },
{ key: "pets", icon: PawPrint, sub: "记录它们的成长" },
{ key: "walks", icon: Route, sub: "一起走过的路" },
{ key: "meetups", icon: CalendarDays, sub: "相约下一次出发" },
{ key: "tasks", icon: CheckCheck, sub: "照顾好每一个日常" },
{ key: "files", icon: Files, sub: "留住温暖的瞬间" },
] as const;
export default function Overview({
stats,
navigate,
}: {
stats: Stats | null;
navigate: (section: Section) => void;
}) {
const max = Math.max(
1,
...(stats?.daily_walks || []).map((day) => day.count),
);
return (
<>
<section className="welcome">
<div>
<p className="eyebrow">A LITTLE CARE, EVERY DAY</p>
<h2>把小日常,照顾成好日子。</h2>
<p>从一张宠物档案,到每一次快乐出门。</p>
</div>
<img src="/brand-logo.png" alt="肉垫计划" />
</section>
<div className="stat-grid">
{metrics.map(({ key, icon: Icon, sub }) => (
<button
key={key}
className={`stat-card tint-${key}`}
onClick={() => navigate(key)}
>
<div className="stat-top">
<span className="stat-icon">
<Icon size={20} />
</span>
<ArrowUpRight size={16} />
</div>
<span>{labels[key]}</span>
<strong>{stats?.[key] ?? "—"}</strong>
<small>{sub}</small>
</button>
))}
</div>
<div className="overview-grid">
<section className="panel">
<header className="panel-heading">
<div>
<h2>这周,一起走了多少次</h2>
<p>最近 7 天的遛狗记录</p>
</div>
<Route size={20} />
</header>
<div className="bar-chart" role="img" aria-label="最近七天遛狗次数">
{(stats?.daily_walks || []).map((day) => (
<div className="chart-column" key={day.date}>
<span>{day.count}</span>
<div className="bar-space">
<div
className="chart-bar"
style={{
height: `${(day.count / max) * 100}%`,
minHeight: day.count ? 6 : 2,
}}
title={`${day.date}:${day.count}次`}
/>
</div>
<small>{day.date.slice(5).replace("-", "/")}</small>
</div>
))}
{!stats && <div className="empty">正在加载趋势</div>}
</div>
</section>
<section className="panel today">
<header className="panel-heading">
<div>
<h2>今天的陪伴</h2>
<p>活动与日常一目了然</p>
</div>
<CalendarDays size={20} />
</header>
<dl>
<div>
<dt>今日出门</dt>
<dd>
{stats?.today_walks ?? "—"}
<small>次</small>
</dd>
</div>
<div>
<dt>正在遛狗 / 暂停中</dt>
<dd>
{stats?.active_walks ?? "—"}
<small>次</small>
</dd>
</div>
<div>
<dt>待开始的约遛</dt>
<dd>
{stats?.upcoming_meetups ?? "—"}
<small>场</small>
</dd>
</div>
<div>
<dt>今日任务已完成</dt>
<dd>
{stats?.today_completions ?? "—"}
<small>项</small>
</dd>
</div>
</dl>
</section>
</div>
<section className="summary-strip">
<div>
<Route size={18} />
<span>累计遛狗距离</span>
<strong>
{stats ? (stats.distance_meters / 1000).toFixed(2) : "—"} km
</strong>
</div>
<div>
<Files size={18} />
<span>文件存储用量</span>
<strong>{stats ? bytes(stats.storage_bytes) : "—"}</strong>
</div>
</section>
</>
);
}
@@ -0,0 +1,508 @@
import { Fragment, useEffect, useState } from "react";
import {
ChevronDown,
ChevronRight,
ChevronLeft,
Search,
LoaderCircle,
PawPrint,
Inbox,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import { api, type Row, type PageResult, type Section } from "@/lib/api";
import { Identity, Status } from "./common";
import {
bytes,
categories,
duration,
filters,
fmt,
labels,
placeholders,
} from "./format";
function UserRow({
row,
action,
detail,
disabled,
}: {
row: Row;
action: (row: Row) => void;
detail: (section: Section, row: Row) => void;
disabled: boolean;
}) {
const [expanded, setExpanded] = useState(false),
[pets, setPets] = useState<Row[] | null>(null),
[error, setError] = useState(""),
[busy, setBusy] = useState(false),
[retry, setRetry] = useState(0);
useEffect(() => {
if (!expanded || pets) return;
const controller = new AbortController();
setBusy(true);
setError("");
api
.userPets(row.id, controller.signal)
.then((result) => {
if (!controller.signal.aborted) { setPets(result); setBusy(false); }
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setBusy(false);
});
return () => controller.abort();
}, [expanded, row.id, pets, retry]);
return (
<Fragment>
<TableRow>
<TableCell>
<button
className="tree-toggle"
aria-expanded={expanded}
aria-label={`${expanded ? "收起" : "展开"}${row.nickname || "用户"}的宠物`}
onClick={() => setExpanded(!expanded)}
>
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
</TableCell>
<TableCell>
<Identity
name={row.nickname}
id={row.id}
url={row.avatar_url || ""}
person
/>
</TableCell>
<TableCell>
<button
className="text-action"
onClick={() => setExpanded(!expanded)}
>
{row.pet_count || 0} 只宠物
</button>
</TableCell>
<TableCell>{fmt(row.created_at)}</TableCell>
<TableCell>
<Status row={row} section="users" />
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
disabled={disabled}
onClick={() => action(row)}
>
{row.disabled ? "启用用户" : "停用用户"}
</Button>
</TableCell>
</TableRow>
{expanded && (
<TableRow className="tree-row">
<TableCell colSpan={6}>
<div className="pet-tree">
<div className="tree-heading">
<PawPrint size={16} />
<strong>{row.nickname || "用户"}的宠物</strong>
<span>包括已归档档案</span>
</div>
{busy ? (
<div className="tree-message">
<LoaderCircle size={16} className="animate-spin" />
加载宠物中
</div>
) : error ? (
<div className="tree-message error-text">
{error}
<Button
size="sm"
variant="outline"
onClick={() => setRetry(retry + 1)}
>
重试
</Button>
</div>
) : !pets?.length ? (
<div className="tree-message">该用户还没有添加宠物</div>
) : (
<div className="pet-tree-grid">
{pets.map((pet) => (
<button
className="pet-tree-card"
key={pet.id}
onClick={() => detail("pets", pet)}
>
<Identity
name={pet.name}
id={pet.breed || "未填写品种"}
url={pet.avatar_url || ""}
/>
<Status row={pet} section="pets" />
<ChevronRight size={16} />
</button>
))}
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</Fragment>
);
}
const headings: Record<Section, string[]> = {
users: ["", "用户", "宠物", "注册时间", "状态", "操作"],
pets: ["宠物", "品种 / 性别", "主人", "体重", "状态", "操作"],
walks: ["遛狗人", "同行宠物", "开始时间", "距离 / 时长", "状态", "操作"],
meetups: ["活动", "发起人", "地点 / 时间", "人数", "状态", "操作"],
tasks: ["任务", "用户 / 宠物", "类别", "计划时间", "今日状态", "操作"],
files: ["文件", "类型", "大小", "上传时间", "操作"],
};
export default function Records({
section,
revision,
action,
detail,
actionBusy,
}: {
section: Section;
revision: number;
action: (section: Section, row: Row) => void;
detail: (section: Section, row: Row) => void;
actionBusy: boolean;
}) {
const [page, setPage] = useState(1),
[query, setQuery] = useState(""),
[draft, setDraft] = useState(""),
[status, setStatus] = useState("");
const [data, setData] = useState<PageResult | null>(null),
[busy, setBusy] = useState(true),
[error, setError] = useState(""),
[retry, setRetry] = useState(0);
useEffect(() => {
const controller = new AbortController();
setBusy(true);
setError("");
setData(null);
api
.list(
section,
{ page, page_size: 10, q: query, status },
controller.signal,
)
.then((result) => {
if (controller.signal.aborted) return;
if (page > Math.max(1, Math.ceil(result.total / 10))) {
setPage(Math.max(1, Math.ceil(result.total / 10)));
return;
}
setData(result);
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setBusy(false);
});
return () => controller.abort();
}, [section, page, query, status, revision, retry]);
const pages = Math.max(1, Math.ceil((data?.total || 0) / 10));
return (
<section className="panel records">
<div className="records-heading">
<div>
<h2>{labels[section]}</h2>
<p>
{data ? `共 ${data.total} 条记录` : "读取记录中"}
{section === "users" && " · 展开用户查看宠物"}
</p>
</div>
<span className="per-page">每页 10 条</span>
</div>
<div className="toolbar">
<form
onSubmit={(e) => {
e.preventDefault();
setPage(1);
setQuery(draft.trim());
setRetry(retry + 1);
}}
className="search-form"
>
<Search size={17} />
<Input
value={draft}
maxLength={128}
onChange={(e) => setDraft(e.target.value)}
placeholder={placeholders[section]}
aria-label={placeholders[section]}
/>
<Button variant="outline" type="submit" disabled={busy}>
搜索
</Button>
</form>
{filters[section] && (
<select
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(1);
}}
aria-label="筛选状态"
>
<option value="">
{section === "tasks" ? "全部类别" : "全部状态"}
</option>
{filters[section]!.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
)}
{(query || status) && (
<Button
variant="ghost"
onClick={() => {
setDraft("");
setQuery("");
setStatus("");
setPage(1);
}}
>
重置
</Button>
)}
</div>
{error ? (
<div className="empty error-text" role="alert">
<Inbox size={30} />
<p>{error}</p>
<Button variant="outline" onClick={() => setRetry(retry + 1)}>
重新加载
</Button>
</div>
) : busy ? (
<div className="empty" role="status">
<LoaderCircle size={28} className="animate-spin" />
<p>正在加载{labels[section]}</p>
</div>
) : !data?.items.length ? (
<div className="empty">
<Inbox size={32} />
<h3>{query || status ? "没有符合条件的记录" : "这里还没有记录"}</h3>
<p>
{query || status
? "试试其他关键词或清除筛选条件。"
: "用户在小程序中的日常,会慢慢汇集到这里。"}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
{headings[section].map((heading, index) => (
<TableHead key={index}>{heading}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((row) =>
section === "users" ? (
<UserRow
key={`${revision}-${row.id}`}
row={row}
action={(row) => action("users", row)}
detail={detail}
disabled={actionBusy}
/>
) : (
<TableRow key={row.id}>
{section === "pets" ? (
<>
<TableCell>
<Identity
name={row.name}
id={row.id}
url={row.avatar_url || ""}
/>
</TableCell>
<TableCell>
{row.breed || "未填写品种"}
<small>
{row.gender === "male"
? "男孩"
: row.gender === "female"
? "女孩"
: "未填写"}
</small>
</TableCell>
<TableCell>
<Identity
name={row.user_name}
id={row.user_id}
person
/>
</TableCell>
<TableCell>
{row.weight_kg ? `${row.weight_kg} kg` : "—"}
</TableCell>
<TableCell>
<Status row={row} section={section} />
</TableCell>
</>
) : section === "walks" ? (
<>
<TableCell>
<Identity
name={row.user_name}
id={row.user_id}
person
/>
</TableCell>
<TableCell>
<div className="pet-tags">
{row.pet_names?.length
? row.pet_names.map((name, index) => (
<span key={index}>
<PawPrint size={12} />
{name}
</span>
))
: "—"}
</div>
<small className="mono" title={row.id}>
记录 {row.id.slice(0, 8)}
</small>
</TableCell>
<TableCell>{fmt(row.started_at)}</TableCell>
<TableCell>
{((row.distance_meters || 0) / 1000).toFixed(2)} km
<small>{duration(row.duration_seconds)}</small>
</TableCell>
<TableCell>
<Status row={row} section={section} />
</TableCell>
</>
) : section === "meetups" ? (
<>
<TableCell>
<strong>{row.title}</strong>
<small className="mono">{row.id.slice(0, 8)}</small>
</TableCell>
<TableCell>{row.user_name || "未设置昵称"}</TableCell>
<TableCell>
{row.place_name}
<small>{fmt(row.starts_at)}</small>
</TableCell>
<TableCell>
{row.participants} / {row.max_people}
</TableCell>
<TableCell>
<Status row={row} section={section} />
</TableCell>
</>
) : section === "tasks" ? (
<>
<TableCell>
<strong>{row.title}</strong>
<small>
{row.repeat_rule === "daily" ? "每天" : "单次"}
</small>
</TableCell>
<TableCell>
{row.user_name || "未设置昵称"}
<small>{row.pet_name || "—"}</small>
</TableCell>
<TableCell>
{categories[row.category || ""] || row.category || "—"}
</TableCell>
<TableCell>{row.time_of_day}</TableCell>
<TableCell>
<Status row={row} section={section} />
</TableCell>
</>
) : (
<>
<TableCell>
<strong className="file-name" title={row.name}>
{row.name}
</strong>
<small className="mono">{row.id.slice(0, 8)}</small>
</TableCell>
<TableCell>{row.content_type}</TableCell>
<TableCell>{bytes(row.size)}</TableCell>
<TableCell>{fmt(row.created_at)}</TableCell>
</>
)}
<TableCell>
<div className="row-actions">
<Button
variant="ghost"
size="sm"
onClick={() => detail(section, row)}
>
查看详情
</Button>
{section === "meetups" && row.status === "open" && (
<Button
variant="outline"
size="sm"
disabled={actionBusy}
onClick={() => action(section, row)}
>
取消活动
</Button>
)}
</div>
</TableCell>
</TableRow>
),
)}
</TableBody>
</Table>
)}
<footer className="pagination">
<span>
{data?.total
? `第 ${(page - 1) * 10 + 1}–${Math.min(page * 10, data.total)} 条,共 ${data.total} 条`
: "共 0 条"}
</span>
<div>
<Button
variant="outline"
size="icon"
aria-label="上一页"
disabled={busy || page <= 1}
onClick={() => setPage(page - 1)}
>
<ChevronLeft size={16} />
</Button>
<span>
第 {page} / {pages} 页
</span>
<Button
variant="outline"
size="icon"
aria-label="下一页"
disabled={busy || page >= pages}
onClick={() => setPage(page + 1)}
>
<ChevronRight size={16} />
</Button>
</div>
</footer>
</section>
);
}
@@ -0,0 +1,80 @@
import { PawPrint, UserRound } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import type { Row, Section } from "@/lib/api";
export function Avatar({
url,
person = false,
}: {
url?: string;
person?: boolean;
}) {
const [broken, setBroken] = useState("");
return (
<span className="avatar">
{url && broken !== url ? (
<img src={url} alt="" onError={() => setBroken(url)} />
) : person ? (
<UserRound size={20} />
) : (
<PawPrint size={20} />
)}
</span>
);
}
export function Status({ row, section }: { row: Row; section: Section }) {
const label =
section === "users"
? row.disabled
? "已停用"
: "正常"
: section === "pets"
? row.archived
? "已归档"
: "在用"
: section === "tasks"
? row.completed
? "今日已完成"
: "今日待完成"
: {
active: "遛狗中",
paused: "已暂停",
finished: "已结束",
open: "开放",
cancelled: "已取消",
}[row.status || ""] || "—";
const ended = row.disabled || row.archived || row.status === "cancelled";
return (
<Badge
variant={
ended ? "muted" : row.status === "paused" ? "warning" : "default"
}
>
{label}
</Badge>
);
}
export function Identity({
name,
id,
url,
person = false,
}: {
name?: string;
id?: string;
url?: string;
person?: boolean;
}) {
return (
<div className="identity">
{url !== undefined && <Avatar url={url} person={person} />}
<div>
<strong>{name || (person ? "未设置昵称" : "未命名")}</strong>
<small className="mono" title={id}>
{id || "—"}
</small>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import type { Section } from "@/lib/api";
export const labels: Record<Section | "overview", string> = {
overview: "工作台",
users: "用户管理",
pets: "宠物档案",
walks: "遛狗记录",
meetups: "约遛活动",
tasks: "日常任务",
files: "文件管理",
};
export const fmt = (value?: string) =>
value && !Number.isNaN(Date.parse(value))
? new Date(value).toLocaleString("zh-CN", { hour12: false })
: "—";
export const bytes = (value = 0) =>
value >= 1048576
? `${(value / 1048576).toFixed(1)} MB`
: `${(value / 1024).toFixed(1)} KB`;
export const duration = (value = 0) =>
`${Math.floor(value / 3600)}小时 ${Math.floor((value % 3600) / 60)}分钟`;
export const categories: Record<string, string> = {
food: "喂食",
water: "饮水",
walk: "运动",
medicine: "用药",
grooming: "清洁",
other: "其他",
};
export const filters: Partial<Record<Section, [string, string][]>> = {
users: [
["enabled", "正常"],
["disabled", "已停用"],
],
pets: [
["active", "在用"],
["archived", "已归档"],
],
walks: [
["active", "遛狗中"],
["paused", "已暂停"],
["finished", "已结束"],
],
meetups: [
["open", "开放"],
["cancelled", "已取消"],
],
tasks: Object.entries(categories),
};
export const placeholders: Record<Section, string> = {
users: "搜索用户昵称或 ID",
pets: "搜索宠物名字、品种或主人 ID",
walks: "搜索记录 ID 或用户 ID",
meetups: "搜索活动名称、地点或 ID",
tasks: "搜索任务名称、用户或宠物 ID",
files: "搜索文件名、MD5 或 ID",
};
@@ -40,4 +40,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
) )
Button.displayName = 'Button' Button.displayName = 'Button'
export { Button, buttonVariants } export { Button }
+36 -15
View File
@@ -1,11 +1,11 @@
import * as React from 'react' import * as React from "react";
import * as DialogPrimitive from '@radix-ui/react-dialog' import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from 'lucide-react' import { X } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger const DialogTrigger = DialogPrimitive.Trigger;
const DialogClose = DialogPrimitive.Close const DialogClose = DialogPrimitive.Close;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
@@ -16,7 +16,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg rounded-xl', "fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg rounded-xl",
className, className,
)} )}
{...props} {...props}
@@ -24,18 +24,39 @@ const DialogContent = React.forwardRef<
{children} {children}
<DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 hover:opacity-100"> <DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 hover:opacity-100">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">关闭</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPrimitive.Portal> </DialogPrimitive.Portal>
)) ));
DialogContent.displayName = 'DialogContent' DialogContent.displayName = "DialogContent";
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { function DialogHeader({
return <div className={cn('flex flex-col space-y-1.5', className)} {...props} /> className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("flex flex-col space-y-1.5", className)} {...props} />
);
} }
function DialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) { function DialogTitle({
return <h2 className={cn('text-lg font-semibold', className)} {...props} /> className,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
} }
export { Dialog, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogTitle } export {
Dialog,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
};
File diff suppressed because it is too large Load Diff
+113 -11
View File
@@ -1,9 +1,89 @@
import axios from "axios"; import axios from "axios";
export type Section =
"users" | "pets" | "walks" | "meetups" | "tasks" | "files";
export interface Row {
id: string;
nickname?: string;
name?: string;
title?: string;
avatar_url?: string;
avatar_file_id?: string;
cover_url?: string;
user_id?: string;
user_name?: string;
host_user_id?: string;
pet_id?: string;
pet_name?: string;
pet_names?: string[];
pet_count?: number;
disabled?: boolean;
archived?: boolean;
status?: string;
created_at?: string;
started_at?: string;
ended_at?: string;
starts_at?: string;
breed?: string;
species?: string;
gender?: string;
birth_date?: string;
weight_kg?: number;
coat?: string;
allergies?: string;
care_notes?: string;
duration_seconds?: number;
distance_meters?: number;
share_enabled?: boolean;
place_name?: string;
description?: string;
latitude?: number;
longitude?: number;
participants?: number;
max_people?: number;
category?: string;
time_of_day?: string;
repeat_rule?: string;
completed?: boolean;
reminder_enabled?: boolean;
size?: number;
content_type?: string;
md5?: string;
url?: string;
bucket?: string;
object_key?: string;
}
export interface Point {
latitude: number;
longitude: number;
recorded_at: string;
}
export interface PageResult {
items: Row[];
total: number;
page: number;
page_size: number;
}
export interface Stats {
users: number;
pets: number;
walks: number;
meetups: number;
tasks: number;
files: number;
today_walks: number;
active_walks: number;
upcoming_meetups: number;
today_completions: number;
distance_meters: number;
duration_seconds: number;
storage_bytes: number;
daily_walks: { date: string; count: number }[];
}
const key = "pets_admin_token"; const key = "pets_admin_token";
export const getToken = () => localStorage.getItem(key) || ""; export const getToken = () => localStorage.getItem(key) || "";
export const setToken = (value: string) => localStorage.setItem(key, value); export const setToken = (value: string) => localStorage.setItem(key, value);
export const clearToken = () => localStorage.removeItem(key); export const clearToken = () => localStorage.removeItem(key);
const http = axios.create({ baseURL: "/api" }); const http = axios.create({ baseURL: "/api", timeout: 20000 });
http.interceptors.request.use((config) => { http.interceptors.request.use((config) => {
const token = getToken(); const token = getToken();
if (token) config.headers.Authorization = `Bearer ${token}`; if (token) config.headers.Authorization = `Bearer ${token}`;
@@ -16,20 +96,42 @@ http.interceptors.response.use(
throw new Error(body?.message || "请求失败"); throw new Error(body?.message || "请求失败");
}, },
(error) => { (error) => {
if (error.response?.status === 401) clearToken(); if (axios.isCancel(error)) return Promise.reject(error);
throw new Error(error.response?.data?.message || error.message); if (error.response?.status === 401) {
clearToken();
window.dispatchEvent(new Event("admin-auth-expired"));
}
return Promise.reject(
new Error(
error.response?.data?.message ||
(error.code === "ECONNABORTED"
? "请求超时,请重试"
: "无法连接服务器,请稍后重试"),
),
);
}, },
); );
export const api = { export const api = {
login: (username: string, password: string) => login: (username: string, password: string) =>
http.post<any, { token: string }>("/admin/login", { username, password }), http.post<unknown, { token: string }>("/admin/login", {
stats: () => http.get<any, Record<string, number>>("/admin/stats"), username,
users: () => http.get<any, any[]>("/admin/users"), password,
pets: () => http.get<any, any[]>("/admin/pets"), }),
walks: () => http.get<any, any[]>("/admin/walks"), stats: (signal?: AbortSignal) =>
meetups: () => http.get<any, any[]>("/admin/meetups"), http.get<unknown, Stats>("/admin/stats", { signal }),
list: (
section: Section,
params: { page: number; page_size: number; q: string; status: string },
signal?: AbortSignal,
) => http.get<unknown, PageResult>(`/admin/${section}`, { params, signal }),
userPets: (id: string, signal?: AbortSignal) =>
http.get<unknown, Row[]>(`/admin/users/${id}/pets`, { signal }),
walk: (id: string, signal?: AbortSignal) =>
http.get<unknown, { id: string; points: Point[] }>(`/admin/walks/${id}`, {
signal,
}),
setUserDisabled: (id: string, disabled: boolean) => setUserDisabled: (id: string, disabled: boolean) =>
http.put(`/admin/users/${id}/disabled`, { disabled }), http.put(`/admin/users/${id}/disabled`, { disabled }),
setMeetupStatus: (id: string, status: string) => cancelMeetup: (id: string) =>
http.put(`/admin/meetups/${id}/status`, { status }), http.put(`/admin/meetups/${id}/status`, { status: "cancelled" }),
}; };