Complete admin dashboard with paginated user pet trees and walk relations
This commit is contained in:
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
BIN
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
-1
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
-24
@@ -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
@@ -2,11 +2,11 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<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" />
|
||||
<title>肉垫计划 · 管理端</title>
|
||||
<script type="module" crossorigin src="/assets/index-COwxdvZQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BQA5NViF.css">
|
||||
<title>肉垫计划 · 管理后台</title>
|
||||
<script type="module" crossorigin src="/assets/index-DkKuSusU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B39ofd9P.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestAdminAtRootAndAPIRoutes(t *testing.T) {
|
||||
}{
|
||||
{path: "/", status: http.StatusOK, body: "<div id=\"root\"></div>"},
|
||||
{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/missing", status: http.StatusNotFound},
|
||||
{path: "/assets/missing.js", status: http.StatusNotFound},
|
||||
|
||||
@@ -1,98 +1,462 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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) {
|
||||
counts := gin.H{}
|
||||
for _, pair := range []struct {
|
||||
name string
|
||||
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
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
func (a *API) adminUsers(c *gin.Context) {
|
||||
var rows []model.User
|
||||
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
|
||||
fail(c, 500, "读取用户失败")
|
||||
query := search(a.db.Model(&model.User{}), c, "nickname", "id::text")
|
||||
switch c.Query("status") {
|
||||
case "":
|
||||
case "enabled":
|
||||
query = query.Where("disabled = false")
|
||||
case "disabled":
|
||||
query = query.Where("disabled = true")
|
||||
default:
|
||||
fail(c, 400, "筛选状态无效")
|
||||
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))
|
||||
for _, u := range rows {
|
||||
v := a.userView(u)
|
||||
v["disabled"] = u.Disabled
|
||||
items = append(items, v)
|
||||
view := a.userView(u)
|
||||
view["disabled"] = u.Disabled
|
||||
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)
|
||||
}
|
||||
func (a *API) adminPets(c *gin.Context) {
|
||||
var rows []model.Pet
|
||||
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
|
||||
fail(c, 500, "读取宠物失败")
|
||||
query := search(a.db.Model(&model.Pet{}), c, "name", "breed", "id::text", "user_id::text")
|
||||
switch c.Query("status") {
|
||||
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
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, p := range rows {
|
||||
v := a.petView(p)
|
||||
v["user_id"] = p.UserID
|
||||
v["user_name"] = names[p.UserID]
|
||||
v["archived"] = p.Archived
|
||||
items = append(items, v)
|
||||
}
|
||||
ok(c, items)
|
||||
page.Items = items
|
||||
ok(c, page)
|
||||
}
|
||||
func (a *API) adminWalks(c *gin.Context) {
|
||||
var rows []model.Walk
|
||||
if a.db.Order("started_at DESC").Limit(200).Find(&rows).Error != nil {
|
||||
fail(c, 500, "读取遛狗失败")
|
||||
query := search(a.db.Model(&model.Walk{}), c, "id::text", "user_id::text")
|
||||
query, valid := filterStatus(c, query, "status", "active", "paused", "finished")
|
||||
if !valid {
|
||||
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) {
|
||||
var rows []model.Meetup
|
||||
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
|
||||
fail(c, 500, "读取约遛失败")
|
||||
query := search(a.db.Model(&model.Meetup{}), c, "title", "place_name", "id::text")
|
||||
query, valid := filterStatus(c, query, "status", "open", "cancelled")
|
||||
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
|
||||
}
|
||||
items := make([]gin.H, 0, len(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) {
|
||||
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, "参数无效")
|
||||
return
|
||||
}
|
||||
r := a.db.Model(&model.User{}).Where("id = ?", c.Param("id")).Update("disabled", in.Disabled)
|
||||
if r.Error != nil || r.RowsAffected == 0 {
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
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, "用户不存在")
|
||||
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) {
|
||||
var in struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || (in.Status != "open" && in.Status != "cancelled") {
|
||||
fail(c, http.StatusBadRequest, "状态无效")
|
||||
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || in.Status != "cancelled" {
|
||||
fail(c, 400, "状态无效")
|
||||
return
|
||||
}
|
||||
r := a.db.Model(&model.Meetup{}).Where("id = ?", c.Param("id")).Update("status", in.Status)
|
||||
if r.Error != nil || r.RowsAffected == 0 {
|
||||
fail(c, 404, "约遛不存在")
|
||||
result := a.db.Model(&model.Meetup{}).Where("id = ? AND status = ?", c.Param("id"), "open").Update("status", in.Status)
|
||||
if result.Error != nil {
|
||||
fail(c, 500, "更新约遛失败")
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
fail(c, 409, "约遛不存在或已取消")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"status": in.Status})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -73,9 +73,13 @@ func (a *API) Register(r *gin.Engine) {
|
||||
admin.Use(a.requireRole("admin"))
|
||||
admin.GET("/stats", a.adminStats)
|
||||
admin.GET("/users", a.adminUsers)
|
||||
admin.GET("/users/:id/pets", a.adminUserPets)
|
||||
admin.GET("/pets", a.adminPets)
|
||||
admin.GET("/walks", a.adminWalks)
|
||||
admin.GET("/walks/:id", a.adminWalkDetail)
|
||||
admin.GET("/meetups", a.adminMeetups)
|
||||
admin.GET("/tasks", a.adminTasks)
|
||||
admin.GET("/files", a.adminFiles)
|
||||
admin.PUT("/users/:id/disabled", a.adminSetUserDisabled)
|
||||
admin.PUT("/meetups/:id/status", a.adminSetMeetupStatus)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<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" />
|
||||
<title>肉垫计划 · 管理端</title>
|
||||
<title>肉垫计划 · 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<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 |
@@ -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 |
+307
-262
@@ -1,64 +1,102 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Users,
|
||||
PawPrint,
|
||||
Route,
|
||||
CalendarDays,
|
||||
CheckCheck,
|
||||
Files,
|
||||
LayoutDashboard,
|
||||
LoaderCircle,
|
||||
LogOut,
|
||||
PawPrint,
|
||||
RefreshCw,
|
||||
Route,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api, clearToken, getToken, setToken } from "@/lib/api";
|
||||
|
||||
type Tab = "overview" | "users" | "pets" | "walks" | "meetups";
|
||||
const labels: Record<Tab, string> = {
|
||||
overview: "总览",
|
||||
users: "用户",
|
||||
pets: "宠物",
|
||||
walks: "遛狗记录",
|
||||
meetups: "约遛活动",
|
||||
};
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
api,
|
||||
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 = {
|
||||
overview: PawPrint,
|
||||
overview: LayoutDashboard,
|
||||
users: Users,
|
||||
pets: PawPrint,
|
||||
walks: Route,
|
||||
meetups: CalendarDays,
|
||||
tasks: CheckCheck,
|
||||
files: Files,
|
||||
};
|
||||
const fmt = (v: string) => (v ? new Date(v).toLocaleString("zh-CN") : "—");
|
||||
export default function App() {
|
||||
const [token, auth] = useState(getToken()),
|
||||
const [token, setAuth] = useState(getToken()),
|
||||
[username, setUsername] = useState("sundynix"),
|
||||
[password, setPassword] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("overview"),
|
||||
[items, setItems] = useState<any[]>([]),
|
||||
[stats, setStats] = useState<Record<string, number>>({});
|
||||
const [tab, setTab] = useState<Section | "overview">("overview"),
|
||||
[revision, refresh] = useState(0),
|
||||
[stats, setStats] = useState<Stats | null>(null);
|
||||
const [error, setError] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
async function load() {
|
||||
[busy, setBusy] = useState(false),
|
||||
[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);
|
||||
setError("");
|
||||
try {
|
||||
if (tab === "overview") setStats(await api.stats());
|
||||
else setItems(await api[tab]());
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (token) void load();
|
||||
}, [tab, token]);
|
||||
api
|
||||
.stats(controller.signal)
|
||||
.then((value) => {
|
||||
if (!controller.signal.aborted) setStats(value);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!controller.signal.aborted) setError(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setBusy(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [tab, token, revision]);
|
||||
async function login(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const r = await api.login(username, password);
|
||||
setToken(r.token);
|
||||
auth(r.token);
|
||||
const value = await api.login(username.trim(), password);
|
||||
setToken(value.token);
|
||||
setAuth(value.token);
|
||||
setPassword("");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
@@ -67,55 +105,117 @@ export default function App() {
|
||||
}
|
||||
function logout() {
|
||||
clearToken();
|
||||
auth("");
|
||||
setAuth("");
|
||||
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 {
|
||||
await api.setUserDisabled(row.id, !row.disabled);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
async function cancel(row: any) {
|
||||
if (!window.confirm(`取消「${row.title}」?`)) return;
|
||||
try {
|
||||
await api.setMeetupStatus(row.id, "cancelled");
|
||||
await load();
|
||||
if (confirm.section === "users")
|
||||
await api.setUserDisabled(confirm.row.id, !confirm.row.disabled);
|
||||
else await api.cancelMeetup(confirm.row.id);
|
||||
setNotice(
|
||||
confirm.section === "users"
|
||||
? confirm.row.disabled
|
||||
? "用户已启用"
|
||||
: "用户已停用,位置分享已关闭"
|
||||
: "活动已取消",
|
||||
);
|
||||
setConfirm(null);
|
||||
refresh((value) => value + 1);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
const actionTitle =
|
||||
confirm?.section === "users"
|
||||
? confirm.row.disabled
|
||||
? "启用用户"
|
||||
: "停用用户"
|
||||
: "取消约遛活动";
|
||||
if (!token)
|
||||
return (
|
||||
<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}>
|
||||
<div className="login-mark">🐾</div>
|
||||
<p className="eyebrow">肉垫计划 · 管理端</p>
|
||||
<h1>欢迎回来</h1>
|
||||
<p className="muted">照顾每一只小短腿的快乐日常。</p>
|
||||
<label>
|
||||
用户名
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
密码
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<Button disabled={busy} className="w-full">
|
||||
登录管理后台
|
||||
<span className="login-mark">
|
||||
<PawPrint size={26} />
|
||||
</span>
|
||||
<p className="eyebrow">肉垫计划 · 管理后台</p>
|
||||
<h2>欢迎回来</h2>
|
||||
<p className="muted">今天也一起,照顾好每一个小日常。</p>
|
||||
<label htmlFor="username">管理员账号</label>
|
||||
<Input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
maxLength={64}
|
||||
/>
|
||||
<label htmlFor="password">密码</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{error && (
|
||||
<p className="error-banner" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button disabled={busy} className="w-full login-submit">
|
||||
{busy && <LoaderCircle size={16} className="animate-spin" />}
|
||||
{busy ? "正在登录" : "进入管理后台"}
|
||||
</Button>
|
||||
<small className="login-footnote">
|
||||
肉垫计划 · 给陪伴一个温暖的地方
|
||||
</small>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
@@ -123,219 +223,164 @@ export default function App() {
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<span>🐾</span>
|
||||
<img src="/brand-logo.png" alt="" />
|
||||
<div>
|
||||
<strong>肉垫计划</strong>
|
||||
<small>ADMIN STUDIO</small>
|
||||
<small>PAWPRINT PLAN</small>
|
||||
</div>
|
||||
</div>
|
||||
<p className="side-caption">工作台</p>
|
||||
<nav>
|
||||
{(Object.keys(labels) as Tab[]).map((key) => {
|
||||
<p className="side-caption">陪伴工作台</p>
|
||||
<nav aria-label="管理导航">
|
||||
{(Object.keys(labels) as (Section | "overview")[]).map((key) => {
|
||||
const Icon = icons[key];
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
className={tab === key ? "nav-item active" : "nav-item"}
|
||||
onClick={() => setTab(key)}
|
||||
className={`nav-item ${tab === key ? "active" : ""}`}
|
||||
aria-current={tab === key ? "page" : undefined}
|
||||
title={labels[key]}
|
||||
onClick={() => navigate(key)}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{labels[key]}
|
||||
<Icon size={19} />
|
||||
<span>{labels[key]}</span>
|
||||
{tab === key && <span className="nav-dot" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="side-note">
|
||||
<PawPrint size={22} />
|
||||
<p>
|
||||
陪伴它们的每一天,
|
||||
<br />
|
||||
都值得记录。
|
||||
</p>
|
||||
</div>
|
||||
<div className="side-bottom">
|
||||
<span>让每次出门都值得记录</span>
|
||||
<button onClick={logout}>
|
||||
<LogOut size={16} /> 退出登录
|
||||
</button>
|
||||
<span className="admin-indicator">管理员</span>
|
||||
<Button variant="ghost" onClick={logout} title="退出登录">
|
||||
<LogOut size={16} />
|
||||
<span>退出登录</span>
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="content">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">PAWPRINT DASHBOARD</p>
|
||||
<p className="eyebrow">
|
||||
肉垫计划 / {tab === "overview" ? "今天的日常" : "数据管理"}
|
||||
</p>
|
||||
<h1>{labels[tab]}</h1>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => void load()} disabled={busy}>
|
||||
<RefreshCw size={16} /> 刷新
|
||||
</Button>
|
||||
<div className="topbar-actions">
|
||||
<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
|
||||
variant="ghost"
|
||||
className="mobile-logout"
|
||||
size="icon"
|
||||
onClick={logout}
|
||||
aria-label="退出登录"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{tab === "overview" ? (
|
||||
<>
|
||||
<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">
|
||||
<div className="table-head">
|
||||
<h2>{labels[tab]}</h2>
|
||||
<p>最近 {items.length} 条记录</p>
|
||||
</div>
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{tab === "users" ? (
|
||||
<>
|
||||
<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>
|
||||
{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" ? (
|
||||
<Overview stats={stats} navigate={navigate} />
|
||||
) : (
|
||||
<Records
|
||||
key={tab}
|
||||
section={tab}
|
||||
revision={revision}
|
||||
action={(section, row) => {
|
||||
setError("");
|
||||
setConfirm({ section, row });
|
||||
}}
|
||||
detail={(section, row) => setDetail({ section, row })}
|
||||
actionBusy={actionBusy}
|
||||
/>
|
||||
)}
|
||||
<footer className="page-footer">
|
||||
肉垫计划 · 把每一份陪伴,认真记下来
|
||||
</footer>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button }
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
@@ -16,7 +16,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
@@ -24,18 +24,39 @@ const DialogContent = React.forwardRef<
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 hover:opacity-100">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">关闭</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
))
|
||||
DialogContent.displayName = 'DialogContent'
|
||||
));
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('flex flex-col space-y-1.5', className)} {...props} />
|
||||
function DialogHeader({
|
||||
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>) {
|
||||
return <h2 className={cn('text-lg font-semibold', className)} {...props} />
|
||||
function DialogTitle({
|
||||
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,
|
||||
};
|
||||
|
||||
+953
-205
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,89 @@
|
||||
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";
|
||||
export const getToken = () => localStorage.getItem(key) || "";
|
||||
export const setToken = (value: string) => localStorage.setItem(key, value);
|
||||
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) => {
|
||||
const token = getToken();
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
@@ -16,20 +96,42 @@ http.interceptors.response.use(
|
||||
throw new Error(body?.message || "请求失败");
|
||||
},
|
||||
(error) => {
|
||||
if (error.response?.status === 401) clearToken();
|
||||
throw new Error(error.response?.data?.message || error.message);
|
||||
if (axios.isCancel(error)) return Promise.reject(error);
|
||||
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 = {
|
||||
login: (username: string, password: string) =>
|
||||
http.post<any, { token: string }>("/admin/login", { username, password }),
|
||||
stats: () => http.get<any, Record<string, number>>("/admin/stats"),
|
||||
users: () => http.get<any, any[]>("/admin/users"),
|
||||
pets: () => http.get<any, any[]>("/admin/pets"),
|
||||
walks: () => http.get<any, any[]>("/admin/walks"),
|
||||
meetups: () => http.get<any, any[]>("/admin/meetups"),
|
||||
http.post<unknown, { token: string }>("/admin/login", {
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
stats: (signal?: AbortSignal) =>
|
||||
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) =>
|
||||
http.put(`/admin/users/${id}/disabled`, { disabled }),
|
||||
setMeetupStatus: (id: string, status: string) =>
|
||||
http.put(`/admin/meetups/${id}/status`, { status }),
|
||||
cancelMeetup: (id: string) =>
|
||||
http.put(`/admin/meetups/${id}/status`, { status: "cancelled" }),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user