Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25c2596ce9 | ||
|
|
6c37785ce1 | ||
|
|
3655a6a89e | ||
|
|
7fc50f9212 | ||
|
|
c2b51f96e0 | ||
|
|
adfd0574cc | ||
|
|
46ac8f8a47 | ||
|
|
8a3036f0db | ||
|
|
87dfc4f335 | ||
|
|
c72cf24f56 |
@@ -17,3 +17,6 @@ WECHAT_APP_SECRET=
|
||||
ADMIN_USERNAME=sundynix
|
||||
ADMIN_PASSWORD=sundynix
|
||||
TAG=latest
|
||||
|
||||
# 高德 Web 服务 Key
|
||||
AMAP_KEY=
|
||||
|
||||
@@ -5,6 +5,7 @@ services:
|
||||
ports:
|
||||
- "4000:9090"
|
||||
environment:
|
||||
PETS_AMAP_KEY: ${AMAP_KEY:-}
|
||||
PETS_SERVER_PORT: "9090"
|
||||
PETS_SERVER_MODE: release
|
||||
PETS_POSTGRES_HOST: ${POSTGRES_HOST:-192.168.100.127}
|
||||
|
||||
@@ -31,3 +31,7 @@ auth:
|
||||
admin:
|
||||
username: sundynix
|
||||
password: sundynix
|
||||
|
||||
# 高德 Web 服务:首页所在城市与实况天气
|
||||
amap:
|
||||
key: ""
|
||||
|
||||
@@ -30,3 +30,7 @@ auth:
|
||||
admin:
|
||||
username: sundynix
|
||||
password: sundynix
|
||||
|
||||
# 高德 Web 服务:首页所在城市与实况天气
|
||||
amap:
|
||||
key: "c6e0887a6cd0aeaf8f1fd3dd5704b8c3"
|
||||
|
||||
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},
|
||||
|
||||
@@ -9,7 +9,12 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type AMapConfig struct {
|
||||
Key string `mapstructure:"key"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
AMap AMapConfig `mapstructure:"amap"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Postgres PostgresConfig `mapstructure:"postgres"`
|
||||
MinIO MinIOConfig `mapstructure:"minio"`
|
||||
@@ -67,6 +72,7 @@ type AdminConfig struct {
|
||||
|
||||
func Load() (*Config, error) {
|
||||
v := viper.New()
|
||||
v.SetDefault("amap.key", "")
|
||||
v.SetDefault("server.port", 9090)
|
||||
v.SetDefault("server.mode", "debug")
|
||||
v.SetDefault("postgres.host", "192.168.100.127")
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package environment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotConfigured = errors.New("位置和天气服务暂未配置")
|
||||
var ErrCoordinates = errors.New("定位坐标无效")
|
||||
|
||||
type Weather struct {
|
||||
Description string `json:"description"`
|
||||
Temperature string `json:"temperature"`
|
||||
ReportedAt string `json:"reported_at"`
|
||||
}
|
||||
type Result struct {
|
||||
LocationName string `json:"location_name"`
|
||||
City string `json:"city"`
|
||||
District string `json:"district"`
|
||||
Weather *Weather `json:"weather"`
|
||||
}
|
||||
type cached struct {
|
||||
result Result
|
||||
expires time.Time
|
||||
}
|
||||
type Client struct {
|
||||
key string
|
||||
baseURL string
|
||||
http *http.Client
|
||||
mu sync.Mutex
|
||||
cache map[string]cached
|
||||
}
|
||||
|
||||
func New(key string) *Client {
|
||||
return &Client{key: key, baseURL: "https://restapi.amap.com", http: &http.Client{Timeout: 5 * time.Second}, cache: make(map[string]cached)}
|
||||
}
|
||||
|
||||
// AMap returns [] instead of an empty string for some address components.
|
||||
func addressText(raw json.RawMessage) string {
|
||||
var text string
|
||||
_ = json.Unmarshal(raw, &text)
|
||||
return text
|
||||
}
|
||||
func (c *Client) get(ctx context.Context, path string, params url.Values, target any) error {
|
||||
params.Set("key", c.key)
|
||||
params.Set("output", "JSON")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path+"?"+params.Encode(), nil)
|
||||
if err != nil {
|
||||
return errors.New("位置服务请求失败")
|
||||
}
|
||||
res, err := c.http.Do(req)
|
||||
// Never return the transport error: it may contain the URL and API key.
|
||||
if err != nil {
|
||||
return errors.New("位置服务暂时不可用")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return errors.New("位置服务暂时不可用")
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(target); err != nil {
|
||||
return errors.New("位置服务响应无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *Client) Lookup(ctx context.Context, latitude, longitude float64) (Result, error) {
|
||||
if math.IsNaN(latitude) || math.IsNaN(longitude) || math.IsInf(latitude, 0) || math.IsInf(longitude, 0) || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180 {
|
||||
return Result{}, ErrCoordinates
|
||||
}
|
||||
if c.key == "" {
|
||||
return Result{}, ErrNotConfigured
|
||||
}
|
||||
cacheKey := fmt.Sprintf("%.3f,%.3f", longitude, latitude)
|
||||
c.mu.Lock()
|
||||
hit, found := c.cache[cacheKey]
|
||||
c.mu.Unlock()
|
||||
if found && time.Now().Before(hit.expires) {
|
||||
return hit.result, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
var geo struct {
|
||||
Status string `json:"status"`
|
||||
Regeocode struct {
|
||||
Address struct {
|
||||
Province json.RawMessage `json:"province"`
|
||||
City json.RawMessage `json:"city"`
|
||||
District json.RawMessage `json:"district"`
|
||||
Adcode string `json:"adcode"`
|
||||
} `json:"addressComponent"`
|
||||
} `json:"regeocode"`
|
||||
}
|
||||
err := c.get(ctx, "/v3/geocode/regeo", url.Values{"location": {fmt.Sprintf("%.6f,%.6f", longitude, latitude)}, "extensions": {"base"}}, &geo)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if geo.Status != "1" {
|
||||
return Result{}, errors.New("所在城市获取失败,请稍后重试")
|
||||
}
|
||||
address := geo.Regeocode.Address
|
||||
city := addressText(address.City)
|
||||
if city == "" {
|
||||
city = addressText(address.Province)
|
||||
}
|
||||
district := addressText(address.District)
|
||||
if city == "" || address.Adcode == "" {
|
||||
return Result{}, errors.New("当前位置暂不支持天气查询")
|
||||
}
|
||||
result := Result{City: city, District: district, LocationName: city}
|
||||
if district != "" && district != city {
|
||||
result.LocationName += " · " + district
|
||||
}
|
||||
var weather struct {
|
||||
Status string `json:"status"`
|
||||
Lives []struct {
|
||||
Description string `json:"weather"`
|
||||
Temperature string `json:"temperature"`
|
||||
ReportedAt string `json:"reporttime"`
|
||||
} `json:"lives"`
|
||||
}
|
||||
err = c.get(ctx, "/v3/weather/weatherInfo", url.Values{"city": {address.Adcode}, "extensions": {"base"}}, &weather)
|
||||
if err == nil && weather.Status == "1" && len(weather.Lives) > 0 {
|
||||
live := weather.Lives[0]
|
||||
if strings.TrimSpace(live.Description) != "" && live.Temperature != "" {
|
||||
result.Weather = &Weather{live.Description, live.Temperature, live.ReportedAt}
|
||||
}
|
||||
}
|
||||
// Keep the city even if the weather provider fails; retry missing weather next time.
|
||||
if result.Weather != nil {
|
||||
c.mu.Lock()
|
||||
if len(c.cache) >= 256 {
|
||||
clear(c.cache)
|
||||
}
|
||||
c.cache[cacheKey] = cached{result, time.Now().Add(15 * time.Minute)}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package environment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLookupMunicipalityAndCache(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.URL.Query().Get("key") != "test-key" {
|
||||
t.Error("key missing")
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/v3/geocode/regeo":
|
||||
if r.URL.Query().Get("location") != "121.473700,31.230400" {
|
||||
t.Errorf("wrong coordinate order: %s", r.URL.Query().Get("location"))
|
||||
}
|
||||
fmt.Fprint(w, `{"status":"1","regeocode":{"addressComponent":{"province":"上海市","city":[],"district":"黄浦区","adcode":"310101"}}}`)
|
||||
case "/v3/weather/weatherInfo":
|
||||
if r.URL.Query().Get("city") != "310101" {
|
||||
t.Error("weather must use adcode")
|
||||
}
|
||||
fmt.Fprint(w, `{"status":"1","lives":[{"weather":"晴","temperature":"24","reporttime":"2026-10-04 10:00:00"}]}`)
|
||||
default:
|
||||
t.Error("unexpected path")
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
client := New("test-key")
|
||||
client.baseURL = srv.URL
|
||||
for range 2 {
|
||||
result, err := client.Lookup(context.Background(), 31.2304, 121.4737)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.LocationName != "上海市 · 黄浦区" || result.Weather == nil || result.Weather.Temperature != "24" {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Errorf("cache did not reduce calls: %d", calls.Load())
|
||||
}
|
||||
}
|
||||
func TestPartialWeatherFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v3/geocode/regeo" {
|
||||
fmt.Fprint(w, `{"status":"1","regeocode":{"addressComponent":{"province":"浙江省","city":"杭州市","district":"西湖区","adcode":"330106"}}}`)
|
||||
} else {
|
||||
fmt.Fprint(w, `{"status":"0","info":"INVALID_USER_KEY"}`)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
client := New("test-key")
|
||||
client.baseURL = srv.URL
|
||||
result, err := client.Lookup(context.Background(), 30.2, 120.1)
|
||||
if err != nil || result.LocationName != "杭州市 · 西湖区" || result.Weather != nil {
|
||||
t.Fatalf("must retain city without fabricated weather: %+v %v", result, err)
|
||||
}
|
||||
}
|
||||
func TestInvalidInputsAndProviderError(t *testing.T) {
|
||||
client := New("")
|
||||
if _, err := client.Lookup(context.Background(), 30, 120); err != ErrNotConfigured {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, latitude := range []float64{91, math.NaN(), math.Inf(1)} {
|
||||
if _, err := client.Lookup(context.Background(), latitude, 120); err != ErrCoordinates {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"status":"0","info":"INVALID_USER_KEY"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
client = New("secret-key")
|
||||
client.baseURL = srv.URL
|
||||
if _, err := client.Lookup(context.Background(), 30, 120); err == nil {
|
||||
t.Fatal("provider rejection accepted")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sundynix/pets-be/internal/environment"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (a *API) environment(c *gin.Context) {
|
||||
lat, latErr := strconv.ParseFloat(c.Query("latitude"), 64)
|
||||
lon, lonErr := strconv.ParseFloat(c.Query("longitude"), 64)
|
||||
if latErr != nil || lonErr != nil {
|
||||
fail(c, http.StatusBadRequest, "请提供有效的定位坐标")
|
||||
return
|
||||
}
|
||||
result, err := a.env.Lookup(c.Request.Context(), lat, lon)
|
||||
if err != nil {
|
||||
status := http.StatusBadGateway
|
||||
if errors.Is(err, environment.ErrCoordinates) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
if errors.Is(err, environment.ErrNotConfigured) {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
fail(c, status, err.Error())
|
||||
return
|
||||
}
|
||||
ok(c, result)
|
||||
}
|
||||
@@ -12,18 +12,20 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/environment"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"github.com/sundynix/pets-be/internal/storage"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
env *environment.Client
|
||||
db *gorm.DB
|
||||
storage *storage.Storage
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, storage *storage.Storage, cfg *config.Config) *API {
|
||||
return &API{db: db, storage: storage, cfg: cfg}
|
||||
return &API{db: db, storage: storage, cfg: cfg, env: environment.New(cfg.AMap.Key)}
|
||||
}
|
||||
|
||||
func (a *API) Register(r *gin.Engine) {
|
||||
@@ -38,6 +40,7 @@ func (a *API) Register(r *gin.Engine) {
|
||||
|
||||
user := api.Group("")
|
||||
user.Use(a.requireRole("user"))
|
||||
user.GET("/environment", a.environment)
|
||||
user.GET("/me", a.me)
|
||||
user.GET("/pets", a.listPets)
|
||||
user.POST("/pets", a.createPet)
|
||||
@@ -70,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" }),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# 肉垫计划小程序
|
||||
|
||||
用微信开发者工具导入 `pets-fe`,AppID 见 `project.config.json`。接口基础地址在 `utils/config.js`,正式环境为 `https://pet.sundynix.cn/api`。请求封装会追加 `/pets`、`/walks` 等路径,不会再追加一层 `/api`。
|
||||
|
||||
## 核心流程
|
||||
|
||||
- 宠物:添加、编辑、上传头像、归档;档案使用文件 ID,服务端返回可显示的头像地址。
|
||||
- 日常任务:按日期查看完成情况,新增、编辑、删除任务,记录或取消当天完成状态。
|
||||
- 遛狗:可一次选择多只宠物,记录轨迹、暂停、继续、结束;历史页可查看路线和同行宠物。
|
||||
- 位置分享:开始遛狗后主动开启,分享给微信好友;结束遛狗自动关闭。定位更新发生在小程序打开期间,后台持续定位还需微信对应类目与接口权限。
|
||||
- 约遛:发起活动、选择地点和同行宠物、报名、查看报名状态、退出或取消活动。
|
||||
|
||||
## 真机检查
|
||||
|
||||
1. 登录后添加至少两只宠物,确认头像上传与档案编辑。
|
||||
2. 新建喝水、喂食任务,编辑时间并切换日期,验证完成状态。
|
||||
3. 同时选两只宠物开始遛狗,确认定位、暂停/继续、分享和结束;在历史页查看记录。
|
||||
4. 用另一个微信账号报名约遛,确认已报名和退出状态。
|
||||
|
||||
小程序源码约 600 KB,低于微信主包 2 MB 限制。真机调试前请确认小程序后台已配置 `pet.sundynix.cn` 为合法请求域名。
|
||||
+3
-14
@@ -7,21 +7,10 @@ App({
|
||||
if (this.globalData.token) return this.globalData.token;
|
||||
if (!this.loginPromise)
|
||||
this.loginPromise = (async () => {
|
||||
const { isSimulator } = require("./utils/config");
|
||||
const { request } = require("./utils/api");
|
||||
let data;
|
||||
try {
|
||||
const { request } = require("./utils/api");
|
||||
const { code } = await wx.login();
|
||||
data = await request("/auth/login", "POST", { code }, false);
|
||||
} catch (error) {
|
||||
if (!isSimulator) throw error;
|
||||
data = await request(
|
||||
"/auth/dev",
|
||||
"POST",
|
||||
{ key: "wechat-devtools", nickname: "本地铲屎官" },
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (!code) throw new Error("微信登录未取得凭证,请重试");
|
||||
const data = await request("/auth/login", "POST", { code }, false);
|
||||
this.globalData.token = data.token;
|
||||
this.globalData.user = data.user;
|
||||
wx.setStorageSync("pet_token", data.token);
|
||||
|
||||
+30
-4
@@ -1,8 +1,34 @@
|
||||
{
|
||||
"pages": ["pages/home/home", "pages/meetups/meetups", "pages/walk/walk", "pages/tasks/tasks", "pages/pets/pets", "pages/pet-edit/pet-edit", "pages/meetup-edit/meetup-edit", "pages/share/share"],
|
||||
"window": { "navigationStyle": "custom", "backgroundColor": "#f6f9f6", "backgroundTextStyle": "dark" },
|
||||
"permission": { "scope.userLocation": { "desc": "遛狗时记录轨迹,并在你主动开启分享后展示实时位置" } },
|
||||
"requiredPrivateInfos": ["getLocation", "chooseLocation"],
|
||||
"pages": [
|
||||
"pages/home/home",
|
||||
"pages/meetups/meetups",
|
||||
"pages/walk/walk",
|
||||
"pages/tasks/tasks",
|
||||
"pages/pets/pets",
|
||||
"pages/pet-edit/pet-edit",
|
||||
"pages/meetup-edit/meetup-edit",
|
||||
"pages/share/share",
|
||||
"pages/walk-records/walk-records"
|
||||
],
|
||||
"window": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#fff9f3",
|
||||
"backgroundTextStyle": "dark"
|
||||
},
|
||||
"usingComponents": {
|
||||
"page-layout": "/components/page-layout/index",
|
||||
"app-icon": "/components/app-icon/index"
|
||||
},
|
||||
"permission": {
|
||||
"scope.userLocation": {
|
||||
"desc": "显示当地天气、记录遛狗轨迹及主动分享位置"
|
||||
}
|
||||
},
|
||||
"requiredPrivateInfos": [
|
||||
"getLocation",
|
||||
"chooseLocation"
|
||||
],
|
||||
"sitemapLocation": "sitemap.json",
|
||||
"style": "v2",
|
||||
"lazyCodeLoading": "requiredComponents"
|
||||
|
||||
+34
-27
@@ -1,14 +1,24 @@
|
||||
page {
|
||||
background: #f6f9f6;
|
||||
color: #173e36;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #fff9f3;
|
||||
color: #604a40;
|
||||
font-family: ui-rounded, "Hiragino Maru Gothic ProN", "PingFang SC", sans-serif;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
}
|
||||
view,
|
||||
text,
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
textarea,
|
||||
picker {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
button {
|
||||
@@ -18,51 +28,45 @@ button::after {
|
||||
border: 0;
|
||||
}
|
||||
.screen {
|
||||
min-height: 100vh;
|
||||
padding: calc(env(safe-area-inset-top) + 36rpx) 32rpx 170rpx;
|
||||
min-height: 100%;
|
||||
padding: 16rpx 32rpx calc(170rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.eyebrow {
|
||||
color: #8fa39b;
|
||||
color: #886c5f;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
.title {
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
margin: 12rpx 0 26rpx;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
margin: 38rpx 0 20rpx;
|
||||
}
|
||||
.muted {
|
||||
color: #8b9f96;
|
||||
color: #886c5f;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 34rpx;
|
||||
box-shadow: 0 15rpx 45rpx rgba(30, 70, 55, 0.05);
|
||||
box-shadow: 0 15rpx 45rpx rgba(123, 84, 58, 0.06);
|
||||
padding: 28rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.primary {
|
||||
background: #194f42;
|
||||
background: #b95738;
|
||||
color: white;
|
||||
border-radius: 23rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
padding: 22rpx 30rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.secondary {
|
||||
background: #e7f0e7;
|
||||
color: #28674e;
|
||||
background: #fff0e2;
|
||||
color: #b95738;
|
||||
border-radius: 23rpx;
|
||||
font-size: 27rpx;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
padding: 20rpx;
|
||||
}
|
||||
.row {
|
||||
@@ -74,14 +78,14 @@ button::after {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 99rpx;
|
||||
background: #e9f3e8;
|
||||
color: #36805c;
|
||||
background: #fff0e2;
|
||||
color: #b95738;
|
||||
padding: 8rpx 18rpx;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #9daf9f;
|
||||
color: #886c5f;
|
||||
padding: 64rpx 24rpx;
|
||||
}
|
||||
.field {
|
||||
@@ -89,12 +93,11 @@ button::after {
|
||||
}
|
||||
.field-label {
|
||||
font-size: 24rpx;
|
||||
color: #718b7e;
|
||||
color: #886c5f;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field picker {
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
@@ -102,6 +105,10 @@ button::after {
|
||||
font-size: 28rpx;
|
||||
min-height: 76rpx;
|
||||
}
|
||||
.field picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.field textarea {
|
||||
height: 170rpx;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Component({
|
||||
properties: {
|
||||
kind: { type: String, value: "paw" },
|
||||
size: { type: Number, value: 48 },
|
||||
color: { type: String, value: "#b95738" },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "component": true }
|
||||
@@ -0,0 +1,13 @@
|
||||
<view class="app-icon-icon" style="width: {{size}}rpx; height: {{size}}rpx; color: {{color}}">
|
||||
<view wx:if="{{kind==='water'}}" class="app-icon-drop"></view>
|
||||
<view wx:elif="{{kind==='food'}}" class="app-icon-bowl"><view class="app-icon-bowl-rim"></view></view>
|
||||
<view wx:elif="{{kind==='pet'}}" class="app-icon-pet">
|
||||
<view class="app-icon-ear app-icon-ear-left"></view><view class="app-icon-ear app-icon-ear-right"></view>
|
||||
<view class="app-icon-face"><view class="app-icon-eye app-icon-eye-left"></view><view class="app-icon-eye app-icon-eye-right"></view><view class="app-icon-nose"></view></view>
|
||||
</view>
|
||||
<view wx:else class="app-icon-paw">
|
||||
<view class="app-icon-toe app-icon-one"></view><view class="app-icon-toe app-icon-two"></view>
|
||||
<view class="app-icon-toe app-icon-three"></view><view class="app-icon-toe app-icon-four"></view>
|
||||
<view class="app-icon-pad"></view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,21 @@
|
||||
.app-icon-icon { position: relative; display: flex; align-items: center; justify-content: center; }
|
||||
.app-icon-drop { width: 48%; height: 48%; border: 3rpx solid currentColor; border-radius: 50% 50% 50% 0; transform: rotate(135deg); }
|
||||
.app-icon-bowl { position: relative; width: 70%; height: 40%; border: 3rpx solid currentColor; border-radius: 5rpx 5rpx 50% 50%; }
|
||||
.app-icon-bowl-rim { position: absolute; left: -8%; top: -5rpx; width: 116%; height: 3rpx; border-radius: 4rpx; background: currentColor; }
|
||||
.app-icon-paw { position: relative; width: 85%; height: 85%; }
|
||||
.app-icon-toe, .app-icon-pad { position: absolute; background: currentColor; }
|
||||
.app-icon-toe { width: 17%; height: 24%; border-radius: 50%; }
|
||||
.app-icon-one { left: 0; top: 26%; transform: rotate(-28deg); }
|
||||
.app-icon-two { left: 24%; top: 3%; transform: rotate(-12deg); }
|
||||
.app-icon-three { right: 24%; top: 3%; transform: rotate(12deg); }
|
||||
.app-icon-four { right: 0; top: 26%; transform: rotate(28deg); }
|
||||
.app-icon-pad { left: 24%; top: 48%; width: 52%; height: 42%; border-radius: 50% 50% 45% 45%; }
|
||||
.app-icon-pet { position: relative; width: 85%; height: 85%; }
|
||||
.app-icon-ear { position: absolute; top: 22%; width: 27%; height: 52%; border: 3rpx solid currentColor; border-radius: 55% 40% 70% 70%; }
|
||||
.app-icon-ear-left { left: -3%; transform: rotate(25deg); }
|
||||
.app-icon-ear-right { right: -3%; transform: rotate(-25deg); }
|
||||
.app-icon-face { position: absolute; bottom: 0; left: 12%; width: 76%; height: 68%; border: 3rpx solid currentColor; border-radius: 42% 42% 48% 48%; background: #fff; }
|
||||
.app-icon-eye { position: absolute; top: 29%; width: 9%; height: 14%; border-radius: 50%; background: currentColor; }
|
||||
.app-icon-eye-left { left: 23%; }
|
||||
.app-icon-eye-right { right: 23%; }
|
||||
.app-icon-nose { position: absolute; left: 40%; bottom: 22%; width: 20%; height: 14%; border-radius: 40% 40% 60% 60%; background: currentColor; }
|
||||
@@ -0,0 +1,38 @@
|
||||
const { navigationGeometry } = require("../../utils/layout");
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
title: String,
|
||||
subtitle: String,
|
||||
location: { type: Boolean, value: false },
|
||||
back: { type: Boolean, value: false },
|
||||
},
|
||||
data: navigationGeometry(),
|
||||
lifetimes: {
|
||||
attached() {
|
||||
this.updateLayout();
|
||||
},
|
||||
},
|
||||
pageLifetimes: {
|
||||
resize() {
|
||||
this.updateLayout();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
locate() {
|
||||
this.triggerEvent("locationtap");
|
||||
},
|
||||
updateLayout() {
|
||||
const info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync();
|
||||
let menu = {};
|
||||
try {
|
||||
menu = wx.getMenuButtonBoundingClientRect();
|
||||
} catch (_) {}
|
||||
this.setData(navigationGeometry(info, menu));
|
||||
},
|
||||
goBack() {
|
||||
if (getCurrentPages().length > 1) wx.navigateBack({ delta: 1 });
|
||||
else wx.reLaunch({ url: "/pages/home/home" });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "component": true }
|
||||
@@ -0,0 +1,15 @@
|
||||
<view class="navigation" style="height: {{headerHeight}}px">
|
||||
<view class="navigation-row" style="top: {{top}}px; height: {{rowHeight}}px; width: {{titleWidth}}px">
|
||||
<view wx:if="{{location}}" class="local-header" bindtap="locate" aria-role="button" aria-label="更新当前位置和天气">
|
||||
<view class="local-title"><view class="location-pin"></view><text>{{title}}</text><view class="location-chevron"></view></view>
|
||||
<view class="local-meta"><text>{{subtitle}}</text></view>
|
||||
</view>
|
||||
<view wx:if="{{back}}" class="back" bindtap="goBack" aria-role="button" aria-label="返回">
|
||||
<view class="back-arrow"></view>
|
||||
</view>
|
||||
<text wx:if="{{!location}}" class="navigation-title">{{title}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view class="content" style="top: {{headerHeight}}px; height: {{scrollHeight}}px" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
|
||||
<slot />
|
||||
</scroll-view>
|
||||
@@ -0,0 +1,108 @@
|
||||
.navigation {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 25;
|
||||
background: #fff9f3;
|
||||
}
|
||||
.navigation-row {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.navigation-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #604a40;
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.local-header {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.local-title {
|
||||
height: 19px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #604a40;
|
||||
font-size: 16px;
|
||||
line-height: 19px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.local-title text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.location-pin {
|
||||
position: relative;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
flex-shrink: 0;
|
||||
border: 1.5px solid #b95738;
|
||||
border-radius: 50% 50% 50% 0;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
.location-pin::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
left: 3.5px;
|
||||
top: 3.5px;
|
||||
border-radius: 50%;
|
||||
background: #b95738;
|
||||
}
|
||||
.location-chevron {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #886c5f;
|
||||
border-bottom: 1px solid #886c5f;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.local-meta {
|
||||
margin-top: 2px;
|
||||
color: #886c5f;
|
||||
font-size: 10px;
|
||||
line-height: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.back {
|
||||
width: 28px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.back-arrow {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-left: 2px solid #604a40;
|
||||
border-bottom: 2px solid #604a40;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.content {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ Component({
|
||||
properties: {
|
||||
pets: { type: Array, value: [] },
|
||||
selected: { type: Array, value: [] },
|
||||
disabled: { type: Boolean, value: false },
|
||||
},
|
||||
data: { viewPets: [] },
|
||||
observers: {
|
||||
@@ -16,11 +17,18 @@ Component({
|
||||
},
|
||||
methods: {
|
||||
toggle(e) {
|
||||
if (this.data.disabled) return;
|
||||
const id = e.currentTarget.dataset.id;
|
||||
const selected = this.data.selected.includes(id)
|
||||
? this.data.selected.filter((x) => x !== id)
|
||||
: [...this.data.selected, id];
|
||||
this.triggerEvent("change", { selected });
|
||||
},
|
||||
selectAll() {
|
||||
if (this.data.disabled) return;
|
||||
const ids = this.data.pets.map((pet) => pet.id);
|
||||
const allSelected = ids.every((id) => this.data.selected.includes(id));
|
||||
this.triggerEvent("change", { selected: allSelected ? [] : ids });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
{ "component": true }
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {
|
||||
"app-icon": "/components/app-icon/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<view class="picker">
|
||||
<view wx:if="{{pets.length>1&&!disabled}}" class="chip select-all" bindtap="selectAll">
|
||||
<text>✓</text><text>全选 / 清空</text>
|
||||
</view>
|
||||
<view
|
||||
wx:for="{{viewPets}}"
|
||||
wx:key="id"
|
||||
@@ -7,7 +10,7 @@
|
||||
bindtap="toggle"
|
||||
>
|
||||
<image wx:if="{{item.avatar_url}}" src="{{item.avatar_url}}" />
|
||||
<text wx:else>🐕</text>
|
||||
<app-icon wx:else size="32" />
|
||||
<text>{{item.name}}</text>
|
||||
<text class="check">{{item.chosen?'✓':'+'}}</text>
|
||||
</view>
|
||||
|
||||
@@ -8,16 +8,16 @@
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
background: white;
|
||||
border: 2rpx solid #e0eae2;
|
||||
color: #527164;
|
||||
border: 2rpx solid #ece2d8;
|
||||
color: #886c5f;
|
||||
padding: 11rpx 18rpx;
|
||||
border-radius: 99rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.chip.chosen {
|
||||
background: #dff0e1;
|
||||
border-color: #55a273;
|
||||
color: #145441;
|
||||
background: #fff0e2;
|
||||
border-color: #b95738;
|
||||
color: #b95738;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chip image {
|
||||
@@ -29,3 +29,7 @@
|
||||
margin-left: 3rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
.select-all {
|
||||
border-style: dashed;
|
||||
color: #b95738;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const tabs = [
|
||||
{ path: "/pages/home/home", label: "首页", icon: "⌂" },
|
||||
{ path: "/pages/meetups/meetups", label: "约遛", icon: "♡" },
|
||||
{ path: "/pages/walk/walk", label: "遛狗", icon: "🐾" },
|
||||
{ path: "/pages/tasks/tasks", label: "任务", icon: "☑" },
|
||||
{ path: "/pages/pets/pets", label: "宠物", icon: "♙" },
|
||||
{ path: "/pages/home/home", label: "首页", key: "home" },
|
||||
{ path: "/pages/meetups/meetups", label: "约遛", key: "meetups" },
|
||||
{ path: "/pages/walk/walk", label: "遛狗", key: "walk" },
|
||||
{ path: "/pages/tasks/tasks", label: "任务", key: "tasks" },
|
||||
{ path: "/pages/pets/pets", label: "宠物", key: "pets" },
|
||||
];
|
||||
Component({
|
||||
properties: { current: String },
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "component": true }
|
||||
{ "component": true, "usingComponents": { "app-icon": "/components/app-icon/index" } }
|
||||
|
||||
@@ -2,11 +2,35 @@
|
||||
<view
|
||||
wx:for="{{tabs}}"
|
||||
wx:key="path"
|
||||
class="item {{current===item.path?'active':''}} {{item.label==='遛狗'?'walk':''}}"
|
||||
class="item {{current===item.path?'active':''}} {{item.key==='walk'?'walk':''}}"
|
||||
data-path="{{item.path}}"
|
||||
bindtap="go"
|
||||
aria-role="button"
|
||||
aria-label="{{item.label}}"
|
||||
>
|
||||
<view class="icon">{{item.icon}}</view>
|
||||
<text>{{item.label}}</text>
|
||||
<view wx:if="{{item.key==='walk'}}" class="walk-bubble">
|
||||
<view class="paw">
|
||||
<view class="toe toe-one"></view>
|
||||
<view class="toe toe-two"></view>
|
||||
<view class="toe toe-three"></view>
|
||||
<view class="toe toe-four"></view>
|
||||
<view class="pad"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:else class="icon-wrap">
|
||||
<view wx:if="{{item.key==='home'}}" class="home-icon">
|
||||
<view class="home-roof"></view>
|
||||
<view class="home-body"></view>
|
||||
<view class="home-door"></view>
|
||||
</view>
|
||||
<view wx:elif="{{item.key==='meetups'}}" class="friends-icon">
|
||||
<view class="friend friend-left"></view>
|
||||
<view class="friend friend-right"></view>
|
||||
</view>
|
||||
<view wx:elif="{{item.key==='tasks'}}" class="task-icon"><view class="task-tick"></view></view>
|
||||
<view wx:else class="pet-icon"><app-icon kind="pet" size="44" color="{{current===item.path?'#b95738':'#9d8779'}}" /></view>
|
||||
</view>
|
||||
<text class="label">{{item.label}}</text>
|
||||
<view wx:if="{{current===item.path && item.key!=='walk'}}" class="active-dot"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,47 +1,190 @@
|
||||
.bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(124rpx + env(safe-area-inset-bottom));
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
background: #fff;
|
||||
left: 24rpx;
|
||||
right: 24rpx;
|
||||
bottom: calc(env(safe-area-inset-bottom) + 20rpx);
|
||||
height: 112rpx;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
box-shadow: 0 -8rpx 35rpx rgba(32, 68, 50, 0.06);
|
||||
align-items: stretch;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border: 2rpx solid #ece2d8;
|
||||
border-radius: 42rpx;
|
||||
box-shadow: 0 12rpx 42rpx rgba(123, 84, 58, 0.12);
|
||||
z-index: 20;
|
||||
}
|
||||
.item {
|
||||
position: relative;
|
||||
width: 20%;
|
||||
min-height: 112rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
color: #9eafa4;
|
||||
font-size: 21rpx;
|
||||
font-weight: 600;
|
||||
justify-content: center;
|
||||
color: #9d8779;
|
||||
}
|
||||
.item.active {
|
||||
color: #174d40;
|
||||
color: #b95738;
|
||||
}
|
||||
.icon {
|
||||
font-size: 46rpx;
|
||||
height: 52rpx;
|
||||
line-height: 52rpx;
|
||||
.icon-wrap {
|
||||
width: 58rpx;
|
||||
height: 58rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 22rpx;
|
||||
}
|
||||
.walk .icon {
|
||||
width: 82rpx;
|
||||
height: 82rpx;
|
||||
line-height: 82rpx;
|
||||
text-align: center;
|
||||
background: #174d40;
|
||||
color: #fff;
|
||||
border-radius: 27rpx;
|
||||
box-shadow: 0 10rpx 23rpx rgba(24, 74, 59, 0.23);
|
||||
margin-top: -30rpx;
|
||||
font-size: 39rpx;
|
||||
.active .icon-wrap {
|
||||
background: #fff0e2;
|
||||
transform: translateY(-4rpx);
|
||||
}
|
||||
.walk.active .icon {
|
||||
background: #eaaa69;
|
||||
.label {
|
||||
font-size: 21rpx;
|
||||
line-height: 27rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
.active-dot {
|
||||
position: absolute;
|
||||
bottom: 4rpx;
|
||||
width: 7rpx;
|
||||
height: 7rpx;
|
||||
border-radius: 50%;
|
||||
background: #df936a;
|
||||
}
|
||||
.home-icon {
|
||||
position: relative;
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
}
|
||||
.home-roof {
|
||||
position: absolute;
|
||||
top: 3rpx;
|
||||
left: 10rpx;
|
||||
width: 25rpx;
|
||||
height: 25rpx;
|
||||
border-left: 4rpx solid currentColor;
|
||||
border-top: 4rpx solid currentColor;
|
||||
border-radius: 3rpx;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.home-body {
|
||||
position: absolute;
|
||||
left: 6rpx;
|
||||
right: 6rpx;
|
||||
bottom: 1rpx;
|
||||
height: 24rpx;
|
||||
border-left: 4rpx solid currentColor;
|
||||
border-right: 4rpx solid currentColor;
|
||||
border-bottom: 4rpx solid currentColor;
|
||||
border-radius: 0 0 5rpx 5rpx;
|
||||
}
|
||||
.home-door {
|
||||
position: absolute;
|
||||
left: 18rpx;
|
||||
bottom: 2rpx;
|
||||
width: 10rpx;
|
||||
height: 15rpx;
|
||||
border-left: 3rpx solid currentColor;
|
||||
border-right: 3rpx solid currentColor;
|
||||
border-top: 3rpx solid currentColor;
|
||||
border-radius: 5rpx 5rpx 0 0;
|
||||
}
|
||||
.friends-icon {
|
||||
position: relative;
|
||||
width: 48rpx;
|
||||
height: 45rpx;
|
||||
}
|
||||
.friend {
|
||||
position: absolute;
|
||||
bottom: 2rpx;
|
||||
width: 25rpx;
|
||||
height: 16rpx;
|
||||
border: 4rpx solid currentColor;
|
||||
border-radius: 18rpx 18rpx 5rpx 5rpx;
|
||||
}
|
||||
.friend::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -25rpx;
|
||||
left: 3rpx;
|
||||
width: 13rpx;
|
||||
height: 13rpx;
|
||||
border: 4rpx solid currentColor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.friend-left { left: 1rpx; }
|
||||
.friend-right { right: 1rpx; background: #fff; }
|
||||
.active .friend-right { background: #fff0e2; }
|
||||
.task-icon {
|
||||
position: relative;
|
||||
width: 35rpx;
|
||||
height: 35rpx;
|
||||
border: 4rpx solid currentColor;
|
||||
border-radius: 9rpx;
|
||||
}
|
||||
.task-tick {
|
||||
position: absolute;
|
||||
left: 8rpx;
|
||||
top: 6rpx;
|
||||
width: 13rpx;
|
||||
height: 18rpx;
|
||||
border-right: 4rpx solid currentColor;
|
||||
border-bottom: 4rpx solid currentColor;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.pet-icon {
|
||||
width: 46rpx;
|
||||
height: 46rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.active .pet-icon { opacity: 1; }
|
||||
.walk-bubble {
|
||||
position: absolute;
|
||||
top: -43rpx;
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 8rpx solid #fff9f3;
|
||||
border-radius: 38rpx;
|
||||
background: linear-gradient(145deg, #f8bd8e, #e38e69);
|
||||
box-shadow: 0 10rpx 24rpx rgba(178, 105, 72, 0.26);
|
||||
transform: rotate(-8deg);
|
||||
}
|
||||
.walk.active .walk-bubble {
|
||||
background: linear-gradient(145deg, #df8b61, #b95738);
|
||||
box-shadow: 0 10rpx 24rpx rgba(185, 87, 56, 0.22);
|
||||
}
|
||||
.walk .label {
|
||||
position: absolute;
|
||||
bottom: 13rpx;
|
||||
color: #aa7356;
|
||||
}
|
||||
.walk.active .label { color: #b95738; }
|
||||
.paw {
|
||||
position: relative;
|
||||
width: 62rpx;
|
||||
height: 62rpx;
|
||||
transform: rotate(8deg);
|
||||
}
|
||||
.toe,
|
||||
.pad {
|
||||
position: absolute;
|
||||
background: #fff;
|
||||
}
|
||||
.toe {
|
||||
width: 11rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.toe-one { left: 3rpx; top: 19rpx; transform: rotate(-28deg); }
|
||||
.toe-two { left: 17rpx; top: 5rpx; transform: rotate(-12deg); }
|
||||
.toe-three { right: 17rpx; top: 5rpx; transform: rotate(12deg); }
|
||||
.toe-four { right: 3rpx; top: 19rpx; transform: rotate(28deg); }
|
||||
.pad {
|
||||
left: 15rpx;
|
||||
top: 30rpx;
|
||||
width: 32rpx;
|
||||
height: 26rpx;
|
||||
border-radius: 50% 50% 45% 45%;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
const { secure, toast } = require("../../utils/api");
|
||||
const { localDate } = require("../../utils/date");
|
||||
function dateLabel() {
|
||||
const now = new Date();
|
||||
return `${now.getMonth() + 1}月${now.getDate()}日 周${"日一二三四五六"[now.getDay()]}`;
|
||||
}
|
||||
Page({
|
||||
data: {
|
||||
locationName: "当前位置",
|
||||
headerSubtitle: dateLabel() + " · 轻触获取天气",
|
||||
pets: [],
|
||||
selected: [],
|
||||
tasks: [],
|
||||
@@ -10,6 +17,49 @@ Page({
|
||||
},
|
||||
onShow() {
|
||||
this.load();
|
||||
this.updateEnvironmentLabel();
|
||||
if (!this.environmentAt || Date.now() - this.environmentAt > 15 * 60 * 1000) {
|
||||
this.refreshEnvironment();
|
||||
}
|
||||
},
|
||||
updateEnvironmentLabel() {
|
||||
this.setData({ headerSubtitle: dateLabel() + " · " + (this.weatherLabel || "轻触获取天气") });
|
||||
},
|
||||
async refreshEnvironment(event) {
|
||||
if (this.environmentBusy) return;
|
||||
this.environmentBusy = true;
|
||||
const tapped = !!event;
|
||||
let positioned = false;
|
||||
try {
|
||||
const settings = await new Promise((resolve, reject) => wx.getSetting({ success: resolve, fail: reject }));
|
||||
if (settings.authSetting["scope.userLocation"] === false) {
|
||||
if (!tapped) throw new Error("location-denied");
|
||||
const enabled = await new Promise((resolve, reject) => wx.openSetting({ success: resolve, fail: reject }));
|
||||
if (!enabled.authSetting["scope.userLocation"]) throw new Error("location-denied");
|
||||
}
|
||||
this.weatherLabel = "正在获取天气";
|
||||
this.updateEnvironmentLabel();
|
||||
const point = await new Promise((resolve, reject) => wx.getLocation({
|
||||
type: "gcj02",
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
}));
|
||||
positioned = true;
|
||||
const result = await secure("/environment", "GET", { latitude: point.latitude, longitude: point.longitude });
|
||||
this.setData({ locationName: result.location_name });
|
||||
this.weatherLabel = result.weather
|
||||
? `${result.weather.description} ${result.weather.temperature}°C`
|
||||
: "天气暂不可用";
|
||||
this.environmentAt = result.weather ? Date.now() : 0;
|
||||
} catch (error) {
|
||||
const denied = /denied|deny|auth/.test(error.errMsg || error.message || "");
|
||||
this.setData({ locationName: positioned ? "位置暂不可用" : denied ? "开启定位" : "定位暂不可用" });
|
||||
this.weatherLabel = denied ? "轻触开启定位" : "轻触重试";
|
||||
if (tapped && !denied) wx.showToast({ title: positioned ? "位置和天气获取失败,请重试" : "定位失败,请重试", icon: "none" });
|
||||
} finally {
|
||||
this.environmentBusy = false;
|
||||
this.updateEnvironmentLabel();
|
||||
}
|
||||
},
|
||||
async load() {
|
||||
try {
|
||||
@@ -20,7 +70,7 @@ Page({
|
||||
secure("/me"),
|
||||
]);
|
||||
const chosen = wx.getStorageSync("walk_pet_ids") || [],
|
||||
month = new Date().toISOString().slice(0, 7);
|
||||
month = localDate().slice(0, 7);
|
||||
this.setData({
|
||||
pets,
|
||||
selected: chosen.filter((id) => pets.some((p) => p.id === id)),
|
||||
@@ -50,14 +100,14 @@ Page({
|
||||
wx.redirectTo({ url: "/pages/tasks/tasks" });
|
||||
},
|
||||
goWalks() {
|
||||
wx.redirectTo({ url: "/pages/walk/walk" });
|
||||
wx.navigateTo({ url: "/pages/walk-records/walk-records" });
|
||||
},
|
||||
async complete(e) {
|
||||
const id = e.currentTarget.dataset.id,
|
||||
task = this.data.tasks.find((t) => t.id === id);
|
||||
try {
|
||||
await secure("/tasks/" + id + "/completion", "PUT", {
|
||||
date: new Date().toLocaleDateString("sv-SE"),
|
||||
date: localDate(),
|
||||
completed: !task.completed,
|
||||
});
|
||||
await this.load();
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
{ "usingComponents": { "tab-bar": "/components/tab-bar/index", "pet-picker": "/components/pet-picker/index" } }
|
||||
{
|
||||
"disableScroll": true,
|
||||
"usingComponents": {
|
||||
"tab-bar": "/components/tab-bar/index",
|
||||
"pet-picker": "/components/pet-picker/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<page-layout id="page-layout" title="{{locationName}}" subtitle="{{headerSubtitle}}" location="{{true}}" bind:locationtap="refreshEnvironment">
|
||||
<view class="screen">
|
||||
<view class="eyebrow">你好,今天也一起好好生活</view>
|
||||
<view class="row top">
|
||||
<view class="title">早安,{{name}} ☀️</view>
|
||||
<image src="/assets/logo-corgi-v2.png" class="avatar" />
|
||||
</view>
|
||||
<view class="hero">
|
||||
<view class="hero-copy">
|
||||
<text class="hero-small">今日的主角</text>
|
||||
<text class="hero-title">小短腿,出发吧!</text>
|
||||
<text class="hero-meta">多只狗狗 · 每一天都是好日子</text>
|
||||
<text class="hero-title">出门走走吧</text>
|
||||
<text class="hero-meta">一起出发,收集今天的小快乐</text>
|
||||
<view class="hero-caption">● 陪伴它们的每一天,都值得记录</view>
|
||||
</view>
|
||||
<image src="/assets/corgi-family-five.png" mode="aspectFit" />
|
||||
@@ -19,7 +15,7 @@
|
||||
</view>
|
||||
<view class="card walk-card">
|
||||
<view class="walk-intro">
|
||||
<view class="dog-emoji">🐕</view>
|
||||
<view class="walk-art"><app-icon size="74" /></view>
|
||||
<view>
|
||||
<view class="walk-title">今天的风刚刚好</view>
|
||||
<view class="muted">选择同行的狗狗,一起出门转转。</view>
|
||||
@@ -68,7 +64,7 @@
|
||||
{{item.completed?'✓':''}}
|
||||
</view>
|
||||
<view class="task-icon">
|
||||
{{item.category==='water'?'💧':item.category==='food'?'🥣':'🐾'}}
|
||||
<app-icon kind="{{item.category}}" size="36" />
|
||||
</view>
|
||||
<view class="task-main">
|
||||
<text class="task-name">{{item.title}}</text>
|
||||
@@ -76,8 +72,9 @@
|
||||
</view>
|
||||
</view>
|
||||
<view wx:if="{{!tasks.length}}" class="empty">
|
||||
今天还没有任务,给狗狗安排一件小事吧 🐾
|
||||
今天还没有任务,给狗狗安排一件小事吧
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</page-layout>
|
||||
<tab-bar current="/pages/home/home" />
|
||||
|
||||
@@ -1,64 +1,50 @@
|
||||
.top {
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
.avatar {
|
||||
width: 70rpx;
|
||||
height: 70rpx;
|
||||
border-radius: 50%;
|
||||
border: 6rpx solid white;
|
||||
box-shadow: 0 5rpx 22rpx #174b3520;
|
||||
}
|
||||
.hero {
|
||||
height: 330rpx;
|
||||
background: #194f42;
|
||||
min-height: 330rpx;
|
||||
background: linear-gradient(135deg, #fff0dd, #ffdec7);
|
||||
border-radius: 38rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 35rpx;
|
||||
color: white;
|
||||
color: #694c3f;
|
||||
}
|
||||
.hero-copy {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 62%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.hero-small {
|
||||
color: #abd9b8;
|
||||
color: #7c5b46;
|
||||
font-size: 23rpx;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hero-title {
|
||||
font-size: 43rpx;
|
||||
font-weight: 800;
|
||||
font-size: 38rpx;
|
||||
font-weight: 600;
|
||||
margin: 16rpx 0;
|
||||
}
|
||||
.hero-meta {
|
||||
font-size: 23rpx;
|
||||
color: #d9eadd;
|
||||
color: #7c5b46;
|
||||
}
|
||||
.hero-caption {
|
||||
font-size: 20rpx;
|
||||
color: #b5e2be;
|
||||
color: #7c5b46;
|
||||
margin-top: 36rpx;
|
||||
}
|
||||
.hero image {
|
||||
position: absolute;
|
||||
width: 310rpx;
|
||||
height: 290rpx;
|
||||
right: -24rpx;
|
||||
bottom: 8rpx;
|
||||
width: 48%;
|
||||
height: 220rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.heading {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.link {
|
||||
font-size: 24rpx;
|
||||
color: #398668;
|
||||
font-weight: 700;
|
||||
color: #b95738;
|
||||
font-weight: 600;
|
||||
}
|
||||
.walk-card {
|
||||
padding: 26rpx;
|
||||
@@ -68,18 +54,18 @@
|
||||
gap: 22rpx;
|
||||
align-items: center;
|
||||
}
|
||||
.dog-emoji {
|
||||
.walk-art {
|
||||
width: 145rpx;
|
||||
height: 145rpx;
|
||||
border-radius: 26rpx;
|
||||
background: #dfeedd;
|
||||
background: #fff0e2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 72rpx;
|
||||
}
|
||||
.walk-title {
|
||||
font-size: 31rpx;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.walk-intro .muted {
|
||||
@@ -87,7 +73,7 @@
|
||||
}
|
||||
.picker-label {
|
||||
font-size: 23rpx;
|
||||
color: #7d9989;
|
||||
color: #886c5f;
|
||||
margin: 27rpx 0 16rpx;
|
||||
}
|
||||
.start {
|
||||
@@ -109,11 +95,11 @@
|
||||
background: #f7eadc;
|
||||
}
|
||||
.stats > view:nth-child(3) {
|
||||
background: #e8efea;
|
||||
background: #edf2fb;
|
||||
}
|
||||
.stats text {
|
||||
display: block;
|
||||
color: #8da49a;
|
||||
color: #886c5f;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.stats strong {
|
||||
@@ -123,7 +109,7 @@
|
||||
}
|
||||
.stats small {
|
||||
font-size: 22rpx;
|
||||
color: #94a69d;
|
||||
color: #886c5f;
|
||||
}
|
||||
.tasks {
|
||||
padding: 12rpx 25rpx;
|
||||
@@ -133,7 +119,7 @@
|
||||
align-items: center;
|
||||
gap: 17rpx;
|
||||
padding: 22rpx 0;
|
||||
border-bottom: 1rpx solid #edf1ed;
|
||||
border-bottom: 1rpx solid #ece2d8;
|
||||
}
|
||||
.task:last-child {
|
||||
border: 0;
|
||||
@@ -141,21 +127,21 @@
|
||||
.check {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border: 3rpx solid #cfdfd2;
|
||||
border: 3rpx solid #ece2d8;
|
||||
border-radius: 13rpx;
|
||||
text-align: center;
|
||||
line-height: 37rpx;
|
||||
color: white;
|
||||
}
|
||||
.check.done {
|
||||
background: #3b9164;
|
||||
border-color: #3b9164;
|
||||
background: #b95738;
|
||||
border-color: #b95738;
|
||||
}
|
||||
.task-icon {
|
||||
width: 65rpx;
|
||||
height: 65rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #f0f5ee;
|
||||
background: #fff0e2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 34rpx;
|
||||
@@ -166,7 +152,7 @@
|
||||
gap: 6rpx;
|
||||
}
|
||||
.task-name {
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
}
|
||||
.task-main .muted {
|
||||
font-size: 22rpx;
|
||||
|
||||
@@ -11,6 +11,7 @@ Page({
|
||||
date: "",
|
||||
time: "",
|
||||
max_people: 8,
|
||||
saving: false,
|
||||
},
|
||||
onLoad() {
|
||||
this.load();
|
||||
@@ -47,6 +48,7 @@ Page({
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
if (this.data.saving) return;
|
||||
const d = this.data;
|
||||
if (
|
||||
!d.title.trim() ||
|
||||
@@ -58,6 +60,16 @@ Page({
|
||||
wx.showToast({ title: "请填完时间地点并选择狗狗", icon: "none" });
|
||||
return;
|
||||
}
|
||||
const startsAt = new Date(d.date + "T" + d.time + ":00+08:00");
|
||||
if (startsAt <= new Date()) {
|
||||
wx.showToast({ title: "请选择未来的时间", icon: "none" });
|
||||
return;
|
||||
}
|
||||
if (Number(d.max_people) < 2 || Number(d.max_people) > 100) {
|
||||
wx.showToast({ title: "人数应为 2 到 100", icon: "none" });
|
||||
return;
|
||||
}
|
||||
this.setData({ saving: true });
|
||||
try {
|
||||
await secure("/meetups", "POST", {
|
||||
title: d.title,
|
||||
@@ -65,13 +77,15 @@ Page({
|
||||
place_name: d.place_name,
|
||||
latitude: d.latitude,
|
||||
longitude: d.longitude,
|
||||
starts_at: new Date(d.date + "T" + d.time + ":00+08:00").toISOString(),
|
||||
starts_at: startsAt.toISOString(),
|
||||
max_people: Number(d.max_people),
|
||||
pet_ids: d.selected,
|
||||
});
|
||||
wx.navigateBack();
|
||||
} catch (e) {
|
||||
toast(e);
|
||||
} finally {
|
||||
this.setData({ saving: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
{ "usingComponents": { "pet-picker": "/components/pet-picker/index" } }
|
||||
{
|
||||
"disableScroll": true,
|
||||
"usingComponents": {
|
||||
"pet-picker": "/components/pet-picker/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<page-layout id="page-layout" title="发起约遛" back="{{true}}">
|
||||
<view class="screen">
|
||||
<view class="eyebrow">HOST A WALK</view>
|
||||
<view class="title">发起约遛 🌿</view>
|
||||
<view class="field">
|
||||
<view class="field-label">活动名称</view>
|
||||
<input
|
||||
@@ -24,7 +23,7 @@
|
||||
<view class="field">
|
||||
<view class="field-label">集合地点</view>
|
||||
<view class="picker" bindtap="location">
|
||||
📍 {{place_name||'选择地图位置'}}
|
||||
{{place_name||'选择地图位置'}}
|
||||
</view>
|
||||
</view>
|
||||
<view class="field">
|
||||
@@ -49,5 +48,6 @@
|
||||
<view class="field-label">带哪些狗狗?可以多选</view>
|
||||
<pet-picker pets="{{pets}}" selected="{{selected}}" bind:change="pick" />
|
||||
</view>
|
||||
<button class="primary" bindtap="save">发布约遛 →</button>
|
||||
<button class="primary" bindtap="save" disabled="{{saving}}">发布约遛 →</button>
|
||||
</view>
|
||||
</page-layout>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { secure, toast } = require("../../utils/api");
|
||||
Page({
|
||||
data: { meetups: [], pets: [], selected: [], active: null, userId: "" },
|
||||
data: { meetups: [], pets: [], selected: [], active: null, userId: "", joined: false, busy: false },
|
||||
onShow() {
|
||||
this.load();
|
||||
},
|
||||
@@ -26,46 +26,83 @@ Page({
|
||||
create() {
|
||||
wx.navigateTo({ url: "/pages/meetup-edit/meetup-edit" });
|
||||
},
|
||||
open(e) {
|
||||
this.setData({
|
||||
active: this.data.meetups[e.currentTarget.dataset.index],
|
||||
selected: [],
|
||||
});
|
||||
async open(e) {
|
||||
const summary = this.data.meetups[e.currentTarget.dataset.index];
|
||||
try {
|
||||
const active = await secure("/meetups/" + summary.id);
|
||||
const participant = (active.people || []).find((person) => person.user_id === this.data.userId);
|
||||
this.setData({
|
||||
active: { ...active, date: new Date(active.starts_at).toLocaleString("zh-CN") },
|
||||
joined: !!participant,
|
||||
selected: participant ? participant.pet_ids : [],
|
||||
});
|
||||
} catch (error) {
|
||||
toast(error);
|
||||
}
|
||||
},
|
||||
close() {
|
||||
this.setData({ active: null });
|
||||
this.setData({ active: null, joined: false, selected: [] });
|
||||
},
|
||||
pick(e) {
|
||||
this.setData({ selected: e.detail.selected });
|
||||
},
|
||||
async join() {
|
||||
if (this.data.busy) return;
|
||||
if (!this.data.selected.length) {
|
||||
wx.showToast({ title: "请选择同行的狗狗", icon: "none" });
|
||||
return;
|
||||
}
|
||||
this.setData({ busy: true });
|
||||
try {
|
||||
await secure("/meetups/" + this.data.active.id + "/join", "POST", {
|
||||
pet_ids: this.data.selected,
|
||||
});
|
||||
wx.showToast({ title: "报名成功", icon: "success" });
|
||||
this.close();
|
||||
this.load();
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
toast(e);
|
||||
} finally {
|
||||
this.setData({ busy: false });
|
||||
}
|
||||
},
|
||||
async leave() {
|
||||
if (this.data.busy) return;
|
||||
const result = await wx.showModal({ title: "退出约遛", content: "确定不参加这场约遛了吗?" });
|
||||
if (!result.confirm) return;
|
||||
this.setData({ busy: true });
|
||||
try {
|
||||
await secure("/meetups/" + this.data.active.id + "/join", "DELETE");
|
||||
this.close();
|
||||
await this.load();
|
||||
} catch (error) {
|
||||
toast(error);
|
||||
} finally {
|
||||
this.setData({ busy: false });
|
||||
}
|
||||
},
|
||||
location() {
|
||||
const active = this.data.active;
|
||||
if (active && active.latitude && active.longitude) {
|
||||
wx.openLocation({ latitude: active.latitude, longitude: active.longitude, name: active.place_name, scale: 16 });
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
if (this.data.busy) return;
|
||||
const r = await wx.showModal({
|
||||
title: "取消约遛",
|
||||
content: "确定取消这场约遛吗?",
|
||||
});
|
||||
if (!r.confirm) return;
|
||||
this.setData({ busy: true });
|
||||
try {
|
||||
await secure("/meetups/" + this.data.active.id + "/cancel", "POST");
|
||||
this.close();
|
||||
this.load();
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
toast(e);
|
||||
} finally {
|
||||
this.setData({ busy: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
{ "usingComponents": { "tab-bar": "/components/tab-bar/index", "pet-picker": "/components/pet-picker/index" } }
|
||||
{
|
||||
"disableScroll": true,
|
||||
"usingComponents": {
|
||||
"tab-bar": "/components/tab-bar/index",
|
||||
"pet-picker": "/components/pet-picker/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
<page-layout id="page-layout" title="约个伴一起遛">
|
||||
<view class="screen">
|
||||
<view class="eyebrow">LET'S WALK TOGETHER</view>
|
||||
<view class="title">约个伴一起遛 🌿</view>
|
||||
<view class="banner">
|
||||
<text>好天气,和新朋友一起走走</text>
|
||||
<strong>
|
||||
下一次快乐
|
||||
<br />
|
||||
从这里出发
|
||||
</strong>
|
||||
<view class="banner-copy">
|
||||
<text>好天气,和新朋友一起走走</text>
|
||||
<view class="banner-title first">下一次快乐</view>
|
||||
<view class="banner-title">从这里出发</view>
|
||||
</view>
|
||||
<image src="/assets/corgi-family-five.png" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="section-title">附近的约遛</text>
|
||||
<text class="section-title">即将开始的约遛</text>
|
||||
<text class="add" bindtap="create">+ 发起约遛</text>
|
||||
</view>
|
||||
<view
|
||||
@@ -23,20 +21,21 @@
|
||||
>
|
||||
<view class="row">
|
||||
<text class="badge">
|
||||
🐾 {{item.participants}} / {{item.max_people}} 人
|
||||
{{item.participants}} / {{item.max_people}} 人
|
||||
</text>
|
||||
<text class="arrow">›</text>
|
||||
</view>
|
||||
<strong>{{item.title}}</strong>
|
||||
<view class="meta">📍 {{item.place_name}}</view>
|
||||
<view class="meta">🗓 {{item.date}}</view>
|
||||
<view class="meta">地点 · {{item.place_name}}</view>
|
||||
<view class="meta">时间 · {{item.date}}</view>
|
||||
<view class="desc">{{item.description}}</view>
|
||||
</view>
|
||||
<view wx:if="{{!meetups.length}}" class="empty">
|
||||
还没有约遛,发起第一场吧 🐕
|
||||
还没有约遛,发起第一场吧
|
||||
</view>
|
||||
<button class="primary" bindtap="create">+ 发起一场约遛</button>
|
||||
</view>
|
||||
</page-layout>
|
||||
<view wx:if="{{active}}" class="overlay">
|
||||
<view class="sheet">
|
||||
<view class="row">
|
||||
@@ -44,19 +43,18 @@
|
||||
<text bindtap="close">×</text>
|
||||
</view>
|
||||
<view class="detail">
|
||||
📍 {{active.place_name}}
|
||||
<br />
|
||||
🗓 {{active.date}}
|
||||
<br />
|
||||
🐕 {{active.participants}} / {{active.max_people}} 人已报名
|
||||
<view bindtap="location">地点 · {{active.place_name}} {{active.latitude?'· 点开地图':''}}</view>
|
||||
<view>时间 · {{active.date}}</view>
|
||||
<view> {{active.participants}} / {{active.max_people}} 人已报名</view>
|
||||
</view>
|
||||
<view class="desc">{{active.description}}</view>
|
||||
<view wx:if="{{active.host_user_id!==userId}}">
|
||||
<view wx:if="{{active.host_user_id!==userId&&!joined}}">
|
||||
<view class="select-title">带哪几只?可以多选</view>
|
||||
<pet-picker pets="{{pets}}" selected="{{selected}}" bind:change="pick" />
|
||||
<button class="primary" bindtap="join">带狗狗报名</button>
|
||||
<button class="primary" bindtap="join" disabled="{{busy||active.participants>=active.max_people}}">{{active.participants>=active.max_people?'人数已满':'带狗狗报名'}}</button>
|
||||
</view>
|
||||
<button wx:else class="secondary" bindtap="cancel">取消这场约遛</button>
|
||||
<button wx:elif="{{joined&&active.host_user_id!==userId}}" class="secondary" bindtap="leave" disabled="{{busy}}">已报名 · 退出约遛</button>
|
||||
<button wx:else class="secondary" bindtap="cancel" disabled="{{busy}}">取消这场约遛</button>
|
||||
</view>
|
||||
</view>
|
||||
<tab-bar current="/pages/meetups/meetups" />
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
.banner {
|
||||
background: #dfeee2;
|
||||
background: #edf2fb;
|
||||
border-radius: 34rpx;
|
||||
min-height: 245rpx;
|
||||
padding: 36rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 30rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.banner text,
|
||||
.banner strong {
|
||||
.banner-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.banner-copy text,
|
||||
.banner-title {
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.banner text {
|
||||
color: #549375;
|
||||
.banner-copy text {
|
||||
color: #576c88;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.banner strong {
|
||||
.banner-title {
|
||||
font-size: 35rpx;
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.banner-title.first {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.banner image {
|
||||
width: 290rpx;
|
||||
height: 220rpx;
|
||||
position: absolute;
|
||||
right: -30rpx;
|
||||
bottom: 0;
|
||||
width: 52%;
|
||||
height: 190rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.add {
|
||||
font-size: 24rpx;
|
||||
color: #328965;
|
||||
font-weight: 700;
|
||||
color: #b95738;
|
||||
font-weight: 600;
|
||||
}
|
||||
.meetup strong {
|
||||
display: block;
|
||||
@@ -39,30 +44,30 @@
|
||||
margin: 15rpx 0;
|
||||
}
|
||||
.badge {
|
||||
background: #e8f4ea;
|
||||
color: #429369;
|
||||
background: #edf2fb;
|
||||
color: #b95738;
|
||||
border-radius: 99rpx;
|
||||
padding: 8rpx 15rpx;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.arrow {
|
||||
font-size: 39rpx;
|
||||
color: #a1b2a8;
|
||||
color: #9d8779;
|
||||
}
|
||||
.meta {
|
||||
font-size: 24rpx;
|
||||
color: #748c7d;
|
||||
color: #886c5f;
|
||||
margin-top: 9rpx;
|
||||
}
|
||||
.desc {
|
||||
font-size: 24rpx;
|
||||
color: #9aaa9f;
|
||||
color: #886c5f;
|
||||
margin-top: 15rpx;
|
||||
}
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #102a24a0;
|
||||
background: #604a40a0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -81,13 +86,13 @@
|
||||
}
|
||||
.detail {
|
||||
font-size: 25rpx;
|
||||
color: #61816e;
|
||||
color: #886c5f;
|
||||
line-height: 2;
|
||||
margin: 25rpx 0;
|
||||
}
|
||||
.select-title {
|
||||
font-size: 25rpx;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
margin: 30rpx 0 15rpx;
|
||||
}
|
||||
.sheet button {
|
||||
|
||||
@@ -3,7 +3,7 @@ Page({
|
||||
data: {
|
||||
id: "",
|
||||
name: "",
|
||||
breed: "柯基",
|
||||
breed: "",
|
||||
gender: "male",
|
||||
birth_date: "",
|
||||
weight_kg: "",
|
||||
@@ -12,6 +12,7 @@ Page({
|
||||
care_notes: "",
|
||||
avatar_file_id: "",
|
||||
avatar_url: "",
|
||||
saving: false,
|
||||
},
|
||||
onLoad(options) {
|
||||
if (options.id) {
|
||||
@@ -45,6 +46,7 @@ Page({
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
if (this.data.saving) return;
|
||||
if (!this.data.name.trim()) {
|
||||
wx.showToast({ title: "请输入名字", icon: "none" });
|
||||
return;
|
||||
@@ -61,6 +63,7 @@ Page({
|
||||
care_notes: this.data.care_notes,
|
||||
avatar_file_id: this.data.avatar_file_id || null,
|
||||
};
|
||||
this.setData({ saving: true });
|
||||
try {
|
||||
await secure(
|
||||
"/pets" + (this.data.id ? "/" + this.data.id : ""),
|
||||
@@ -70,6 +73,8 @@ Page({
|
||||
wx.navigateBack();
|
||||
} catch (e) {
|
||||
toast(e);
|
||||
} finally {
|
||||
this.setData({ saving: false });
|
||||
}
|
||||
},
|
||||
async archive() {
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
{}
|
||||
{
|
||||
"disableScroll": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<page-layout id="page-layout" title="{{id?'编辑档案':'认识新朋友'}}" back="{{true}}">
|
||||
<view class="screen">
|
||||
<view class="eyebrow">PET PROFILE</view>
|
||||
<view class="title">{{id?'编辑档案':'认识新朋友'}} 🐶</view>
|
||||
<view class="photo" bindtap="choose">
|
||||
<image wx:if="{{avatar_url}}" src="{{avatar_url}}" mode="aspectFill" />
|
||||
<text wx:else>🐕</text>
|
||||
<view>点击上传照片</view>
|
||||
<app-icon wx:else size="70" />
|
||||
<view class="photo-caption">点击上传照片</view>
|
||||
</view>
|
||||
<view class="field">
|
||||
<view class="field-label">名字 *</view>
|
||||
@@ -17,17 +16,17 @@
|
||||
</view>
|
||||
<view class="field">
|
||||
<view class="field-label">品种</view>
|
||||
<input value="{{breed}}" data-key="breed" bindinput="field" />
|
||||
<input placeholder="填写它的品种" value="{{breed}}" data-key="breed" bindinput="field" />
|
||||
</view>
|
||||
<view class="field">
|
||||
<view class="field-label">性别</view>
|
||||
<radio-group bindchange="gender">
|
||||
<label>
|
||||
<radio value="male" checked="{{gender==='male'}}" color="#194f42" />
|
||||
<radio value="male" checked="{{gender==='male'}}" color="#b95738" />
|
||||
男孩
|
||||
</label>
|
||||
<label>
|
||||
<radio value="female" checked="{{gender==='female'}}" color="#194f42" />
|
||||
<radio value="female" checked="{{gender==='female'}}" color="#b95738" />
|
||||
女孩
|
||||
</label>
|
||||
</radio-group>
|
||||
@@ -59,6 +58,7 @@
|
||||
<view class="field-label">照顾备注</view>
|
||||
<textarea value="{{care_notes}}" data-key="care_notes" bindinput="field" />
|
||||
</view>
|
||||
<button class="primary" bindtap="save">保存档案</button>
|
||||
<button class="primary" bindtap="save" disabled="{{saving}}">保存档案</button>
|
||||
<button wx:if="{{id}}" class="archive" bindtap="archive">归档这只宠物</button>
|
||||
</view>
|
||||
</page-layout>
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.photo view {
|
||||
.photo-caption {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #194f42b5;
|
||||
background: #b95738b5;
|
||||
color: white;
|
||||
text-align: center;
|
||||
font-size: 18rpx;
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
{ "usingComponents": { "tab-bar": "/components/tab-bar/index" } }
|
||||
{
|
||||
"disableScroll": true,
|
||||
"usingComponents": {
|
||||
"tab-bar": "/components/tab-bar/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<page-layout id="page-layout" title="我的毛孩子">
|
||||
<view class="screen">
|
||||
<view class="eyebrow">MY LITTLE FAMILY</view>
|
||||
<view class="title">我的毛孩子 🐕</view>
|
||||
<view class="family">
|
||||
<view>
|
||||
<text>五只柯基,也能一起遛</text>
|
||||
<text>每一位家人,都值得被好好照顾</text>
|
||||
<strong>每一只都有自己的故事</strong>
|
||||
</view>
|
||||
<image src="/assets/corgi-family-five.png" mode="aspectFit" />
|
||||
@@ -25,7 +24,7 @@
|
||||
src="{{item.avatar_url}}"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<text wx:else>🐕</text>
|
||||
<app-icon wx:else size="70" />
|
||||
</view>
|
||||
<view class="pet-info">
|
||||
<strong>{{item.name}}</strong>
|
||||
@@ -37,7 +36,8 @@
|
||||
</view>
|
||||
<text class="arrow">›</text>
|
||||
</view>
|
||||
<view wx:if="{{!pets.length}}" class="empty">给第一只狗狗建个档案吧 🐾</view>
|
||||
<view wx:if="{{!pets.length}}" class="empty">给第一只狗狗建个档案吧 </view>
|
||||
<button class="primary add-button" bindtap="add">+ 新建宠物档案</button>
|
||||
</view>
|
||||
</page-layout>
|
||||
<tab-bar current="/pages/pets/pets" />
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
.family {
|
||||
background: #194f42;
|
||||
background: #f0e8f7;
|
||||
border-radius: 35rpx;
|
||||
color: white;
|
||||
color: #746086;
|
||||
min-height: 220rpx;
|
||||
padding: 35rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.family > view {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.family text,
|
||||
.family strong {
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.family text {
|
||||
color: #a4d6b8;
|
||||
color: #765984;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.family strong {
|
||||
font-size: 32rpx;
|
||||
width: 55%;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.family image {
|
||||
width: 270rpx;
|
||||
height: 205rpx;
|
||||
position: absolute;
|
||||
right: -5rpx;
|
||||
bottom: 0;
|
||||
width: 48%;
|
||||
height: 185rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.add {
|
||||
font-weight: 800;
|
||||
color: #368764;
|
||||
font-weight: 600;
|
||||
color: #b95738;
|
||||
}
|
||||
.pet {
|
||||
display: flex;
|
||||
@@ -64,11 +64,11 @@
|
||||
}
|
||||
.pet-info text {
|
||||
font-size: 23rpx;
|
||||
color: #8a9c90;
|
||||
color: #886c5f;
|
||||
}
|
||||
.arrow {
|
||||
font-size: 42rpx;
|
||||
color: #97a99d;
|
||||
color: #886c5f;
|
||||
}
|
||||
.add-button {
|
||||
margin-top: 25rpx;
|
||||
|
||||
@@ -9,15 +9,23 @@ Page({
|
||||
},
|
||||
onLoad(options) {
|
||||
this.token = options.token;
|
||||
if (!this.token) this.setData({ status: "分享链接无效" });
|
||||
},
|
||||
onShow() {
|
||||
if (!this.token) return;
|
||||
this.load();
|
||||
clearInterval(this.timer);
|
||||
this.timer = setInterval(() => this.load(), 15000);
|
||||
},
|
||||
onHide() {
|
||||
clearInterval(this.timer);
|
||||
},
|
||||
onUnload() {
|
||||
clearInterval(this.timer);
|
||||
},
|
||||
async load() {
|
||||
try {
|
||||
const p = await request("/share/walk/" + this.token);
|
||||
const p = await request("/share/walk/" + this.token, "GET", null, false);
|
||||
this.setData({
|
||||
latitude: p.latitude,
|
||||
longitude: p.longitude,
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
{}
|
||||
{
|
||||
"disableScroll": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<page-layout id="page-layout" title="看看它们走到哪儿了" back="{{true}}">
|
||||
<view class="screen">
|
||||
<view class="eyebrow">肉垫计划 · 位置分享</view>
|
||||
<view class="title">找到小短腿的脚印 🐾</view>
|
||||
<map
|
||||
<map wx:if="{{markers.length}}"
|
||||
latitude="{{latitude}}"
|
||||
longitude="{{longitude}}"
|
||||
scale="16"
|
||||
@@ -13,3 +12,4 @@
|
||||
<view class="muted">上次更新 {{updated}}</view>
|
||||
</view>
|
||||
</view>
|
||||
</page-layout>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
page {
|
||||
background: #f6f9f6;
|
||||
background: #fff9f3;
|
||||
}
|
||||
.card {
|
||||
margin-top: 20rpx;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
const { secure, toast } = require("../../utils/api");
|
||||
const today = () => new Date().toLocaleDateString("sv-SE");
|
||||
const { localDate, shiftDate } = require("../../utils/date");
|
||||
Page({
|
||||
data: {
|
||||
pets: [],
|
||||
tasks: [],
|
||||
date: today(),
|
||||
date: localDate(),
|
||||
showForm: false,
|
||||
editingId: "",
|
||||
saving: false,
|
||||
title: "",
|
||||
petIndex: 0,
|
||||
category: "food",
|
||||
@@ -17,11 +19,13 @@ Page({
|
||||
this.load();
|
||||
},
|
||||
async load() {
|
||||
const date = this.data.date;
|
||||
try {
|
||||
const [pets, result] = await Promise.all([
|
||||
secure("/pets"),
|
||||
secure("/tasks?date=" + this.data.date),
|
||||
secure("/tasks?date=" + date),
|
||||
]);
|
||||
if (date !== this.data.date) return;
|
||||
const tasks = result.items.map((t) => ({
|
||||
...t,
|
||||
petName: (pets.find((p) => p.id === t.pet_id) || {}).name || "宠物",
|
||||
@@ -42,17 +46,50 @@ Page({
|
||||
date: this.data.date,
|
||||
completed: !t.completed,
|
||||
});
|
||||
this.load();
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
toast(err);
|
||||
}
|
||||
},
|
||||
open() {
|
||||
this.setData({ showForm: true });
|
||||
if (!this.data.pets.length) {
|
||||
wx.showToast({ title: "请先添加宠物", icon: "none" });
|
||||
return;
|
||||
}
|
||||
this.setData({
|
||||
showForm: true,
|
||||
editingId: "",
|
||||
title: "",
|
||||
petIndex: 0,
|
||||
category: "food",
|
||||
time_of_day: "08:30",
|
||||
repeat_rule: "daily",
|
||||
});
|
||||
},
|
||||
close() {
|
||||
this.setData({ showForm: false });
|
||||
},
|
||||
edit(e) {
|
||||
const task = this.data.tasks[e.currentTarget.dataset.index];
|
||||
const petIndex = this.data.pets.findIndex((pet) => pet.id === task.pet_id);
|
||||
this.setData({
|
||||
showForm: true,
|
||||
editingId: task.id,
|
||||
title: task.title,
|
||||
petIndex: Math.max(petIndex, 0),
|
||||
category: task.category,
|
||||
time_of_day: task.time_of_day,
|
||||
repeat_rule: task.repeat_rule,
|
||||
});
|
||||
},
|
||||
changeDay(e) {
|
||||
this.setData({ date: shiftDate(this.data.date, Number(e.currentTarget.dataset.days)) });
|
||||
this.load();
|
||||
},
|
||||
showToday() {
|
||||
this.setData({ date: localDate() });
|
||||
this.load();
|
||||
},
|
||||
field(e) {
|
||||
this.setData({ [e.currentTarget.dataset.key]: e.detail.value });
|
||||
},
|
||||
@@ -69,22 +106,26 @@ Page({
|
||||
this.setData({ repeat_rule: e.detail.value });
|
||||
},
|
||||
async save() {
|
||||
if (this.data.saving) return;
|
||||
if (!this.data.pets.length || !this.data.title.trim()) {
|
||||
wx.showToast({ title: "请选择宠物并填写任务名", icon: "none" });
|
||||
return;
|
||||
}
|
||||
this.setData({ saving: true });
|
||||
try {
|
||||
await secure("/tasks", "POST", {
|
||||
await secure(this.data.editingId ? "/tasks/" + this.data.editingId : "/tasks", this.data.editingId ? "PUT" : "POST", {
|
||||
pet_id: this.data.pets[this.data.petIndex].id,
|
||||
title: this.data.title,
|
||||
title: this.data.title.trim(),
|
||||
category: this.data.category,
|
||||
time_of_day: this.data.time_of_day,
|
||||
repeat_rule: this.data.repeat_rule,
|
||||
});
|
||||
this.setData({ showForm: false, title: "" });
|
||||
this.load();
|
||||
this.setData({ showForm: false, editingId: "" });
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
toast(e);
|
||||
} finally {
|
||||
this.setData({ saving: false });
|
||||
}
|
||||
},
|
||||
async remove(e) {
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
{ "usingComponents": { "tab-bar": "/components/tab-bar/index" } }
|
||||
{
|
||||
"disableScroll": true,
|
||||
"usingComponents": {
|
||||
"tab-bar": "/components/tab-bar/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<page-layout id="page-layout" title="今日小任务">
|
||||
<view class="screen">
|
||||
<view class="eyebrow">LITTLE THINGS, BIG LOVE</view>
|
||||
<view class="title">今日小任务 ☑</view>
|
||||
<view class="summary">
|
||||
<view>今天也要照顾好它们</view>
|
||||
<view>这一天也要照顾好它们</view>
|
||||
<strong>
|
||||
{{done}}
|
||||
<text>/ {{tasks.length}}</text>
|
||||
@@ -15,6 +14,11 @@
|
||||
<text class="section-title">{{date}} 的日常</text>
|
||||
<text class="add" bindtap="open">+ 添加任务</text>
|
||||
</view>
|
||||
<view class="date-nav">
|
||||
<text data-days="-1" bindtap="changeDay">‹ 前一天</text>
|
||||
<text bindtap="showToday">回到今天</text>
|
||||
<text data-days="1" bindtap="changeDay">后一天 ›</text>
|
||||
</view>
|
||||
<view class="card list">
|
||||
<view wx:for="{{tasks}}" wx:key="id" class="task">
|
||||
<view
|
||||
@@ -25,27 +29,29 @@
|
||||
{{item.completed?'✓':''}}
|
||||
</view>
|
||||
<view class="icon">
|
||||
{{item.category==='water'?'💧':item.category==='food'?'🥣':'🐾'}}
|
||||
<app-icon kind="{{item.category}}" size="36" />
|
||||
</view>
|
||||
<view class="info" data-index="{{index}}" bindtap="toggle">
|
||||
<view class="info" data-index="{{index}}" bindtap="edit">
|
||||
<strong>{{item.title}}</strong>
|
||||
<text>
|
||||
{{item.petName}} · {{item.time_of_day}} ·
|
||||
{{item.repeat_rule==='daily'?'每天':item.repeat_rule==='weekdays'?'工作日':'周末'}}
|
||||
</text>
|
||||
<text>点此编辑</text>
|
||||
</view>
|
||||
<text class="remove" data-index="{{index}}" bindtap="remove">×</text>
|
||||
</view>
|
||||
<view wx:if="{{!tasks.length}}" class="empty">
|
||||
从喝水、喂食开始,安排温柔的小日常 ✨
|
||||
从喝水、喂食开始,安排温柔的小日常
|
||||
</view>
|
||||
</view>
|
||||
<button class="primary" bindtap="open">+ 新建日常任务</button>
|
||||
</view>
|
||||
</page-layout>
|
||||
<view wx:if="{{showForm}}" class="overlay">
|
||||
<view class="sheet">
|
||||
<view class="row">
|
||||
<strong>新建小任务</strong>
|
||||
<strong>{{editingId?'编辑小任务':'新建小任务'}}</strong>
|
||||
<text bindtap="close">×</text>
|
||||
</view>
|
||||
<view class="field">
|
||||
@@ -72,14 +78,14 @@
|
||||
<view class="field-label">类型</view>
|
||||
<radio-group bindchange="categoryChange">
|
||||
<label>
|
||||
<radio value="food" checked="{{category==='food'}}" color="#194f42" />
|
||||
<radio value="food" checked="{{category==='food'}}" color="#b95738" />
|
||||
喂食
|
||||
</label>
|
||||
<label>
|
||||
<radio
|
||||
value="water"
|
||||
checked="{{category==='water'}}"
|
||||
color="#194f42"
|
||||
color="#b95738"
|
||||
/>
|
||||
喝水
|
||||
</label>
|
||||
@@ -87,7 +93,7 @@
|
||||
<radio
|
||||
value="other"
|
||||
checked="{{category==='other'}}"
|
||||
color="#194f42"
|
||||
color="#b95738"
|
||||
/>
|
||||
其他
|
||||
</label>
|
||||
@@ -106,7 +112,7 @@
|
||||
<radio
|
||||
value="daily"
|
||||
checked="{{repeat_rule==='daily'}}"
|
||||
color="#194f42"
|
||||
color="#b95738"
|
||||
/>
|
||||
每天
|
||||
</label>
|
||||
@@ -114,7 +120,7 @@
|
||||
<radio
|
||||
value="weekdays"
|
||||
checked="{{repeat_rule==='weekdays'}}"
|
||||
color="#194f42"
|
||||
color="#b95738"
|
||||
/>
|
||||
工作日
|
||||
</label>
|
||||
@@ -122,13 +128,13 @@
|
||||
<radio
|
||||
value="weekends"
|
||||
checked="{{repeat_rule==='weekends'}}"
|
||||
color="#194f42"
|
||||
color="#b95738"
|
||||
/>
|
||||
周末
|
||||
</label>
|
||||
</radio-group>
|
||||
</view>
|
||||
<button class="primary" bindtap="save">保存任务</button>
|
||||
<button class="primary" bindtap="save" disabled="{{saving}}">保存任务</button>
|
||||
</view>
|
||||
</view>
|
||||
<tab-bar current="/pages/tasks/tasks" />
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
.summary {
|
||||
background: #194f42;
|
||||
color: white;
|
||||
background: #e8f0fa;
|
||||
color: #526986;
|
||||
border-radius: 34rpx;
|
||||
padding: 34rpx;
|
||||
}
|
||||
.summary > view:first-child {
|
||||
font-size: 24rpx;
|
||||
color: #bbe3c4;
|
||||
color: #576c88;
|
||||
}
|
||||
.summary strong {
|
||||
display: block;
|
||||
@@ -15,23 +15,30 @@
|
||||
}
|
||||
.summary strong text {
|
||||
font-size: 30rpx;
|
||||
color: #b4d8be;
|
||||
color: #576c88;
|
||||
}
|
||||
.progress {
|
||||
height: 13rpx;
|
||||
background: #3f7566;
|
||||
background: #dbe4f2;
|
||||
border-radius: 99rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress view {
|
||||
height: 100%;
|
||||
background: #e9ad6a;
|
||||
background: #e49b75;
|
||||
border-radius: 99rpx;
|
||||
}
|
||||
.add {
|
||||
color: #2b8a63;
|
||||
color: #b95738;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
}
|
||||
.date-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #b95738;
|
||||
font-size: 23rpx;
|
||||
margin: 0 0 22rpx;
|
||||
}
|
||||
.list {
|
||||
padding: 8rpx 26rpx;
|
||||
@@ -41,7 +48,7 @@
|
||||
align-items: center;
|
||||
gap: 15rpx;
|
||||
padding: 22rpx 0;
|
||||
border-bottom: 1rpx solid #eef2ee;
|
||||
border-bottom: 1rpx solid #ece2d8;
|
||||
}
|
||||
.task:last-child {
|
||||
border: 0;
|
||||
@@ -49,20 +56,20 @@
|
||||
.check {
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
border: 3rpx solid #cbdcd1;
|
||||
border: 3rpx solid #ece2d8;
|
||||
border-radius: 13rpx;
|
||||
text-align: center;
|
||||
line-height: 37rpx;
|
||||
color: white;
|
||||
}
|
||||
.check.done {
|
||||
background: #389867;
|
||||
border-color: #389867;
|
||||
background: #b95738;
|
||||
border-color: #b95738;
|
||||
}
|
||||
.icon {
|
||||
width: 65rpx;
|
||||
height: 65rpx;
|
||||
background: #eff5ee;
|
||||
background: #fff0e2;
|
||||
border-radius: 18rpx;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -77,29 +84,34 @@
|
||||
}
|
||||
.info text {
|
||||
font-size: 21rpx;
|
||||
color: #92a498;
|
||||
color: #886c5f;
|
||||
margin-top: 5rpx;
|
||||
}
|
||||
.remove {
|
||||
font-size: 36rpx;
|
||||
color: #c4d0c7;
|
||||
color: #9d8779;
|
||||
}
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #102a24a0;
|
||||
background: #604a40a0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.sheet {
|
||||
width: 100%;
|
||||
background: #f6f9f6;
|
||||
background: #fff9f3;
|
||||
border-radius: 35rpx 35rpx 0 0;
|
||||
padding: 32rpx 32rpx calc(env(safe-area-inset-bottom) + 30rpx);
|
||||
max-height: 85vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.sheet::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.sheet > .row strong {
|
||||
font-size: 33rpx;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
const { secure, toast } = require("../../utils/api");
|
||||
|
||||
function duration(seconds) {
|
||||
const minutes = Math.floor((seconds || 0) / 60);
|
||||
return `${minutes} 分 ${String((seconds || 0) % 60).padStart(2, "0")} 秒`;
|
||||
}
|
||||
|
||||
Page({
|
||||
data: { records: [], selected: null, loading: true },
|
||||
onLoad() {
|
||||
this.load();
|
||||
},
|
||||
async load() {
|
||||
try {
|
||||
const [walks, pets] = await Promise.all([secure("/walks"), secure("/pets")]);
|
||||
const names = Object.fromEntries(pets.map((pet) => [pet.id, pet.name]));
|
||||
this.setData({
|
||||
records: walks.filter((walk) => walk.status === "finished").map((walk) => ({
|
||||
...walk,
|
||||
date: new Date(walk.started_at).toLocaleString("zh-CN"),
|
||||
duration: duration(walk.duration_seconds),
|
||||
distance: ((walk.distance_meters || 0) / 1000).toFixed(2),
|
||||
petNames: (walk.pet_ids || []).map((id) => names[id] || "已归档宠物").join("、"),
|
||||
polyline: walk.points && walk.points.length > 1 ? [{
|
||||
points: walk.points.map((point) => ({ latitude: point.latitude, longitude: point.longitude })),
|
||||
color: "#b95738",
|
||||
width: 5,
|
||||
}] : [],
|
||||
lastPoint: walk.points && walk.points.length ? walk.points[walk.points.length - 1] : null,
|
||||
})),
|
||||
loading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
this.setData({ loading: false });
|
||||
toast(error);
|
||||
}
|
||||
},
|
||||
open(e) {
|
||||
this.setData({ selected: this.data.records[e.currentTarget.dataset.index] });
|
||||
},
|
||||
close() {
|
||||
this.setData({ selected: null });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"disableScroll": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<page-layout id="page-layout" title="{{selected?'这次的脚印':'遛狗记录'}}" back="{{true}}">
|
||||
<view class="screen">
|
||||
<text wx:if="{{selected}}" class="back" bindtap="close">‹ 全部记录</text>
|
||||
<view wx:if="{{selected}}">
|
||||
<view class="card detail">
|
||||
<strong>{{selected.date}}</strong>
|
||||
<text>{{selected.petNames}}</text>
|
||||
<view class="metrics">
|
||||
<view><strong>{{selected.duration}}</strong><text>时长</text></view>
|
||||
<view><strong>{{selected.distance}} km</strong><text>距离</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<map wx:if="{{selected.lastPoint}}" class="route-map"
|
||||
latitude="{{selected.lastPoint.latitude}}"
|
||||
longitude="{{selected.lastPoint.longitude}}"
|
||||
polyline="{{selected.polyline}}" scale="15" />
|
||||
<view wx:else class="empty">这次没有留下定位轨迹</view>
|
||||
</view>
|
||||
<view wx:else>
|
||||
<view wx:for="{{records}}" wx:key="id" class="card record" data-index="{{index}}" bindtap="open">
|
||||
<view class="row"><strong>{{item.date}}</strong><text>›</text></view>
|
||||
<view class="pet-names"> {{item.petNames}}</view>
|
||||
<view class="record-stats">{{item.duration}} · {{item.distance}} km · {{item.points.length}} 个足迹</view>
|
||||
</view>
|
||||
<view wx:if="{{!loading&&!records.length}}" class="empty">完成一次遛狗,脚印就会出现在这里 </view>
|
||||
</view>
|
||||
</view>
|
||||
</page-layout>
|
||||
@@ -0,0 +1,10 @@
|
||||
.back { display: block; color: #b95738; margin-bottom: 34rpx; font-size: 26rpx; }
|
||||
.record strong, .detail strong { font-size: 28rpx; }
|
||||
.record .row text { font-size: 38rpx; color: #886c5f; }
|
||||
.pet-names, .detail > text { display: block; color: #886c5f; margin-top: 16rpx; }
|
||||
.record-stats { color: #886c5f; font-size: 24rpx; margin-top: 12rpx; }
|
||||
.metrics { display: flex; gap: 28rpx; margin-top: 28rpx; border-top: 1rpx solid #ece2d8; padding-top: 22rpx; }
|
||||
.metrics view { flex: 1; }
|
||||
.metrics strong, .metrics text { display: block; }
|
||||
.metrics text { color: #886c5f; font-size: 22rpx; margin-top: 7rpx; }
|
||||
.route-map { width: 100%; height: 600rpx; border-radius: 30rpx; overflow: hidden; }
|
||||
+61
-13
@@ -11,9 +11,12 @@ Page({
|
||||
duration: "00:00",
|
||||
distance: "0.00",
|
||||
share: false,
|
||||
busy: false,
|
||||
locationError: "",
|
||||
},
|
||||
onShow() {
|
||||
this.load();
|
||||
clearInterval(this.clock);
|
||||
this.clock = setInterval(() => this.tick(), 1000);
|
||||
},
|
||||
onHide() {
|
||||
@@ -25,6 +28,8 @@ Page({
|
||||
stopTimers() {
|
||||
clearInterval(this.clock);
|
||||
clearInterval(this.tracker);
|
||||
this.clock = null;
|
||||
this.tracker = null;
|
||||
},
|
||||
async load() {
|
||||
try {
|
||||
@@ -41,7 +46,13 @@ Page({
|
||||
this.setData({ pets, selected, walk });
|
||||
if (walk) {
|
||||
this.renderWalk(walk);
|
||||
this.track();
|
||||
if (walk.status === "active") {
|
||||
this.track();
|
||||
this.point();
|
||||
}
|
||||
} else {
|
||||
clearInterval(this.tracker);
|
||||
this.setData({ points: [], polyline: [], duration: "00:00", distance: "0.00", share: false });
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e);
|
||||
@@ -53,19 +64,33 @@ Page({
|
||||
wx.setStorageSync("walk_pet_ids", e.detail.selected);
|
||||
},
|
||||
async start() {
|
||||
if (this.data.busy) return;
|
||||
if (!this.data.selected.length) {
|
||||
wx.showToast({ title: "请选择同行的狗狗", icon: "none" });
|
||||
return;
|
||||
}
|
||||
this.setData({ busy: true, locationError: "" });
|
||||
let position;
|
||||
try {
|
||||
position = await wx.getLocation({ type: "gcj02" });
|
||||
} catch (error) {
|
||||
this.setData({ busy: false, locationError: "请检查小程序定位权限,开启后再试" });
|
||||
toast(new Error("无法取得位置,请检查定位权限"));
|
||||
return;
|
||||
}
|
||||
this.setData({ latitude: position.latitude, longitude: position.longitude });
|
||||
try {
|
||||
const walk = await secure("/walks", "POST", {
|
||||
pet_ids: this.data.selected,
|
||||
});
|
||||
this.setData({ walk });
|
||||
this.track();
|
||||
this.point();
|
||||
await this.savePoint(position);
|
||||
} catch (e) {
|
||||
if (this.data.walk) this.setData({ locationError: "轨迹保存失败,请点击重试" });
|
||||
toast(e);
|
||||
} finally {
|
||||
this.setData({ busy: false });
|
||||
}
|
||||
},
|
||||
track() {
|
||||
@@ -73,17 +98,27 @@ Page({
|
||||
this.tracker = setInterval(() => this.point(), 15000);
|
||||
},
|
||||
async point() {
|
||||
if (!this.data.walk || this.data.walk.status !== "active") return;
|
||||
if (!this.data.walk || this.data.walk.status !== "active" || this.locating) return;
|
||||
this.locating = true;
|
||||
try {
|
||||
const pos = await wx.getLocation({ type: "gcj02" });
|
||||
const walk = await secure(
|
||||
"/walks/" + this.data.walk.id + "/points",
|
||||
"POST",
|
||||
{ latitude: pos.latitude, longitude: pos.longitude },
|
||||
);
|
||||
this.renderWalk(walk);
|
||||
await this.savePoint(pos);
|
||||
this.setData({ locationError: "" });
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
if (this.data.walk) this.setData({ locationError: "定位更新失败,请检查定位权限和网络" });
|
||||
} finally {
|
||||
this.locating = false;
|
||||
}
|
||||
},
|
||||
async savePoint(pos) {
|
||||
const walkId = this.data.walk.id;
|
||||
const walk = await secure(
|
||||
"/walks/" + walkId + "/points",
|
||||
"POST",
|
||||
{ latitude: pos.latitude, longitude: pos.longitude },
|
||||
);
|
||||
if (this.data.walk && this.data.walk.id === walkId && this.data.walk.status === "active") {
|
||||
this.renderWalk(walk);
|
||||
}
|
||||
},
|
||||
renderWalk(walk) {
|
||||
@@ -96,10 +131,10 @@ Page({
|
||||
walk,
|
||||
points: pts,
|
||||
polyline:
|
||||
pts.length > 1 ? [{ points: pts, color: "#268e6a", width: 5 }] : [],
|
||||
pts.length > 1 ? [{ points: pts, color: "#b95738", width: 5 }] : [],
|
||||
latitude: last ? last.latitude : this.data.latitude,
|
||||
longitude: last ? last.longitude : this.data.longitude,
|
||||
distance: (walk.distance_meters / 1000).toFixed(2),
|
||||
distance: ((walk.distance_meters || 0) / 1000).toFixed(2),
|
||||
share: walk.share_enabled,
|
||||
});
|
||||
this.tick();
|
||||
@@ -125,7 +160,9 @@ Page({
|
||||
});
|
||||
},
|
||||
async action(e) {
|
||||
if (this.data.busy) return;
|
||||
const kind = e.currentTarget.dataset.kind;
|
||||
this.setData({ busy: true });
|
||||
try {
|
||||
const walk = await secure(
|
||||
"/walks/" + this.data.walk.id + "/" + kind,
|
||||
@@ -134,12 +171,23 @@ Page({
|
||||
this.renderWalk(walk);
|
||||
if (kind === "finish") {
|
||||
clearInterval(this.tracker);
|
||||
this.setData({ walk: null, points: [], polyline: [], duration: "00:00", distance: "0.00", share: false });
|
||||
wx.showToast({ title: "今天的快乐已记录", icon: "success" });
|
||||
} else if (kind === "resume") {
|
||||
this.track();
|
||||
this.point();
|
||||
} else {
|
||||
clearInterval(this.tracker);
|
||||
}
|
||||
} catch (err) {
|
||||
toast(err);
|
||||
} finally {
|
||||
this.setData({ busy: false });
|
||||
}
|
||||
},
|
||||
records() {
|
||||
wx.navigateTo({ url: "/pages/walk-records/walk-records" });
|
||||
},
|
||||
async shareChange(e) {
|
||||
try {
|
||||
const walk = await secure(
|
||||
@@ -155,7 +203,7 @@ Page({
|
||||
onShareAppMessage() {
|
||||
const walk = this.data.walk;
|
||||
return {
|
||||
title: "肉垫计划 · 看看我们遛到哪儿了 🐾",
|
||||
title: "肉垫计划 · 看看我们遛到哪儿了",
|
||||
path:
|
||||
walk && walk.share_enabled
|
||||
? "/pages/share/share?token=" + walk.share_token
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
{ "usingComponents": { "tab-bar": "/components/tab-bar/index", "pet-picker": "/components/pet-picker/index" } }
|
||||
{
|
||||
"disableScroll": true,
|
||||
"usingComponents": {
|
||||
"tab-bar": "/components/tab-bar/index",
|
||||
"pet-picker": "/components/pet-picker/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<page-layout id="page-layout" title="一起去兜风">
|
||||
<view class="screen walk-screen">
|
||||
<view class="eyebrow">PAWPRINT WALK</view>
|
||||
<view class="title">一起去兜风 🐾</view>
|
||||
<view class="map-box">
|
||||
<map
|
||||
id="walk-map"
|
||||
@@ -18,7 +17,7 @@
|
||||
<view class="card session">
|
||||
<view class="eyebrow">{{walk?'快乐进行时':'出门走走吧'}}</view>
|
||||
<view class="session-title">
|
||||
{{walk?'跟小短腿一起探索':'选择今天同行的狗狗'}}
|
||||
{{walk?'跟它们一起探索':'选择今天同行的狗狗'}}
|
||||
</view>
|
||||
<view class="stats">
|
||||
<view>
|
||||
@@ -38,15 +37,17 @@
|
||||
<pet-picker
|
||||
pets="{{pets}}"
|
||||
selected="{{walk?walk.pet_ids:selected}}"
|
||||
disabled="{{walk!==null}}"
|
||||
bind:change="pick"
|
||||
/>
|
||||
<view wx:if="{{!pets.length}}" class="empty">
|
||||
先到「宠物」添加你的柯基吧 🐕
|
||||
到「宠物」添加同行的狗狗,再一起出发吧
|
||||
</view>
|
||||
<button
|
||||
wx:if="{{!walk||walk.status==='finished'}}"
|
||||
class="primary main-button"
|
||||
bindtap="start"
|
||||
disabled="{{busy}}"
|
||||
>
|
||||
开始遛狗 →
|
||||
</button>
|
||||
@@ -55,10 +56,11 @@
|
||||
class="secondary"
|
||||
data-kind="{{walk.status==='active'?'pause':'resume'}}"
|
||||
bindtap="action"
|
||||
disabled="{{busy}}"
|
||||
>
|
||||
{{walk.status==='active'?'暂停':'继续'}}
|
||||
</button>
|
||||
<button class="primary" data-kind="finish" bindtap="action">
|
||||
<button class="primary" data-kind="finish" bindtap="action" disabled="{{busy}}">
|
||||
结束遛狗
|
||||
</button>
|
||||
</view>
|
||||
@@ -67,14 +69,17 @@
|
||||
<strong>分享实时位置</strong>
|
||||
<text>打开后可分享给朋友,结束遛狗会自动关闭</text>
|
||||
</view>
|
||||
<switch color="#278465" checked="{{share}}" bindchange="shareChange" />
|
||||
<switch color="#b95738" checked="{{share}}" bindchange="shareChange" />
|
||||
</view>
|
||||
<button wx:if="{{share}}" class="share-button" open-type="share">
|
||||
发给朋友看位置 ↗
|
||||
</button>
|
||||
<view wx:if="{{locationError}}" class="location-error" bindtap="point">{{locationError}} · 点击重试</view>
|
||||
</view>
|
||||
<button class="records-button" bindtap="records">查看最近的脚印 ›</button>
|
||||
<view class="privacy">
|
||||
🔒 只有你主动打开分享时,朋友才能查看这次遛狗的位置。
|
||||
只有你主动打开分享时,朋友才能查看位置。保持小程序打开,位置才会持续更新。
|
||||
</view>
|
||||
</view>
|
||||
</page-layout>
|
||||
<tab-bar current="/pages/walk/walk" />
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
.walk-screen > .eyebrow,
|
||||
.walk-screen > .title {
|
||||
margin-left: 32rpx;
|
||||
}
|
||||
.map-box {
|
||||
height: 450rpx;
|
||||
position: relative;
|
||||
@@ -16,11 +12,11 @@
|
||||
top: 25rpx;
|
||||
left: 30rpx;
|
||||
background: white;
|
||||
color: #288363;
|
||||
color: #b95738;
|
||||
padding: 12rpx 21rpx;
|
||||
border-radius: 99rpx;
|
||||
font-size: 23rpx;
|
||||
box-shadow: 0 8rpx 25rpx #173e3622;
|
||||
box-shadow: 0 8rpx 25rpx #604a4022;
|
||||
}
|
||||
.session {
|
||||
margin: -30rpx 25rpx 0;
|
||||
@@ -30,17 +26,17 @@
|
||||
}
|
||||
.session-title {
|
||||
font-size: 35rpx;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
margin: 12rpx 0 30rpx;
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
border-bottom: 2rpx solid #edf1eb;
|
||||
border-bottom: 2rpx solid #ece2d8;
|
||||
padding-bottom: 25rpx;
|
||||
}
|
||||
.stats > view {
|
||||
flex: 1;
|
||||
border-right: 2rpx solid #e9efea;
|
||||
border-right: 2rpx solid #ece2d8;
|
||||
padding-left: 22rpx;
|
||||
}
|
||||
.stats > view:first-child {
|
||||
@@ -55,10 +51,10 @@
|
||||
}
|
||||
.stats text {
|
||||
font-size: 22rpx;
|
||||
color: #99aa9d;
|
||||
color: #886c5f;
|
||||
}
|
||||
.picker-title {
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
margin: 26rpx 0 18rpx;
|
||||
}
|
||||
.main-button {
|
||||
@@ -84,22 +80,34 @@
|
||||
}
|
||||
.share-row text {
|
||||
font-size: 21rpx;
|
||||
color: #90a199;
|
||||
color: #886c5f;
|
||||
max-width: 450rpx;
|
||||
margin-top: 5rpx;
|
||||
}
|
||||
.share-button {
|
||||
margin-top: 20rpx;
|
||||
background: #e6f1e8;
|
||||
color: #267d59;
|
||||
background: #fff0e2;
|
||||
color: #b95738;
|
||||
border-radius: 20rpx;
|
||||
font-size: 25rpx;
|
||||
}
|
||||
.privacy {
|
||||
font-size: 22rpx;
|
||||
color: #739180;
|
||||
background: #e9f3e7;
|
||||
color: #886c5f;
|
||||
background: #edf2fb;
|
||||
border-radius: 22rpx;
|
||||
padding: 22rpx;
|
||||
margin: 20rpx 25rpx;
|
||||
}
|
||||
.location-error {
|
||||
color: #a4513f;
|
||||
font-size: 23rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.records-button {
|
||||
margin: 28rpx 25rpx 0;
|
||||
color: #b95738;
|
||||
background: #fff0e2;
|
||||
border-radius: 20rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
+18
-2
@@ -38,7 +38,7 @@ async function secure(path, method, data) {
|
||||
function toast(error) {
|
||||
wx.showToast({ title: error.message || "操作失败", icon: "none" });
|
||||
}
|
||||
function upload(path) {
|
||||
function uploadOnce(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = getApp().globalData.token;
|
||||
wx.uploadFile({
|
||||
@@ -50,7 +50,11 @@ function upload(path) {
|
||||
try {
|
||||
const body = JSON.parse(res.data);
|
||||
if (body.code === 0) resolve(body.data);
|
||||
else reject(new Error(body.message));
|
||||
else {
|
||||
const error = new Error(body.message || "上传失败");
|
||||
error.status = res.statusCode;
|
||||
reject(error);
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
@@ -59,4 +63,16 @@ function upload(path) {
|
||||
});
|
||||
});
|
||||
}
|
||||
async function upload(path) {
|
||||
await getApp().login();
|
||||
try {
|
||||
return await uploadOnce(path);
|
||||
} catch (error) {
|
||||
if (error.status !== 401) throw error;
|
||||
wx.removeStorageSync("pet_token");
|
||||
getApp().globalData.token = "";
|
||||
await getApp().login();
|
||||
return uploadOnce(path);
|
||||
}
|
||||
}
|
||||
module.exports = { request, secure, toast, upload };
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
function localDate(date = new Date()) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function shiftDate(value, days) {
|
||||
const date = new Date(`${value}T12:00:00`);
|
||||
date.setDate(date.getDate() + days);
|
||||
return localDate(date);
|
||||
}
|
||||
|
||||
module.exports = { localDate, shiftDate };
|
||||
@@ -0,0 +1,19 @@
|
||||
function navigationGeometry(info = {}, menu = {}) {
|
||||
const width = info.windowWidth || 375;
|
||||
const height = info.windowHeight || 812;
|
||||
const status = info.statusBarHeight || 24;
|
||||
const validMenu = menu.top >= status && menu.height > 0 && menu.left > 0 && menu.left < width;
|
||||
const top = validMenu ? menu.top : status + 6;
|
||||
const rowHeight = validMenu ? menu.height : 32;
|
||||
const menuLeft = validMenu ? menu.left : width - 100;
|
||||
const headerHeight = top + rowHeight + 12;
|
||||
return {
|
||||
top,
|
||||
rowHeight,
|
||||
titleWidth: Math.max(80, menuLeft - 28),
|
||||
headerHeight,
|
||||
scrollHeight: Math.max(0, height - headerHeight),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { navigationGeometry };
|
||||
@@ -0,0 +1,136 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
|
||||
const apiPath = require.resolve("../pets-fe/utils/api");
|
||||
|
||||
function page(name, secure, wx = {}) {
|
||||
global.wx = {
|
||||
getStorageSync: () => [],
|
||||
setStorageSync: () => {},
|
||||
showToast: () => {},
|
||||
showModal: async () => ({ confirm: true }),
|
||||
navigateBack: () => {},
|
||||
getLocation: async () => ({ latitude: 31.2, longitude: 121.4 }),
|
||||
...wx,
|
||||
};
|
||||
require.cache[apiPath] = {
|
||||
id: apiPath,
|
||||
filename: apiPath,
|
||||
loaded: true,
|
||||
exports: { secure, toast: (error) => { throw error; } },
|
||||
};
|
||||
let definition;
|
||||
global.Page = (value) => { definition = value; };
|
||||
const filename = path.resolve(__dirname, `../pets-fe/pages/${name}/${name}.js`);
|
||||
delete require.cache[filename];
|
||||
require(filename);
|
||||
return {
|
||||
...definition,
|
||||
data: { ...definition.data },
|
||||
setData(changes) { Object.assign(this.data, changes); },
|
||||
};
|
||||
}
|
||||
|
||||
test("编辑任务走 PUT,并保留选中的宠物", async () => {
|
||||
const calls = [];
|
||||
const pet = { id: "pet-1", name: "奶盖" };
|
||||
const task = { id: "task-1", pet_id: pet.id, title: "换水", category: "water", time_of_day: "09:00", repeat_rule: "daily" };
|
||||
const screen = page("tasks", async (route, method, body) => {
|
||||
calls.push({ route, method, body });
|
||||
if (route === "/pets") return [pet];
|
||||
if (route.startsWith("/tasks?")) return { items: [task] };
|
||||
return task;
|
||||
});
|
||||
await screen.load();
|
||||
screen.edit({ currentTarget: { dataset: { index: 0 } } });
|
||||
screen.setData({ title: "更换饮水" });
|
||||
await screen.save();
|
||||
assert.deepEqual(calls.find((call) => call.method === "PUT"), {
|
||||
route: "/tasks/task-1",
|
||||
method: "PUT",
|
||||
body: { pet_id: pet.id, title: "更换饮水", category: "water", time_of_day: "09:00", repeat_rule: "daily" },
|
||||
});
|
||||
});
|
||||
|
||||
test("多宠物遛狗发送全部 ID,结束后可再次开始", async () => {
|
||||
const calls = [];
|
||||
const active = { id: "walk-1", status: "active", started_at: new Date().toISOString(), pet_ids: ["a", "b"], distance_meters: 0, points: [] };
|
||||
const screen = page("walk", async (route, method, body) => {
|
||||
calls.push({ route, method, body });
|
||||
if (route === "/walks" && method === "POST") return active;
|
||||
if (route.endsWith("/points")) return { ...active, points: [{ latitude: 31.2, longitude: 121.4 }] };
|
||||
if (route.endsWith("/finish")) return { ...active, status: "finished" };
|
||||
});
|
||||
screen.track = () => {};
|
||||
screen.setData({ selected: ["a", "b"] });
|
||||
await screen.start();
|
||||
assert.deepEqual(calls[0].body.pet_ids, ["a", "b"]);
|
||||
assert.equal(calls[1].route, "/walks/walk-1/points");
|
||||
await screen.action({ currentTarget: { dataset: { kind: "finish" } } });
|
||||
assert.equal(screen.data.walk, null);
|
||||
assert.deepEqual(screen.data.selected, ["a", "b"]);
|
||||
});
|
||||
|
||||
test("足迹列表保留同行宠物和轨迹", async () => {
|
||||
const screen = page("walk-records", async (route) => route === "/pets"
|
||||
? [{ id: "a", name: "奶盖" }, { id: "b", name: "布丁" }]
|
||||
: [{ id: "walk-1", status: "finished", started_at: "2026-10-01T08:00:00+08:00", duration_seconds: 120, distance_meters: 500, pet_ids: ["a", "b"], points: [{ latitude: 31.2, longitude: 121.4 }, { latitude: 31.3, longitude: 121.5 }] }]);
|
||||
await screen.load();
|
||||
assert.equal(screen.data.records[0].petNames, "奶盖、布丁");
|
||||
assert.equal(screen.data.records[0].distance, "0.50");
|
||||
assert.equal(screen.data.records[0].polyline[0].points.length, 2);
|
||||
});
|
||||
|
||||
test("约遛详情识别已报名并能退出", async () => {
|
||||
const calls = [];
|
||||
const screen = page("meetups", async (route, method) => {
|
||||
calls.push({ route, method });
|
||||
if (route === "/meetups/event-1") return { id: "event-1", starts_at: "2026-10-05T08:00:00+08:00", people: [{ user_id: "user-1", pet_ids: ["pet-1"] }] };
|
||||
return {};
|
||||
});
|
||||
screen.setData({ userId: "user-1", meetups: [{ id: "event-1" }] });
|
||||
screen.load = async () => {};
|
||||
await screen.open({ currentTarget: { dataset: { index: 0 } } });
|
||||
assert.equal(screen.data.joined, true);
|
||||
assert.deepEqual(screen.data.selected, ["pet-1"]);
|
||||
await screen.leave();
|
||||
assert.deepEqual(calls[1], { route: "/meetups/event-1/join", method: "DELETE" });
|
||||
});
|
||||
|
||||
test("首页使用 GCJ-02 定位并展示真实城市天气", async () => {
|
||||
const screen = page("home", async (route, method, body) => {
|
||||
assert.equal(route, "/environment");
|
||||
assert.equal(method, "GET");
|
||||
assert.deepEqual(body, { latitude: 30.2, longitude: 120.1 });
|
||||
return { location_name: "杭州市 · 西湖区", weather: { description: "小雨", temperature: "19" } };
|
||||
}, {
|
||||
getSetting: ({ success }) => success({ authSetting: { "scope.userLocation": true } }),
|
||||
getLocation: ({ type, success }) => {
|
||||
assert.equal(type, "gcj02");
|
||||
success({ latitude: 30.2, longitude: 120.1 });
|
||||
},
|
||||
});
|
||||
await screen.refreshEnvironment();
|
||||
assert.equal(screen.data.locationName, "杭州市 · 西湖区");
|
||||
assert.match(screen.data.headerSubtitle, /周. · 小雨 19°C$/);
|
||||
});
|
||||
|
||||
test("拒绝定位不请求后端,点击后可以恢复授权", async () => {
|
||||
let calls = 0;
|
||||
const screen = page("home", async () => {
|
||||
calls++;
|
||||
return { location_name: "杭州市", weather: null };
|
||||
}, {
|
||||
getSetting: ({ success }) => success({ authSetting: { "scope.userLocation": false } }),
|
||||
openSetting: ({ success }) => success({ authSetting: { "scope.userLocation": true } }),
|
||||
getLocation: ({ success }) => success({ latitude: 30.2, longitude: 120.1 }),
|
||||
});
|
||||
await screen.refreshEnvironment();
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(screen.data.locationName, "开启定位");
|
||||
await screen.refreshEnvironment({ type: "locationtap" });
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(screen.data.locationName, "杭州市");
|
||||
assert.match(screen.data.headerSubtitle, /天气暂不可用$/);
|
||||
});
|
||||
Reference in New Issue
Block a user