Compare commits

...
28 Commits
Author SHA1 Message Date
Blizzard fdf9b9829c Merge pull request 'Feat/dev' (#6) from feat/dev into main
ci-cd / verify (push) Successful in 16s
ci-cd / deploy (push) Successful in 5m58s
Reviewed-on: #6
2026-10-05 12:22:47 +00:00
Blizzard 3175936266 feat: support shared pet care tasks and smooth daily page return
ci-cd / verify (push) Successful in 2m18s
ci-cd / deploy (push) Skipped
2026-10-05 20:20:05 +08:00
Blizzard fbc1eda561 fix: ignore cancelled pet photo selection
ci-cd / verify (push) Successful in 6s
ci-cd / deploy (push) Skipped
2026-10-05 20:13:10 +08:00
Blizzard abd64d1cdd Merge pull request 'Feat/dev' (#5) from feat/dev into main
ci-cd / verify (push) Successful in 6s
ci-cd / deploy (push) Successful in 1m34s
Reviewed-on: #5
2026-10-05 05:41:52 +00:00
Blizzard 9d7cf720a5 feat: add multi-source breed encyclopedia and two-hour refresh sessions
ci-cd / verify (push) Successful in 1m59s
ci-cd / deploy (push) Skipped
2026-10-05 13:38:24 +08:00
Blizzard 607d709100 Avoid reloading tasks when already viewing today
ci-cd / verify (push) Successful in 5s
ci-cd / deploy (push) Skipped
2026-10-05 12:01:37 +08:00
Blizzard d6cb582ffe Rename home tab to daily and add calendar sunshine icon
ci-cd / verify (push) Successful in 5s
ci-cd / deploy (push) Skipped
2026-10-05 11:43:10 +08:00
Blizzard b1439b0fe5 Move pet card actions into menu and use circular brand logos
ci-cd / verify (push) Successful in 1m39s
ci-cd / deploy (push) Skipped
2026-10-05 11:38:15 +08:00
Blizzard cb4ffa13e9 Add one-way task completion and exportable pet identity cards
ci-cd / verify (push) Successful in 1m31s
ci-cd / deploy (push) Skipped
2026-10-05 11:32:32 +08:00
Blizzard f0ee63e642 Cache home location and weather across page navigation
ci-cd / verify (push) Successful in 6s
ci-cd / deploy (push) Skipped
2026-10-05 11:08:11 +08:00
Blizzard 42aea10101 Add immediate startup login and loading states for mini program home
ci-cd / verify (push) Successful in 5s
ci-cd / deploy (push) Skipped
2026-10-05 11:03:33 +08:00
Blizzard 1d8c1693ac Merge pull request 'Generate unique Mao Hai parent nicknames for new users' (#4) from feat/dev into main
ci-cd / verify (push) Successful in 5s
ci-cd / deploy (push) Successful in 3m30s
Reviewed-on: #4
2026-10-04 03:54:45 +00:00
Blizzard 4916c31edd Generate unique Mao Hai parent nicknames for new users
ci-cd / verify (push) Successful in 1m31s
ci-cd / deploy (push) Skipped
2026-10-04 11:53:53 +08:00
Blizzard 25c2596ce9 Merge pull request 'Feat/dev' (#3) from feat/dev into main
ci-cd / verify (push) Successful in 6s
ci-cd / deploy (push) Successful in 3m9s
Reviewed-on: #3
2026-10-04 03:48:28 +00:00
Blizzard 6c37785ce1 Complete admin dashboard with paginated user pet trees and walk relations
ci-cd / verify (push) Successful in 1m40s
ci-cd / deploy (push) Skipped
2026-10-04 11:45:15 +08:00
Blizzard 3655a6a89e Shorten location permission description for WeChat upload
ci-cd / verify (push) Successful in 4s
ci-cd / deploy (push) Skipped
2026-10-04 11:05:50 +08:00
Blizzard 7fc50f9212 Add AMap location and weather header with warm mini program theme
ci-cd / verify (push) Successful in 1m54s
ci-cd / deploy (push) Skipped
2026-10-04 11:03:34 +08:00
Blizzard c2b51f96e0 Fix capsule-aligned navigation and refresh mini program typography
ci-cd / verify (push) Successful in 6s
ci-cd / deploy (push) Skipped
2026-10-04 10:30:17 +08:00
Blizzard adfd0574cc Hide scrollbars across mini program pages
ci-cd / verify (push) Successful in 5s
ci-cd / deploy (push) Skipped
2026-10-04 09:58:07 +08:00
Blizzard 46ac8f8a47 Respect mini program status bar and menu safe area
ci-cd / verify (push) Successful in 4s
ci-cd / deploy (push) Skipped
2026-10-04 09:53:07 +08:00
Blizzard 8a3036f0db Redesign mini program navigation and add back controls
ci-cd / verify (push) Successful in 4s
ci-cd / deploy (push) Skipped
2026-10-04 09:48:12 +08:00
Blizzard 87dfc4f335 Keep corgi artwork fully visible in mini program banners
ci-cd / verify (push) Successful in 4s
ci-cd / deploy (push) Skipped
2026-10-04 09:37:17 +08:00
Blizzard c72cf24f56 Complete core mini program flows
ci-cd / verify (push) Successful in 5s
ci-cd / deploy (push) Skipped
2026-10-04 09:26:58 +08:00
Blizzard 5649ffe0df Merge pull request 'Feat/dev' (#2) from feat/dev into main
ci-cd / verify (push) Successful in 6s
ci-cd / deploy (push) Successful in 2m22s
Reviewed-on: #2
2026-10-01 06:25:06 +00:00
Blizzard 5688db4d63 Serve embedded admin assets from root
ci-cd / deploy (push) Skipped
ci-cd / verify (push) Successful in 1m31s
2026-10-01 14:23:50 +08:00
Blizzard fd4f7708ba Point mini program API to production domain
ci-cd / verify (push) Successful in 5s
ci-cd / deploy (push) Skipped
2026-10-01 14:14:54 +08:00
Blizzard e27adfe64c Merge pull request 'Serve admin console at root path' (#1) from feat/dev into main
ci-cd / verify (push) Successful in 25s
ci-cd / deploy (push) Successful in 2m46s
Reviewed-on: #1
2026-10-01 06:14:43 +00:00
Blizzard 8de48cb44d Serve admin console at root path
ci-cd / verify (push) Successful in 1m48s
ci-cd / deploy (push) Skipped
2026-10-01 14:13:04 +08:00
160 changed files with 32494 additions and 1246 deletions
+5
View File
@@ -11,9 +11,14 @@ MINIO_SECRET_KEY=sundynix
MINIO_BUCKET=sundynix-pet MINIO_BUCKET=sundynix-pet
MINIO_USE_SSL=true MINIO_USE_SSL=true
JWT_EXPIRE_HOURS=2
JWT_REFRESH_HOURS=2
JWT_SECRET= JWT_SECRET=
WECHAT_APP_ID=wx52dfc635739a9c19 WECHAT_APP_ID=wx52dfc635739a9c19
WECHAT_APP_SECRET= WECHAT_APP_SECRET=
ADMIN_USERNAME=sundynix ADMIN_USERNAME=sundynix
ADMIN_PASSWORD=sundynix ADMIN_PASSWORD=sundynix
TAG=latest TAG=latest
# 高德 Web 服务 Key
AMAP_KEY=
+3
View File
@@ -5,6 +5,7 @@ services:
ports: ports:
- "4000:9090" - "4000:9090"
environment: environment:
PETS_AMAP_KEY: ${AMAP_KEY:-}
PETS_SERVER_PORT: "9090" PETS_SERVER_PORT: "9090"
PETS_SERVER_MODE: release PETS_SERVER_MODE: release
PETS_POSTGRES_HOST: ${POSTGRES_HOST:-192.168.100.127} PETS_POSTGRES_HOST: ${POSTGRES_HOST:-192.168.100.127}
@@ -19,6 +20,8 @@ services:
PETS_MINIO_BUCKET: ${MINIO_BUCKET:-sundynix-pet} PETS_MINIO_BUCKET: ${MINIO_BUCKET:-sundynix-pet}
PETS_MINIO_USE_SSL: ${MINIO_USE_SSL:-true} PETS_MINIO_USE_SSL: ${MINIO_USE_SSL:-true}
PETS_JWT_SECRET: ${JWT_SECRET:?} PETS_JWT_SECRET: ${JWT_SECRET:?}
PETS_JWT_EXPIRE_HOURS: ${JWT_EXPIRE_HOURS:-2}
PETS_JWT_REFRESH_HOURS: ${JWT_REFRESH_HOURS:-2}
PETS_WECHAT_APP_ID: ${WECHAT_APP_ID:?} PETS_WECHAT_APP_ID: ${WECHAT_APP_ID:?}
PETS_WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?} PETS_WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?}
PETS_AUTH_DEV_LOGIN: "false" PETS_AUTH_DEV_LOGIN: "false"
+19 -3
View File
@@ -1,6 +1,6 @@
# 肉垫计划 # 肉垫计划
核心功能:多宠物档案、日常照顾任务、多宠物遛狗与主动位置分享、约遛。后端用 Gin + GORM + PostgreSQL,管理端用 React + Vite + shadcn/ui + Tailwind,构建后通过 `go:embed` 随服务提供 `/admin/`。 核心功能:多宠物档案、日常照顾任务、多宠物遛狗与主动位置分享、约遛。后端用 Gin + GORM + PostgreSQL,管理端用 React + Vite + shadcn/ui + Tailwind,构建后通过 `go:embed` 随服务提供 `/`。
## 目录 ## 目录
@@ -15,12 +15,12 @@
## 本地启动 ## 本地启动
1. `cp configs/config.example.yaml configs/config.yaml`,设置 PostgreSQL、MinIO、JWT 和微信参数。配置文件不进入 Git。 1. `cp configs/config.example.yaml configs/config.yaml`,设置 PostgreSQL、MinIO、JWT 和微信参数。
2. `cd web/admin && npm ci && npm run build`。 2. `cd web/admin && npm ci && npm run build`。
3. 若数据库还不存在,在 `pets-be` 目录执行一次 `go run ./cmd/dbinit`。 3. 若数据库还不存在,在 `pets-be` 目录执行一次 `go run ./cmd/dbinit`。
4. 执行 `go run ./cmd/server`。 4. 执行 `go run ./cmd/server`。
默认业务接口 `http://localhost:9090/api`,管理端 `http://localhost:9090/admin/`。小程序生产 API 地址是 `https://pet.sundynix.cn/api`。 默认业务接口 `http://localhost:9090/api`,管理端 `http://localhost:9090/`。小程序生产 API 地址是 `https://pet.sundynix.cn/api`。
配置可用 `PETS_` 前缀的环境变量覆盖,例如 `PETS_POSTGRES_PASSWORD`。生产模式必须设置 `PETS_ADMIN_PASSWORD`,微信登录需要 App ID 和 App Secret。`auth.dev_login` 只能在 debug 模式开启。 配置可用 `PETS_` 前缀的环境变量覆盖,例如 `PETS_POSTGRES_PASSWORD`。生产模式必须设置 `PETS_ADMIN_PASSWORD`,微信登录需要 App ID 和 App Secret。`auth.dev_login` 只能在 debug 模式开启。
@@ -33,3 +33,19 @@ GORM 使用 `sundynix_` 表前缀、单数 snake_case 表名和字段名,例
## 部署 ## 部署
`../deploy/docker-compose.yml` 使用外部 PostgreSQL 和 MinIO;把 `../deploy/.env.example` 复制为 `.env` 后设置凭据。`pet.sundynix.cn` 用于业务 API;`s3.sundynix.cn` 用于文件上传和图片直链。URL 由 MinIO endpoint、bucket 和 object key 拼接,在上传时写入文件表,格式为 `https://s3.sundynix.cn/sundynix-pet/<object_key>`。`res.sundynix.cn/browser/...` 是 MinIO 控制台页面,不能作为图片直链。 `../deploy/docker-compose.yml` 使用外部 PostgreSQL 和 MinIO;把 `../deploy/.env.example` 复制为 `.env` 后设置凭据。`pet.sundynix.cn` 用于业务 API;`s3.sundynix.cn` 用于文件上传和图片直链。URL 由 MinIO endpoint、bucket 和 object key 拼接,在上传时写入文件表,格式为 `https://s3.sundynix.cn/sundynix-pet/<object_key>`。`res.sundynix.cn/browser/...` 是 MinIO 控制台页面,不能作为图片直链。
## 管理端路由与犬种百科
菜单使用独立路由:`/admin/overview`、`/admin/users`、`/admin/pets`、`/admin/breeds`、`/admin/walks`、`/admin/meetups`、`/admin/tasks`、`/admin/files`;登录页 `/admin/login`。根路径自动进入工作台或登录页;Go 的 SPA 回退支持刷新和直达链接。业务接口仍在 `/api`,无需额外 API 前缀。
犬种目录自动导入 350 项(CKU 去重 347 项,另补充三种实用分类),按名称拼音 A–Z 排序。选品种使用 `breed_id`,手动填写保留 `breed` 名称并清空 `breed_id`。后台提供新增、编辑、启用、停用、搜索及每页十条分页。停用品种不影响已有宠物档案。
CKU 当前返回完整详情的 88 个犬种已整理基本资料、简短改写介绍和来源链接;另三项补充分类提供原创介绍。资料缺失时显示待补充,后台可以编辑百科字段。图片与长篇犬种标准未转载。
## 登录刷新
管理端和小程序的访问 token、refresh token 都为 **2 小时**。请求前发现不足一分钟即刷新;401 也会尝试刷新并仅重试一次。刷新凭证每次轮换,数据库只存 SHA-256 摘要;两个角色的凭证不能混用,停用账号、过期及退出会话都不能继续刷新。后台退出会撤销会话和其访问 token。
刷新成功后,两种凭证重新计时两小时;超过两小时未使用则重新登录。小程序自动重新微信登录,管理端回到登录页并保留原目标路由。并发接口共用刷新请求,支持 Web Locks 的浏览器还会跨标签页串行刷新;网络失败保留凭证以便重试。
接口:`POST /api/auth/refresh`、`POST /api/admin/refresh`,请求体为 `{ "refresh_token": "..." }`。登录与刷新响应包含 `token`、`refresh_token`、`expires_in: 7200`、`expires_at`、`refresh_expires_at`(Unix 秒)。旧七天 token 会要求重新登录。时长按业务约束固定两小时,旧环境变量不会把期限拉长。
+36
View File
@@ -0,0 +1,36 @@
// seedbreeds imports the embedded CKU breed catalog without overwriting administrator edits.
package main
import (
"github.com/sundynix/pets-be/internal/breeds"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/database"
"github.com/sundynix/pets-be/internal/model"
"log"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
db, err := database.Open(cfg.Postgres, true)
if err != nil {
log.Fatal(err)
}
// Only the schema needed by these features, leaving unrelated application records intact.
if err := db.AutoMigrate(&model.Breed{}, &model.BreedReference{}, &model.Pet{}, &model.RefreshSession{}); err != nil {
log.Fatal(err)
}
added, err := breeds.Seed(db)
if err != nil {
log.Fatal(err)
}
var total int64
if err := db.Model(&model.Breed{}).Count(&total).Error; err != nil {
log.Fatal(err)
}
var sources int64
db.Model(&model.BreedReference{}).Count(&sources)
log.Printf("breed catalog imported: added=%d total=%d references=%d", added, total, sources)
}
+6 -1
View File
@@ -19,7 +19,8 @@ minio:
jwt: jwt:
secret: "" # 用 PETS_JWT_SECRET 注入 secret: "" # 用 PETS_JWT_SECRET 注入
expire_hours: 168 expire_hours: 2
refresh_hours: 2
wechat: wechat:
app_id: "" app_id: ""
@@ -31,3 +32,7 @@ auth:
admin: admin:
username: sundynix username: sundynix
password: sundynix password: sundynix
# 高德 Web 服务:首页所在城市与实况天气
amap:
key: ""
+6
View File
@@ -18,6 +18,8 @@ minio:
use_ssl: true use_ssl: true
jwt: jwt:
expire_hours: 2
refresh_hours: 2
secret: kFdKDNCXpyJO2W+htdxobhHNzUIYuwcaZV94aJbIGHQ= # 生产用 PETS_JWT_SECRET 覆盖 secret: kFdKDNCXpyJO2W+htdxobhHNzUIYuwcaZV94aJbIGHQ= # 生产用 PETS_JWT_SECRET 覆盖
wechat: wechat:
@@ -30,3 +32,7 @@ auth:
admin: admin:
username: sundynix username: sundynix
password: sundynix password: sundynix
# 高德 Web 服务:首页所在城市与实况天气
amap:
key: "c6e0887a6cd0aeaf8f1fd3dd5704b8c3"
+1
View File
@@ -7,6 +7,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/minio/minio-go/v7 v7.2.1 github.com/minio/minio-go/v7 v7.2.1
github.com/mozillazg/go-pinyin v0.21.0
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.53.0 golang.org/x/crypto v0.53.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
+2
View File
@@ -86,6 +86,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mozillazg/go-pinyin v0.21.0 h1:Wo8/NT45z7P3er/9YSLHA3/kjZzbLz5hR7i+jGeIGao=
github.com/mozillazg/go-pinyin v0.21.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
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
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

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

Before

Width:  |  Height:  |  Size: 4.9 KiB

+5 -5
View File
@@ -1,12 +1,12 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/brand-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title> <title>肉垫计划 · 管理后台</title>
<script type="module" crossorigin src="/admin/assets/index-DmUsbLpt.js"></script> <script type="module" crossorigin src="/assets/index-N2FSM0pD.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BQA5NViF.css"> <link rel="stylesheet" crossorigin href="/assets/index-CkSlqiFn.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+14 -6
View File
@@ -12,7 +12,7 @@ import (
//go:embed all:dist //go:embed all:dist
var distFS embed.FS var distFS embed.FS
// Register 把内嵌的后台 SPA 挂到 /admin 下,index.html 做 SPA fallback // Register 把内嵌的后台 SPA 挂到根路径,保留 /api 给业务接口。
func Register(r *gin.Engine) { func Register(r *gin.Engine) {
sub, err := fs.Sub(distFS, "dist") sub, err := fs.Sub(distFS, "dist")
if err != nil { if err != nil {
@@ -21,9 +21,7 @@ func Register(r *gin.Engine) {
fileServer := http.FileServer(http.FS(sub)) fileServer := http.FileServer(http.FS(sub))
handler := func(c *gin.Context) { handler := func(c *gin.Context) {
// 去掉前缀 /admin,交给内嵌文件服务器 reqPath := strings.TrimPrefix(c.Request.URL.Path, "/")
reqPath := strings.TrimPrefix(c.Request.URL.Path, "/admin")
reqPath = strings.TrimPrefix(reqPath, "/")
// 若请求的是真实存在的静态资源,直出;否则回退 index.html(前端路由) // 若请求的是真实存在的静态资源,直出;否则回退 index.html(前端路由)
if reqPath != "" { if reqPath != "" {
@@ -33,6 +31,10 @@ func Register(r *gin.Engine) {
fileServer.ServeHTTP(c.Writer, c.Request) fileServer.ServeHTTP(c.Writer, c.Request)
return return
} }
if strings.HasPrefix(reqPath, "assets/") {
c.Status(http.StatusNotFound)
return
}
} }
// SPA fallback // SPA fallback
data, err := fs.ReadFile(sub, "index.html") data, err := fs.ReadFile(sub, "index.html")
@@ -43,6 +45,12 @@ func Register(r *gin.Engine) {
c.Data(http.StatusOK, "text/html; charset=utf-8", data) c.Data(http.StatusOK, "text/html; charset=utf-8", data)
} }
r.GET("/admin", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/") }) r.GET("/", handler)
r.GET("/admin/*filepath", handler) r.NoRoute(func(c *gin.Context) {
if (c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead) || strings.HasPrefix(c.Request.URL.Path, "/api/") || c.Request.URL.Path == "/api" {
c.Status(http.StatusNotFound)
return
}
handler(c)
})
} }
+56
View File
@@ -0,0 +1,56 @@
package admin
import (
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/httpapi"
)
func TestAdminAtRootAndAPIRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
httpapi.New(nil, nil, &config.Config{}).Register(r)
Register(r)
assets, err := fs.ReadDir(distFS, "dist/assets")
if err != nil {
t.Fatal(err)
}
var script string
for _, asset := range assets {
if strings.HasSuffix(asset.Name(), ".js") {
script = "/assets/" + asset.Name()
break
}
}
if script == "" {
t.Fatal("admin bundle has no JavaScript asset")
}
for _, tc := range []struct {
path string
status int
body string
}{
{path: "/", status: http.StatusOK, body: "<div id=\"root\"></div>"},
{path: "/admin/breeds", status: http.StatusOK, body: "<div id=\"root\"></div>"},
{path: "/admin/walks", status: http.StatusOK, body: "<div id=\"root\"></div>"},
{path: "/admin/files", status: http.StatusOK, body: "<div id=\"root\"></div>"},
{path: script, 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},
} {
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, tc.path, nil))
if recorder.Code != tc.status || (tc.body != "" && !strings.Contains(recorder.Body.String(), tc.body)) {
t.Errorf("GET %s: got %d %q, want %d containing %q", tc.path, recorder.Code, recorder.Body.String(), tc.status, tc.body)
}
}
}
+33
View File
@@ -0,0 +1,33 @@
# 犬种目录与百科
## 数据结构
- `sundynix_breed`:UUID 主键、中文名、英文名、别名、拼音与首字母、启用状态;起源地、FCI 编号和分组、肩高、体重、寿命、介绍、性格、运动、毛发护理、相处笔记、指数。`field_sources` 使用 PostgreSQL JSONB 保存展示字段的来源机构。
- `sundynix_breed_reference`:UUID 主键,`breed_id` 关联犬种;机构、机构内编号、原始页面链接、核对日期、事实字段(JSONB)。每个犬种与机构唯一。参考尺寸和指数按机构分别保存,不合并为统一标准。
- `sundynix_pet.breed_id`:可空。选目录品种时关联 UUID;手动填写时仅保留 `breed` 文本。
- `editorial_locked`:管理员保存资料后锁定展示内容,重复导入不会覆盖人工修改或清空的字段。来源资料只读,导入仅在其事实内容发生变化时更新。
## 展示
管理端 `/admin/breeds` 支持每页 10 条分页、名称/别名/拼音搜索、A–Z 筛选、启停与新增编辑。编辑窗切换“展示资料 / 官方来源”,可以对照各机构标准与评分。展示字段可手动完善,缺失字段留空。
小程序“知识”提供搜索、中文拼音 A–Z 筛选和详情;详情分为基本信息、性格指数、散步与护理、资料出处,字段注明来源,引用链接可复制。所有文案为简短中文整理,不保留来源长篇文章或图片。品种选择组件复用于新增/编辑档案,也允许手填。
## 采集与导入
2026-10-05:CKU 公开列表 348 项,按中文名称去重后 347 项。补充中华田园犬、混种犬、贵宾犬三项实用分类,合计 350 项。
- CKU:https://www.cku.org.cn/oa-web/api/v1/open/getFciDogAll
88 个详情接口有完整资料,整理基本事实与简短性格标签。
- FCI:https://www.fci.be/en/nomenclature/
使用编号准确关联目录,保存官方名称、原产地和分组;变种保留自己的中文档案,来源标明基础标准范围。分组页面归属国字段用于起源地参考,尺寸不从目录推测。
- AKC:https://www.akc.org/dog-breeds/
只使用准确英文名或已核对的等价名称匹配,提取结构化尺寸、寿命和指数。尺寸展示统一换算 cm/kg,原始单位保留在来源资料。变种不套用父犬种的 AKC 展示数据;父犬种参考资料明确标注标准范围。
- 英国皇家犬业俱乐部:https://www.royalkennelclub.com/breeds-a-z
本机直连超时,先从官方页面核对 8 个犬种的运动、护理和寿命信息,记录于 `rkc_facts.json`。
`data.json` 保存中文目录与已有摘要;`enrichment.json` 保存多来源的事实快照和展示补充。`rkc_facts.json` 是人工核对的事实记录。
从仓库根目录执行 `python3 pets-be/scripts/collect_breed_facts.py` 可重新生成补充数据。采集脚本仅用 Python 标准库,缓存置于 `/private/tmp/pawprint-breed-sources`;机构连接失败不伪造数据。
服务器迁移时自动导入;也可在 `pets-be` 目录执行 `go run ./cmd/seedbreeds`。内容无变化时跳过资料更新。导入不会恢复管理员改名或停用的品种,人工编辑后的展示内容不会被刷新覆盖。
+102
View File
@@ -0,0 +1,102 @@
package breeds
import (
_ "embed"
"encoding/json"
"fmt"
"strings"
"unicode"
"github.com/mozillazg/go-pinyin"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
//go:embed data.json
var data []byte
// SortFields provides both full pinyin and initials, retaining Latin breed names.
func SortFields(name string) (string, string, string) {
args := pinyin.NewArgs()
args.Fallback = func(r rune, _ pinyin.Args) []string {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return []string{strings.ToLower(string(r))}
}
return nil
}
words := pinyin.LazyPinyin(name, args)
// Breed-name phrases with common alternative pronunciations.
if strings.HasPrefix(name, "重庆") && len(words) > 0 {
words[0] = "chong"
}
if strings.HasPrefix(name, "长毛") && len(words) > 0 {
words[0] = "chang"
}
full := strings.Join(words, "")
var initials strings.Builder
for _, word := range words {
if word != "" {
initials.WriteByte(word[0])
}
}
initial := "#"
if len(full) > 0 && full[0] >= 'a' && full[0] <= 'z' {
initial = strings.ToUpper(full[:1])
}
return full, initials.String(), initial
}
func Defaults() ([]model.Breed, error) {
var rows []struct {
Origin string `json:"origin"`
BreedGroup string `json:"breed_group"`
FCINumber string `json:"fci_number"`
EnglishName string `json:"english_name"`
Height string `json:"height"`
Weight string `json:"weight"`
Summary string `json:"summary"`
Temperament string `json:"temperament"`
Traits string `json:"traits"`
CareNotes string `json:"care_notes"`
EncyclopediaUpdatedAt string `json:"encyclopedia_updated_at"`
Name string `json:"name"`
Aliases string `json:"aliases"`
Source string `json:"source"`
SourceKey string `json:"source_key"`
SourceURL string `json:"source_url"`
}
if err := json.Unmarshal(data, &rows); err != nil {
return nil, err
}
result := make([]model.Breed, 0, len(rows))
for _, row := range rows {
full, short, initial := SortFields(row.Name)
key := row.SourceKey
result = append(result, model.Breed{Origin: row.Origin, BreedGroup: row.BreedGroup, FCINumber: row.FCINumber, EnglishName: row.EnglishName, Height: row.Height, Weight: row.Weight, Summary: row.Summary, Temperament: row.Temperament, Traits: row.Traits, CareNotes: row.CareNotes, EncyclopediaUpdatedAt: row.EncyclopediaUpdatedAt, Name: row.Name, Aliases: row.Aliases, Pinyin: full, PinyinInitials: short, Initial: initial, Enabled: true, Source: row.Source, SourceKey: &key, SourceURL: row.SourceURL})
}
return result, nil
}
// Existing entries (including edits and disabled breeds) are never overwritten.
func Seed(db *gorm.DB) (int64, error) {
rows, err := Defaults()
if err != nil {
return 0, fmt.Errorf("load breeds: %w", err)
}
result := db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(rows, 100)
if result.Error != nil {
return result.RowsAffected, result.Error
}
// Backfill newly introduced encyclopedia fields exactly once on untouched seed rows.
// Future starts never overwrite administrator changes.
for _, row := range rows {
if row.Summary == "" {
continue
}
if err := db.Model(&model.Breed{}).Where("source_key = ? AND name = ? AND (encyclopedia_updated_at IS NULL OR encyclopedia_updated_at = '') AND updated_at = created_at", row.SourceKey, row.Name).Updates(map[string]any{"origin": row.Origin, "breed_group": row.BreedGroup, "fci_number": row.FCINumber, "english_name": row.EnglishName, "height": row.Height, "weight": row.Weight, "summary": row.Summary, "temperament": row.Temperament, "traits": row.Traits, "care_notes": row.CareNotes, "encyclopedia_updated_at": row.EncyclopediaUpdatedAt}).Error; err != nil {
return result.RowsAffected, err
}
}
return result.RowsAffected, enrich(db)
}
+59
View File
@@ -0,0 +1,59 @@
package breeds
import (
"strings"
"testing"
)
func TestCatalogAndPinyin(t *testing.T) {
rows, err := Defaults()
if err != nil {
t.Fatal(err)
}
if len(rows) != 350 {
t.Fatalf("catalog count %d", len(rows))
}
names, sources := map[string]bool{}, map[string]bool{}
for _, row := range rows {
if names[row.Name] || row.SourceKey == nil || sources[*row.SourceKey] || row.Pinyin == "" || row.Initial == "" {
t.Fatalf("invalid catalog row: %s", row.Name)
}
names[row.Name] = true
sources[*row.SourceKey] = true
}
for name, want := range map[string]string{"柯基": "K", "重庆犬": "C", "长毛吉娃娃": "C", "中华田园犬": "Z", "Border Collie": "B"} {
full, short, initial := SortFields(name)
if initial != want || full == "" || short == "" {
t.Fatalf("%s: %s/%s/%s", name, full, short, initial)
}
}
}
func TestOfficialSources(t *testing.T) {
rows, err := Enrichments()
if err != nil {
t.Fatal(err)
}
counts := map[string]int{}
for _, row := range rows {
seen := map[string]bool{}
for _, ref := range row.References {
if seen[ref.Provider] || len(ref.Facts) == 0 || ref.URL == "" || ref.RetrievedAt == "" {
t.Fatalf("invalid reference for %s", row.Name)
}
seen[ref.Provider] = true
counts[ref.Provider]++
if ref.Provider == "fci" && "cku:"+ref.ExternalID != strings.TrimRight(row.SourceKey, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
t.Fatalf("wrong FCI mapping %s/%s", row.SourceKey, ref.ExternalID)
}
}
for key, provider := range row.FieldSources {
if provider != "editorial" && !seen[provider] {
t.Fatalf("%s %s has missing source %s", row.Name, key, provider)
}
}
}
if len(rows) != 350 || counts["fci"] != 347 || counts["akc"] < 160 || counts["rkc"] != 8 {
t.Fatalf("incomplete import: %v", counts)
}
}
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
package breeds
import (
_ "embed"
"encoding/json"
"fmt"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"reflect"
)
//go:embed enrichment.json
var enrichmentData []byte
type Enrichment struct {
SourceKey string `json:"source_key"`
Name string `json:"name"`
Fields map[string]string `json:"fields"`
FieldSources map[string]string `json:"field_sources"`
References []model.BreedReference `json:"references"`
}
func Enrichments() ([]Enrichment, error) {
var rows []Enrichment
err := json.Unmarshal(enrichmentData, &rows)
return rows, err
}
// Reference facts remain available even when an editor locks the display content.
func enrich(db *gorm.DB) error {
rows, err := Enrichments()
if err != nil {
return fmt.Errorf("load breed sources: %w", err)
}
defaults, err := Defaults()
if err != nil {
return err
}
baseline := map[string]model.Breed{}
for _, breed := range defaults {
baseline[*breed.SourceKey] = breed
}
for _, item := range rows {
var breed model.Breed
if err := db.Where("source_key = ?", item.SourceKey).First(&breed).Error; err != nil {
return err
}
for _, source := range item.References {
source.BreedID = breed.ID
// Source snapshots are read-only in admin; update only when factual metadata changes.
if err := db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "breed_id"}, {Name: "provider"}}, DoUpdates: clause.AssignmentColumns([]string{"external_id", "url", "retrieved_at", "facts"}), Where: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: "sundynix_breed_reference.facts IS DISTINCT FROM EXCLUDED.facts OR sundynix_breed_reference.url IS DISTINCT FROM EXCLUDED.url OR sundynix_breed_reference.retrieved_at IS DISTINCT FROM EXCLUDED.retrieved_at"}}}}).Create(&source).Error; err != nil {
return err
}
}
if breed.EditorialLocked || breed.Name != item.Name {
continue
}
original := baseline[item.SourceKey]
updates := map[string]any{"english_name": original.EnglishName, "origin": original.Origin, "breed_group": original.BreedGroup, "fci_number": original.FCINumber, "height": original.Height, "weight": original.Weight, "summary": original.Summary, "temperament": original.Temperament, "traits": original.Traits, "care_notes": original.CareNotes, "life_span": "", "exercise": "", "grooming": ""}
for field, value := range item.Fields {
switch field {
case "english_name", "origin", "breed_group", "fci_number", "height", "weight", "life_span", "exercise", "grooming", "temperament", "traits", "summary", "care_notes":
updates[field] = value
default:
return fmt.Errorf("unsupported breed field: %s", field)
}
}
origins, err := json.Marshal(item.FieldSources)
if err != nil {
return err
}
updates["field_sources"] = string(origins)
latest := original.EncyclopediaUpdatedAt
for _, ref := range item.References {
if ref.RetrievedAt > latest {
latest = ref.RetrievedAt
}
}
updates["encyclopedia_updated_at"] = latest
// Skip unchanged columns so ordinary starts do not rewrite every breed.
current := map[string]string{"english_name": breed.EnglishName, "origin": breed.Origin, "breed_group": breed.BreedGroup, "fci_number": breed.FCINumber, "height": breed.Height, "weight": breed.Weight, "life_span": breed.LifeSpan, "exercise": breed.Exercise, "grooming": breed.Grooming, "temperament": breed.Temperament, "traits": breed.Traits, "summary": breed.Summary, "care_notes": breed.CareNotes, "encyclopedia_updated_at": breed.EncyclopediaUpdatedAt}
for key, value := range updates {
if key != "field_sources" && current[key] == value {
delete(updates, key)
}
}
if reflect.DeepEqual(breed.FieldSources, item.FieldSources) {
delete(updates, "field_sources")
}
if len(updates) == 0 {
continue
}
// UpdateColumns avoids advancing UpdatedAt on each application start.
if err := db.Model(&breed).Where("editorial_locked = false AND name = ?", item.Name).UpdateColumns(updates).Error; err != nil {
return err
}
}
return nil
}
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
[
{
"source_key": "cku:39",
"external_id": "welsh-corgi-pembroke",
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/pastoral/welsh-corgi-pembroke/",
"facts": {
"exercise": "每日运动量参考:不超过 1 小时",
"grooming": "每周梳理超过一次",
"life_span": "通常超过 12 年",
"size": "小型",
"coat_length": "短毛",
"sheds": "会掉毛"
},
"retrieved_at": "2026-10-05"
},
{
"source_key": "cku:38",
"external_id": "welsh-corgi-cardigan",
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/pastoral/welsh-corgi-cardigan/",
"facts": {
"exercise": "每日运动量参考:不超过 1 小时",
"grooming": "每周梳理超过一次",
"life_span": "通常超过 12 年",
"size": "小型",
"coat_length": "短毛",
"sheds": "会掉毛"
},
"retrieved_at": "2026-10-05"
},
{
"source_key": "cku:86",
"external_id": "yorkshire-terrier",
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/toy/yorkshire-terrier/",
"facts": {
"exercise": "每日运动量参考:不超过 30 分钟",
"grooming": "每天梳理",
"life_span": "通常超过 12 年",
"size": "小型",
"coat_length": "长毛",
"sheds": "掉毛较少"
},
"retrieved_at": "2026-10-05"
},
{
"source_key": "cku:162",
"external_id": "whippet",
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/hound/whippet/",
"facts": {
"exercise": "每日运动量参考:不超过 1 小时",
"grooming": "每周梳理一次",
"life_span": "通常超过 12 年",
"size": "小型",
"coat_length": "短毛",
"sheds": "会掉毛"
},
"retrieved_at": "2026-10-05"
},
{
"source_key": "cku:209",
"external_id": "tibetan-terrier",
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/utility/tibetan-terrier/",
"facts": {
"exercise": "每日运动量参考:不超过 1 小时",
"grooming": "每天梳理",
"life_span": "通常超过 12 年",
"size": "中型",
"coat_length": "长毛",
"sheds": "掉毛较少"
},
"retrieved_at": "2026-10-05"
},
{
"source_key": "cku:231",
"external_id": "tibetan-spaniel",
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/utility/tibetan-spaniel/",
"facts": {
"exercise": "每日运动量参考:不超过 1 小时",
"grooming": "每周梳理超过一次",
"life_span": "通常超过 12 年",
"size": "小型",
"coat_length": "中等毛长",
"sheds": "会掉毛"
},
"retrieved_at": "2026-10-05"
},
{
"source_key": "cku:85",
"external_id": "west-highland-white-terrier",
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/terrier/west-highland-white-terrier/",
"facts": {
"exercise": "每日运动量参考:不超过 1 小时",
"grooming": "每周梳理超过一次",
"life_span": "通常超过 12 年",
"size": "小型",
"coat_length": "中等毛长",
"sheds": "会掉毛"
},
"retrieved_at": "2026-10-05"
},
{
"source_key": "cku:347",
"external_id": "white-swiss-shepherd-dog-imp",
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/pastoral/white-swiss-shepherd-dog-imp/",
"facts": {
"exercise": "每日运动量参考:超过 2 小时",
"grooming": "每周梳理超过一次",
"life_span": "通常超过 10 年",
"size": "大型",
"coat_length": "中长毛",
"sheds": "会掉毛"
},
"retrieved_at": "2026-10-05"
}
]
+12 -1
View File
@@ -9,7 +9,12 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
) )
type AMapConfig struct {
Key string `mapstructure:"key"`
}
type Config struct { type Config struct {
AMap AMapConfig `mapstructure:"amap"`
Server ServerConfig `mapstructure:"server"` Server ServerConfig `mapstructure:"server"`
Postgres PostgresConfig `mapstructure:"postgres"` Postgres PostgresConfig `mapstructure:"postgres"`
MinIO MinIOConfig `mapstructure:"minio"` MinIO MinIOConfig `mapstructure:"minio"`
@@ -49,6 +54,7 @@ type MinIOConfig struct {
type JWTConfig struct { type JWTConfig struct {
Secret string `mapstructure:"secret"` Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"` ExpireHours int `mapstructure:"expire_hours"`
RefreshHours int `mapstructure:"refresh_hours"`
} }
type WeChatConfig struct { type WeChatConfig struct {
@@ -67,6 +73,7 @@ type AdminConfig struct {
func Load() (*Config, error) { func Load() (*Config, error) {
v := viper.New() v := viper.New()
v.SetDefault("amap.key", "")
v.SetDefault("server.port", 9090) v.SetDefault("server.port", 9090)
v.SetDefault("server.mode", "debug") v.SetDefault("server.mode", "debug")
v.SetDefault("postgres.host", "192.168.100.127") v.SetDefault("postgres.host", "192.168.100.127")
@@ -81,7 +88,8 @@ func Load() (*Config, error) {
v.SetDefault("minio.bucket", "sundynix-pet") v.SetDefault("minio.bucket", "sundynix-pet")
v.SetDefault("minio.use_ssl", true) v.SetDefault("minio.use_ssl", true)
v.SetDefault("jwt.secret", "") v.SetDefault("jwt.secret", "")
v.SetDefault("jwt.expire_hours", 168) v.SetDefault("jwt.expire_hours", 2)
v.SetDefault("jwt.refresh_hours", 2)
v.SetDefault("wechat.app_id", "") v.SetDefault("wechat.app_id", "")
v.SetDefault("wechat.app_secret", "") v.SetDefault("wechat.app_secret", "")
v.SetDefault("auth.dev_login", false) v.SetDefault("auth.dev_login", false)
@@ -114,5 +122,8 @@ func Load() (*Config, error) {
if cfg.Server.Mode == "release" && cfg.Auth.DevLogin { if cfg.Server.Mode == "release" && cfg.Auth.DevLogin {
return nil, errors.New("dev login must be disabled in release mode") return nil, errors.New("dev login must be disabled in release mode")
} }
// Access tokens have the same fixed two-hour lifetime for both clients.
cfg.JWT.ExpireHours = 2
cfg.JWT.RefreshHours = 2
return &cfg, nil return &cfg, nil
} }
+4
View File
@@ -9,6 +9,7 @@ import (
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
"gorm.io/gorm/schema" "gorm.io/gorm/schema"
"github.com/sundynix/pets-be/internal/breeds"
"github.com/sundynix/pets-be/internal/config" "github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/model" "github.com/sundynix/pets-be/internal/model"
) )
@@ -39,5 +40,8 @@ func Migrate(db *gorm.DB) error {
if err := db.AutoMigrate(model.All()...); err != nil { if err := db.AutoMigrate(model.All()...); err != nil {
return err return err
} }
if _, err := breeds.Seed(db); err != nil {
return err
}
return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_sundynix_walk_one_active_per_user ON sundynix_walk (user_id) WHERE status IN ('active', 'paused')").Error return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_sundynix_walk_one_active_per_user ON sundynix_walk (user_id) WHERE status IN ('active', 'paused')").Error
} }
+146
View File
@@ -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
}
+86
View File
@@ -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")
}
}
+402 -32
View File
@@ -1,98 +1,468 @@
package httpapi package httpapi
import ( import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/model" "github.com/sundynix/pets-be/internal/model"
"net/http" "gorm.io/gorm"
) )
type adminPage struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
Items any `json:"items"`
}
func pageParams(c *gin.Context) (adminPage, bool) {
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
size, sizeErr := strconv.Atoi(c.DefaultQuery("page_size", "10"))
if err != nil || sizeErr != nil || page < 1 || page > 1000000 || size < 1 || size > 100 || len([]rune(c.Query("q"))) > 128 {
fail(c, http.StatusBadRequest, "分页或搜索参数无效")
return adminPage{}, false
}
return adminPage{Page: page, PageSize: size}, true
}
func adminRows[T any](c *gin.Context, query *gorm.DB, order string) ([]T, adminPage, bool) {
page, valid := pageParams(c)
if !valid {
return nil, page, false
}
if err := query.Count(&page.Total).Error; err != nil {
fail(c, 500, "读取记录数量失败")
return nil, page, false
}
rows := make([]T, 0)
if err := query.Order(order).Offset((page.Page - 1) * page.PageSize).Limit(page.PageSize).Find(&rows).Error; err != nil {
fail(c, 500, "读取记录失败")
return nil, page, false
}
return rows, page, true
}
func search(query *gorm.DB, c *gin.Context, columns ...string) *gorm.DB {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
return query
}
// Only allow literal substring search. Columns are constants supplied by handlers.
value := "%" + strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(q) + "%"
clauses := make([]string, 0, len(columns))
args := make([]any, 0, len(columns))
for _, column := range columns {
clauses = append(clauses, column+" ILIKE ?")
args = append(args, value)
}
return query.Where("("+strings.Join(clauses, " OR ")+")", args...)
}
func filterStatus(c *gin.Context, query *gorm.DB, column string, allowed ...string) (*gorm.DB, bool) {
status := c.Query("status")
if status == "" {
return query, true
}
for _, value := range allowed {
if status == value {
return query.Where(column+" = ?", status), true
}
}
fail(c, 400, "筛选状态无效")
return query, false
}
func (a *API) userNames(ids []string) (map[string]string, error) {
names := map[string]string{}
if len(ids) == 0 {
return names, nil
}
var users []model.User
err := a.db.Select("id", "nickname").Where("id IN ?", ids).Find(&users).Error
for _, user := range users {
names[user.ID] = user.Nickname
}
return names, err
}
func (a *API) adminStats(c *gin.Context) { func (a *API) adminStats(c *gin.Context) {
counts := gin.H{} counts := gin.H{}
for _, pair := range []struct { for _, pair := range []struct {
name string name string
value any value any
}{{"users", &model.User{}}, {"pets", &model.Pet{}}, {"walks", &model.Walk{}}, {"meetups", &model.Meetup{}}} { }{{"users", &model.User{}}, {"pets", &model.Pet{}}, {"walks", &model.Walk{}}, {"meetups", &model.Meetup{}}, {"tasks", &model.CareTask{}}, {"files", &model.File{}}} {
var n int64 var n int64
a.db.Model(pair.value).Count(&n) if err := a.db.Model(pair.value).Count(&n).Error; err != nil {
fail(c, 500, "读取统计失败")
return
}
counts[pair.name] = n counts[pair.name] = n
} }
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for _, pair := range []struct {
name string
query *gorm.DB
}{
{"today_walks", a.db.Model(&model.Walk{}).Where("started_at >= ?", today)},
{"active_walks", a.db.Model(&model.Walk{}).Where("status IN ?", []string{"active", "paused"})},
{"upcoming_meetups", a.db.Model(&model.Meetup{}).Where("status = ? AND starts_at > ?", "open", now)},
{"today_completions", a.db.Model(&model.TaskCompletion{}).Where("date = ?", today)},
} {
var n int64
if pair.query.Count(&n).Error != nil {
fail(c, 500, "读取统计失败")
return
}
counts[pair.name] = n
}
var metrics struct {
Distance float64
Duration int64
Storage int64
}
if a.db.Model(&model.Walk{}).Select("COALESCE(SUM(distance_meters),0) AS distance, COALESCE(SUM(duration_seconds),0) AS duration").Scan(&metrics).Error != nil || a.db.Model(&model.File{}).Select("COALESCE(SUM(size),0) AS storage").Scan(&metrics).Error != nil {
fail(c, 500, "读取统计失败")
return
}
counts["distance_meters"] = metrics.Distance
counts["duration_seconds"] = metrics.Duration
counts["storage_bytes"] = metrics.Storage
type dayCount struct {
Date string `json:"date"`
Count int64 `json:"count"`
}
var grouped []dayCount
since := today.AddDate(0, 0, -6)
if a.db.Model(&model.Walk{}).Select("to_char(started_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, COUNT(*) AS count").Where("started_at >= ?", since).Group("date").Scan(&grouped).Error != nil {
fail(c, 500, "读取趋势失败")
return
}
daily := make([]dayCount, 0, 7)
for day := since; !day.After(today); day = day.AddDate(0, 0, 1) {
entry := dayCount{Date: day.Format("2006-01-02")}
for _, row := range grouped {
if row.Date == entry.Date {
entry.Count = row.Count
}
}
daily = append(daily, entry)
}
counts["daily_walks"] = daily
ok(c, counts) ok(c, counts)
} }
func (a *API) adminUsers(c *gin.Context) { func (a *API) adminUsers(c *gin.Context) {
var rows []model.User query := search(a.db.Model(&model.User{}), c, "nickname", "id::text")
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil { switch c.Query("status") {
fail(c, 500, "读取用户失败") case "":
case "enabled":
query = query.Where("disabled = false")
case "disabled":
query = query.Where("disabled = true")
default:
fail(c, 400, "筛选状态无效")
return return
} }
rows, page, valid := adminRows[model.User](c, query, "created_at DESC, id DESC")
if !valid {
return
}
ids := []string{}
for _, row := range rows {
ids = append(ids, row.ID)
}
var totals []struct {
UserID string
Count int64
}
if len(ids) > 0 && a.db.Model(&model.Pet{}).Select("user_id, COUNT(*) AS count").Where("user_id IN ?", ids).Group("user_id").Scan(&totals).Error != nil {
fail(c, 500, "读取宠物数量失败")
return
}
counts := map[string]int64{}
for _, row := range totals {
counts[row.UserID] = row.Count
}
items := make([]gin.H, 0, len(rows)) items := make([]gin.H, 0, len(rows))
for _, u := range rows { for _, u := range rows {
v := a.userView(u) view := a.userView(u)
v["disabled"] = u.Disabled view["disabled"] = u.Disabled
items = append(items, v) view["pet_count"] = counts[u.ID]
items = append(items, view)
}
page.Items = items
ok(c, page)
}
func (a *API) adminUserPets(c *gin.Context) {
id := c.Param("id")
if !validID(id) {
fail(c, 400, "用户 ID 无效")
return
}
var user model.User
if err := a.db.First(&user, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
fail(c, 404, "用户不存在")
} else {
fail(c, 500, "读取用户失败")
}
return
}
var rows []model.Pet
if a.db.Where("user_id = ?", id).Order("archived ASC, created_at DESC, id DESC").Find(&rows).Error != nil {
fail(c, 500, "读取宠物失败")
return
}
items := make([]gin.H, 0, len(rows))
for _, pet := range rows {
view := a.petView(pet)
view["archived"] = pet.Archived
view["user_id"] = id
view["user_name"] = user.Nickname
items = append(items, view)
} }
ok(c, items) ok(c, items)
} }
func (a *API) adminPets(c *gin.Context) { func (a *API) adminPets(c *gin.Context) {
var rows []model.Pet query := search(a.db.Model(&model.Pet{}), c, "name", "breed", "id::text", "user_id::text")
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil { switch c.Query("status") {
fail(c, 500, "读取宠物失败") case "":
case "active":
query = query.Where("archived = false")
case "archived":
query = query.Where("archived = true")
default:
fail(c, 400, "筛选状态无效")
return
}
rows, page, valid := adminRows[model.Pet](c, query, "created_at DESC, id DESC")
if !valid {
return
}
ids := []string{}
for _, p := range rows {
ids = append(ids, p.UserID)
}
names, err := a.userNames(ids)
if err != nil {
fail(c, 500, "读取主人信息失败")
return return
} }
items := make([]gin.H, 0, len(rows)) items := make([]gin.H, 0, len(rows))
for _, p := range rows { for _, p := range rows {
v := a.petView(p) v := a.petView(p)
v["user_id"] = p.UserID v["user_id"] = p.UserID
v["user_name"] = names[p.UserID]
v["archived"] = p.Archived v["archived"] = p.Archived
items = append(items, v) items = append(items, v)
} }
ok(c, items) page.Items = items
ok(c, page)
} }
func (a *API) adminWalks(c *gin.Context) { func (a *API) adminWalks(c *gin.Context) {
var rows []model.Walk query := search(a.db.Model(&model.Walk{}), c, "id::text", "user_id::text")
if a.db.Order("started_at DESC").Limit(200).Find(&rows).Error != nil { query, valid := filterStatus(c, query, "status", "active", "paused", "finished")
fail(c, 500, "读取遛狗失败") if !valid {
return return
} }
ok(c, rows) rows, page, valid := adminRows[model.Walk](c, query, "started_at DESC, id DESC")
if !valid {
return
}
ids := []string{}
walkIDs := []string{}
for _, w := range rows {
ids = append(ids, w.UserID)
walkIDs = append(walkIDs, w.ID)
}
names, err := a.userNames(ids)
if err != nil {
fail(c, 500, "读取用户失败")
return
}
var links []struct {
WalkID string
PetName string
}
if len(walkIDs) > 0 && a.db.Model(&model.WalkPet{}).Select("sundynix_walk_pet.walk_id, p.name AS pet_name").Joins("JOIN sundynix_pet p ON p.id = sundynix_walk_pet.pet_id").Where("walk_id IN ?", walkIDs).Order("p.created_at ASC").Scan(&links).Error != nil {
fail(c, 500, "读取同行宠物失败")
return
}
pets := map[string][]string{}
for _, link := range links {
pets[link.WalkID] = append(pets[link.WalkID], link.PetName)
}
items := make([]gin.H, 0, len(rows))
for _, w := range rows {
items = append(items, gin.H{"id": w.ID, "user_id": w.UserID, "user_name": names[w.UserID], "pet_names": pets[w.ID], "status": w.Status, "started_at": w.StartedAt, "ended_at": w.EndedAt, "duration_seconds": w.DurationSeconds, "distance_meters": w.DistanceMeters, "share_enabled": w.ShareEnabled})
}
page.Items = items
ok(c, page)
}
func (a *API) adminWalkDetail(c *gin.Context) {
if !validID(c.Param("id")) {
fail(c, 400, "记录 ID 无效")
return
}
var walk model.Walk
if err := a.db.First(&walk, "id = ?", c.Param("id")).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
fail(c, 404, "遛狗记录不存在")
} else {
fail(c, 500, "读取记录失败")
}
return
}
var points []model.WalkPoint
if a.db.Where("walk_id = ?", walk.ID).Order("recorded_at ASC, id ASC").Find(&points).Error != nil {
fail(c, 500, "读取足迹失败")
return
}
ok(c, gin.H{"id": walk.ID, "points": points})
} }
func (a *API) adminMeetups(c *gin.Context) { func (a *API) adminMeetups(c *gin.Context) {
var rows []model.Meetup query := search(a.db.Model(&model.Meetup{}), c, "title", "place_name", "id::text")
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil { query, valid := filterStatus(c, query, "status", "open", "cancelled")
fail(c, 500, "读取约遛失败") if !valid {
return
}
rows, page, valid := adminRows[model.Meetup](c, query, "created_at DESC, id DESC")
if !valid {
return
}
ids := []string{}
for _, m := range rows {
ids = append(ids, m.HostUserID)
}
names, err := a.userNames(ids)
if err != nil {
fail(c, 500, "读取发起人失败")
return return
} }
items := make([]gin.H, 0, len(rows)) items := make([]gin.H, 0, len(rows))
for _, m := range rows { for _, m := range rows {
items = append(items, a.meetupView(m)) v := a.meetupView(m)
v["user_name"] = names[m.HostUserID]
items = append(items, v)
} }
ok(c, items) page.Items = items
ok(c, page)
}
func (a *API) adminTasks(c *gin.Context) {
query := search(a.db.Model(&model.CareTask{}), c, "title", "id::text", "user_id::text", "pet_id::text")
query, valid := filterStatus(c, query, "category", "food", "water", "walk", "medicine", "grooming", "other")
if !valid {
return
}
rows, page, valid := adminRows[model.CareTask](c, query, "created_at DESC, id DESC")
if !valid {
return
}
ids, petIDs, taskIDs := []string{}, []string{}, []string{}
for _, t := range rows {
ids = append(ids, t.UserID)
if t.PetID != nil {
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 {
petName := "全部宠物"
if t.PetID != nil {
petName = petNames[*t.PetID]
}
items = append(items, gin.H{"id": t.ID, "user_id": t.UserID, "user_name": names[t.UserID], "pet_id": t.PetID, "all_pets": t.PetID == nil, "pet_name": petName, "title": t.Title, "category": t.Category, "time_of_day": t.TimeOfDay, "repeat_rule": t.RepeatRule, "reminder_enabled": t.ReminderEnabled, "completed": done[t.ID], "created_at": t.CreatedAt})
}
page.Items = items
ok(c, page)
}
func (a *API) adminFiles(c *gin.Context) {
query := search(a.db.Model(&model.File{}), c, "name", "md5", "id::text")
rows, page, valid := adminRows[model.File](c, query, "created_at DESC, id DESC")
if !valid {
return
}
items := make([]gin.H, 0, len(rows))
for _, file := range rows {
v := a.fileView(file)
v["created_at"] = file.CreatedAt
v["bucket"] = file.Bucket
v["object_key"] = file.ObjectKey
items = append(items, v)
}
page.Items = items
ok(c, page)
} }
func (a *API) adminSetUserDisabled(c *gin.Context) { func (a *API) adminSetUserDisabled(c *gin.Context) {
var in struct { var in struct {
Disabled bool `json:"disabled"` Disabled *bool `json:"disabled"`
} }
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil { if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || in.Disabled == nil {
fail(c, 400, "参数无效") fail(c, 400, "参数无效")
return return
} }
r := a.db.Model(&model.User{}).Where("id = ?", c.Param("id")).Update("disabled", in.Disabled) err := a.db.Transaction(func(tx *gorm.DB) error {
if r.Error != nil || r.RowsAffected == 0 { result := tx.Model(&model.User{}).Where("id = ?", c.Param("id")).Update("disabled", *in.Disabled)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
// Disabling an account also revokes any publicly shared locations.
if *in.Disabled {
return tx.Model(&model.Walk{}).Where("user_id = ?", c.Param("id")).Updates(map[string]any{"share_enabled": false, "share_expires_at": nil}).Error
}
return nil
})
if errors.Is(err, gorm.ErrRecordNotFound) {
fail(c, 404, "用户不存在") fail(c, 404, "用户不存在")
return return
} }
ok(c, gin.H{"disabled": in.Disabled}) if err != nil {
fail(c, 500, "更新用户失败")
return
}
ok(c, gin.H{"disabled": *in.Disabled})
} }
func (a *API) adminSetMeetupStatus(c *gin.Context) { func (a *API) adminSetMeetupStatus(c *gin.Context) {
var in struct { var in struct {
Status string `json:"status"` Status string `json:"status"`
} }
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || (in.Status != "open" && in.Status != "cancelled") { if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || in.Status != "cancelled" {
fail(c, http.StatusBadRequest, "状态无效") fail(c, 400, "状态无效")
return return
} }
r := a.db.Model(&model.Meetup{}).Where("id = ?", c.Param("id")).Update("status", in.Status) result := a.db.Model(&model.Meetup{}).Where("id = ? AND status = ?", c.Param("id"), "open").Update("status", in.Status)
if r.Error != nil || r.RowsAffected == 0 { if result.Error != nil {
fail(c, 404, "约遛不存在") fail(c, 500, "更新约遛失败")
return
}
if result.RowsAffected == 0 {
fail(c, 409, "约遛不存在或已取消")
return return
} }
ok(c, gin.H{"status": in.Status}) ok(c, gin.H{"status": in.Status})
+231
View File
@@ -0,0 +1,231 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/database"
"github.com/sundynix/pets-be/internal/model"
)
func TestAdminPageValidation(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, query := range []string{"", "?page=2&page_size=10", "?page=0", "?page_size=101", "?page=abc", "?page=-1", "?page_size=0"} {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/admin/users"+query, nil)
page, valid := pageParams(context)
expected := query == "" || query == "?page=2&page_size=10"
if valid != expected {
t.Errorf("%s: valid=%v", query, valid)
}
if query == "" && (page.Page != 1 || page.PageSize != 10) {
t.Fatalf("default pagination: %+v", page)
}
if !valid && recorder.Code != 400 {
t.Errorf("%s: status=%d", query, recorder.Code)
}
}
}
func TestAdminListsRequireAdminRole(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.JWT.Secret = "test-secret"
api := New(nil, nil, cfg)
router := gin.New()
api.Register(router)
token, err := api.token(uuid.NewString(), "user")
if err != nil {
t.Fatal(err)
}
for _, route := range []string{"users", "users/" + uuid.NewString() + "/pets", "pets", "walks", "walks/" + uuid.NewString(), "meetups", "tasks", "files", "stats"} {
for _, auth := range []bool{false, true} {
request := httptest.NewRequest(http.MethodGet, "/api/admin/"+route, nil)
if auth {
request.Header.Set("Authorization", "Bearer "+token)
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
expected := http.StatusUnauthorized
if auth {
expected = http.StatusForbidden
}
if recorder.Code != expected {
t.Errorf("%s auth=%v: status=%d", route, auth, recorder.Code)
}
}
}
}
// Opt-in read-only verification against the configured PostgreSQL; never seeds or changes data.
func TestAdminPostgresReadOnly(t *testing.T) {
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
t.Skip("set PETS_ADMIN_INTEGRATION=1 for PostgreSQL verification")
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
db, err := database.Open(cfg.Postgres, true)
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
gin.SetMode(gin.TestMode)
api := New(db, nil, cfg)
router := gin.New()
api.Register(router)
token, err := api.token(uuid.NewString(), "admin")
if err != nil {
t.Fatal(err)
}
read := func(path string) json.RawMessage {
t.Helper()
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != 200 {
t.Fatalf("GET %s: %d %s", path, recorder.Code, recorder.Body.String())
}
var body struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
return body.Data
}
read("/api/admin/stats")
for _, section := range []string{"users", "pets", "walks", "meetups", "tasks", "files"} {
var page struct {
Items []struct {
ID string `json:"id"`
UserID string `json:"user_id"`
PetNames []string `json:"pet_names"`
} `json:"items"`
Total int64 `json:"total"`
PageSize int `json:"page_size"`
Page int `json:"page"`
}
if err := json.Unmarshal(read("/api/admin/"+section+"?page=1&page_size=10"), &page); err != nil {
t.Fatal(err)
}
if page.Page != 1 || page.PageSize != 10 || len(page.Items) > 10 || page.Total < int64(len(page.Items)) {
t.Fatalf("invalid %s pagination: %+v", section, page)
}
read("/api/admin/" + section + "?page=2&page_size=10")
read("/api/admin/" + section + "?q=%25%27%5F")
if len(page.Items) > 0 && section == "users" {
read("/api/admin/users/" + page.Items[0].ID + "/pets")
}
if len(page.Items) > 0 && section == "walks" {
read("/api/admin/walks/" + page.Items[0].ID)
}
}
}
// Fixtures live inside a transaction and are always rolled back.
func TestAdminPaginationAndWalkRelations(t *testing.T) {
gin.SetMode(gin.TestMode)
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
t.Skip("PostgreSQL integration disabled")
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
db, err := database.Open(cfg.Postgres, true)
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
tx := db.Begin()
if tx.Error != nil {
t.Fatal(tx.Error)
}
defer tx.Rollback()
prefix := "admin-test-" + uuid.NewString()
users := make([]model.User, 11)
for i := range users {
users[i] = model.User{Nickname: prefix, OpenID: uuid.NewString()}
}
if tx.Create(&users).Error != nil {
t.Fatal("create temporary users")
}
pets := []model.Pet{{UserID: users[0].ID, Name: "奶盖", Species: "dog"}, {UserID: users[0].ID, Name: "布丁", Species: "dog"}}
if tx.Create(&pets).Error != nil {
t.Fatal("create temporary pets")
}
now := time.Now()
walk := model.Walk{UserID: users[0].ID, Status: "finished", StartedAt: now, SegmentStartedAt: now, ShareToken: uuid.NewString()}
if err := tx.Create(&walk).Error; err != nil {
t.Fatal(err)
}
for _, pet := range pets {
if err := tx.Create(&model.WalkPet{WalkID: walk.ID, PetID: pet.ID}).Error; err != nil {
t.Fatal(err)
}
}
api := New(tx, nil, cfg)
router := gin.New()
api.Register(router)
token, _ := api.token(uuid.NewString(), "admin")
read := func(path string) json.RawMessage {
t.Helper()
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != 200 {
t.Fatalf("%s: %d %s", path, recorder.Code, recorder.Body.String())
}
var body struct {
Data json.RawMessage `json:"data"`
}
json.Unmarshal(recorder.Body.Bytes(), &body)
return body.Data
}
for _, sample := range []struct {
page string
size int
}{{"1", 10}, {"2", 1}} {
var result struct {
Items []json.RawMessage `json:"items"`
Total int64 `json:"total"`
}
if err := json.Unmarshal(read("/api/admin/users?q="+prefix+"&page="+sample.page), &result); err != nil {
t.Fatal(err)
}
if result.Total != 11 || len(result.Items) != sample.size {
t.Fatalf("pagination got %d rows / %d total", len(result.Items), result.Total)
}
}
var children []struct {
Name string `json:"name"`
}
json.Unmarshal(read("/api/admin/users/"+users[0].ID+"/pets"), &children)
if len(children) != 2 {
t.Fatalf("user tree lost pets: %+v", children)
}
var result struct {
Items []struct {
UserID string `json:"user_id"`
UserName string `json:"user_name"`
PetNames []string `json:"pet_names"`
} `json:"items"`
}
json.Unmarshal(read("/api/admin/walks?q="+walk.ID), &result)
if len(result.Items) != 1 || result.Items[0].UserID != users[0].ID || result.Items[0].UserName != prefix || len(result.Items[0].PetNames) != 2 {
t.Fatalf("walk relation incomplete: %+v", result)
}
}
+61 -12
View File
@@ -2,9 +2,11 @@ package httpapi
import ( import (
"context" "context"
"crypto/rand"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"math/big"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -57,12 +59,13 @@ func (a *API) adminLogin(c *gin.Context) {
fail(c, http.StatusUnauthorized, "用户名或密码错误") fail(c, http.StatusUnauthorized, "用户名或密码错误")
return return
} }
token, err := a.token(admin.ID, "admin") pair, err := a.newSession(admin.ID, "admin")
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, "签发令牌失败") fail(c, http.StatusInternalServerError, "签发令牌失败")
return return
} }
ok(c, gin.H{"token": token, "admin": gin.H{"id": admin.ID, "username": admin.Username}}) pair["admin"] = gin.H{"id": admin.ID, "username": admin.Username}
ok(c, pair)
} }
func (a *API) devLogin(c *gin.Context) { func (a *API) devLogin(c *gin.Context) {
@@ -79,8 +82,9 @@ func (a *API) devLogin(c *gin.Context) {
return return
} }
name := strings.TrimSpace(in.Nickname) name := strings.TrimSpace(in.Nickname)
if name == "" { if len([]rune(name)) > 64 {
name = "柯基家长" fail(c, http.StatusBadRequest, "昵称不能超过 64 字")
return
} }
user, err := a.findOrCreateUser("dev:"+in.Key, name) user, err := a.findOrCreateUser("dev:"+in.Key, name)
if err != nil { if err != nil {
@@ -123,7 +127,7 @@ func (a *API) login(c *gin.Context) {
fail(c, http.StatusUnauthorized, "微信登录凭证无效") fail(c, http.StatusUnauthorized, "微信登录凭证无效")
return return
} }
user, err := a.findOrCreateUser(wx.OpenID, "柯基家长") user, err := a.findOrCreateUser(wx.OpenID, "")
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, "登录失败") fail(c, http.StatusInternalServerError, "登录失败")
return return
@@ -140,28 +144,73 @@ func (a *API) findOrCreateUser(openID, nickname string) (model.User, error) {
if !errors.Is(err, gorm.ErrRecordNotFound) { if !errors.Is(err, gorm.ErrRecordNotFound) {
return user, err return user, err
} }
user = model.User{OpenID: openID, Nickname: nickname} // Serialize new-user naming across server instances without requiring custom nicknames to be unique.
if err := a.db.Create(&user).Error; err != nil { err = a.db.Transaction(func(tx *gorm.DB) error {
// A concurrent login may have inserted the same open_id. if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(7270139527)).Error; err != nil {
if lookup := a.db.Where("open_id = ?", openID).First(&user).Error; lookup == nil { return err
return user, nil
} }
// Another login for this open ID may have completed while waiting for the lock.
if err := tx.Where("open_id = ?", openID).First(&user).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
name := strings.TrimSpace(nickname)
if name == "" {
generated, err := defaultNickname(tx)
if err != nil {
return err
}
name = generated
}
user = model.User{OpenID: openID, Nickname: name}
return tx.Create(&user).Error
})
if err != nil {
return user, fmt.Errorf("create user: %w", err) return user, fmt.Errorf("create user: %w", err)
} }
return user, nil return user, nil
} }
func defaultNickname(tx *gorm.DB) (string, error) {
// Prefer four digits. Expand when occupied names make retries frequent.
for digits := 4; digits <= 6; digits++ {
limit := int64(10000)
if digits == 5 {
limit = 100000
} else if digits == 6 {
limit = 1000000
}
for attempt := 0; attempt < 20; attempt++ {
number, err := rand.Int(rand.Reader, big.NewInt(limit))
if err != nil {
return "", fmt.Errorf("generate nickname: %w", err)
}
name := fmt.Sprintf("毛孩家长%0*d", digits, number.Int64())
var count int64
if err := tx.Model(&model.User{}).Where("nickname = ?", name).Count(&count).Error; err != nil {
return "", err
}
if count == 0 {
return name, nil
}
}
}
return "", errors.New("default nickname allocation failed")
}
func (a *API) loginUser(c *gin.Context, user model.User) { func (a *API) loginUser(c *gin.Context, user model.User) {
if user.Disabled { if user.Disabled {
fail(c, http.StatusForbidden, "账号已停用") fail(c, http.StatusForbidden, "账号已停用")
return return
} }
token, err := a.token(user.ID, "user") pair, err := a.newSession(user.ID, "user")
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, "签发令牌失败") fail(c, http.StatusInternalServerError, "签发令牌失败")
return return
} }
ok(c, gin.H{"token": token, "user": a.userView(user)}) pair["user"] = a.userView(user)
ok(c, pair)
} }
func (a *API) me(c *gin.Context) { func (a *API) me(c *gin.Context) {
+219
View File
@@ -0,0 +1,219 @@
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/breeds"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
)
func (a *API) breedQuery(c *gin.Context) *gorm.DB {
return search(a.db.Model(&model.Breed{}), c, "name", "english_name", "aliases", "pinyin", "pinyin_initials")
}
func (a *API) listBreeds(c *gin.Context) {
if len([]rune(c.Query("q"))) > 128 {
fail(c, 400, "搜索内容过长")
return
}
query := a.breedQuery(c).Where("enabled = true")
var rows []model.Breed
if err := query.Order("initial ASC, pinyin ASC, name ASC").Find(&rows).Error; err != nil {
fail(c, 500, "读取品种失败")
return
}
// Users only need the selection fields; no internal source metadata.
items := make([]gin.H, 0, len(rows))
for _, row := range rows {
items = append(items, gin.H{"id": row.ID, "name": row.Name, "english_name": row.EnglishName, "aliases": row.Aliases, "pinyin": row.Pinyin, "pinyin_initials": row.PinyinInitials, "initial": row.Initial, "has_details": row.Summary != "" || row.Origin != "", "origin": row.Origin})
}
ok(c, items)
}
func (a *API) adminBreeds(c *gin.Context) {
query := a.breedQuery(c).Preload("References")
if status := c.Query("status"); status != "" {
if status != "enabled" && status != "disabled" {
fail(c, 400, "状态无效")
return
}
query = query.Where("enabled = ?", status == "enabled")
}
if initial := c.Query("initial"); initial != "" {
if len(initial) != 1 || (initial != "#" && (initial < "A" || initial > "Z")) {
fail(c, 400, "字母无效")
return
}
query = query.Where("initial = ?", initial)
}
rows, page, valid := adminRows[model.Breed](c, query, "initial ASC, pinyin ASC, name ASC")
if !valid {
return
}
page.Items = rows
ok(c, page)
}
type breedInput struct {
LifeSpan string `json:"life_span"`
Exercise string `json:"exercise"`
Grooming string `json:"grooming"`
Traits map[string]int `json:"traits"`
FCINumber string `json:"fci_number"`
Origin string `json:"origin"`
BreedGroup string `json:"breed_group"`
Height string `json:"height"`
Weight string `json:"weight"`
Summary string `json:"summary"`
Temperament string `json:"temperament"`
CareNotes string `json:"care_notes"`
Name string `json:"name" binding:"required"`
EnglishName string `json:"english_name"`
Aliases string `json:"aliases"`
Enabled *bool `json:"enabled" binding:"required"`
}
func bindBreed(c *gin.Context) (model.Breed, bool) {
var in breedInput
if c.ShouldBindJSON(&in) != nil {
fail(c, 400, "请填写品种名称和状态")
return model.Breed{}, false
}
in.Name = strings.TrimSpace(in.Name)
in.EnglishName = strings.TrimSpace(in.EnglishName)
in.Aliases = strings.TrimSpace(in.Aliases)
if in.Name == "" || len([]rune(in.Name)) > 128 || len([]rune(in.EnglishName)) > 128 || len([]rune(in.Aliases)) > 512 {
fail(c, 400, "品种资料长度无效")
return model.Breed{}, false
}
if len([]rune(in.Origin)) > 128 || len([]rune(in.BreedGroup)) > 255 || len([]rune(in.Height)) > 128 || len([]rune(in.Weight)) > 128 || len([]rune(in.Summary)) > 2000 || len([]rune(in.Temperament)) > 255 || len([]rune(in.CareNotes)) > 2000 {
fail(c, 400, "百科资料长度无效")
return model.Breed{}, false
}
if len([]rune(in.LifeSpan)) > 128 || len([]rune(in.Exercise)) > 255 || len([]rune(in.Grooming)) > 255 || len(in.FCINumber) > 16 {
fail(c, 400, "照顾资料长度无效")
return model.Breed{}, false
}
validTraits := map[string]bool{"运动需求": true, "运动活力": true, "训练接受度": true, "掉毛程度": true, "吠叫倾向": true, "梳毛需求": true, "亲近家人": true}
for key, value := range in.Traits {
if !validTraits[key] || value < 1 || value > 5 {
fail(c, 400, "指数需要为 1–5")
return model.Breed{}, false
}
}
scores, _ := json.Marshal(in.Traits)
full, short, initial := breeds.SortFields(in.Name)
return model.Breed{LifeSpan: strings.TrimSpace(in.LifeSpan), Exercise: strings.TrimSpace(in.Exercise), Grooming: strings.TrimSpace(in.Grooming), FCINumber: strings.TrimSpace(in.FCINumber), Traits: string(scores), Origin: strings.TrimSpace(in.Origin), BreedGroup: strings.TrimSpace(in.BreedGroup), Height: strings.TrimSpace(in.Height), Weight: strings.TrimSpace(in.Weight), Summary: strings.TrimSpace(in.Summary), Temperament: strings.TrimSpace(in.Temperament), CareNotes: strings.TrimSpace(in.CareNotes), Name: in.Name, EnglishName: in.EnglishName, Aliases: in.Aliases, Pinyin: full, PinyinInitials: short, Initial: initial, Enabled: *in.Enabled}, true
}
func breedWriteError(c *gin.Context, err error) {
// pgx PostgreSQL unique_violation, without disclosing DB details.
type sqlState interface{ SQLState() string }
var state sqlState
if errors.As(err, &state) && state.SQLState() == "23505" {
fail(c, http.StatusConflict, "该品种名称已存在")
return
}
fail(c, 500, "保存品种失败")
}
func (a *API) createBreed(c *gin.Context) {
row, valid := bindBreed(c)
if !valid {
return
}
row.Source = "管理端录入"
row.EditorialLocked = true
row.EncyclopediaUpdatedAt = time.Now().Format("2006-01-02")
// GORM defaults true for zero values on Create; explicitly select false after insertion.
err := a.db.Transaction(func(tx *gorm.DB) error {
enabled := row.Enabled
if err := tx.Create(&row).Error; err != nil {
return err
}
if !enabled {
row.Enabled = false
return tx.Model(&row).Update("enabled", false).Error
}
return nil
})
if err != nil {
breedWriteError(c, err)
return
}
created(c, row)
}
func (a *API) updateBreed(c *gin.Context) {
if !validID(c.Param("id")) {
fail(c, 404, "品种不存在")
return
}
update, valid := bindBreed(c)
if !valid {
return
}
var row model.Breed
if a.db.First(&row, "id = ?", c.Param("id")).Error != nil {
fail(c, 404, "品种不存在")
return
}
if row.FieldSources == nil {
row.FieldSources = map[string]string{}
}
before := map[string]string{"name": row.Name, "english_name": row.EnglishName, "aliases": row.Aliases, "origin": row.Origin, "breed_group": row.BreedGroup, "height": row.Height, "weight": row.Weight, "summary": row.Summary, "temperament": row.Temperament, "care_notes": row.CareNotes, "life_span": row.LifeSpan, "exercise": row.Exercise, "grooming": row.Grooming, "fci_number": row.FCINumber, "traits": row.Traits}
after := map[string]string{"name": update.Name, "english_name": update.EnglishName, "aliases": update.Aliases, "origin": update.Origin, "breed_group": update.BreedGroup, "height": update.Height, "weight": update.Weight, "summary": update.Summary, "temperament": update.Temperament, "care_notes": update.CareNotes, "life_span": update.LifeSpan, "exercise": update.Exercise, "grooming": update.Grooming, "fci_number": update.FCINumber, "traits": update.Traits}
var previousTraits map[string]int
if json.Unmarshal([]byte(row.Traits), &previousTraits) == nil {
normalized, _ := json.Marshal(previousTraits)
before["traits"] = string(normalized)
}
for key, value := range after {
if value != before[key] && !(key == "traits" && value == "null") {
row.FieldSources[key] = "editorial"
}
}
row.Origin, row.BreedGroup, row.Height, row.Weight, row.Summary, row.Temperament, row.CareNotes = update.Origin, update.BreedGroup, update.Height, update.Weight, update.Summary, update.Temperament, update.CareNotes
row.LifeSpan, row.Exercise, row.Grooming, row.FCINumber = update.LifeSpan, update.Exercise, update.Grooming, update.FCINumber
if update.Traits != "null" {
row.Traits = update.Traits
}
row.EditorialLocked = true
row.EncyclopediaUpdatedAt = time.Now().Format("2006-01-02")
row.Name, row.EnglishName, row.Aliases = update.Name, update.EnglishName, update.Aliases
row.Pinyin, row.PinyinInitials, row.Initial, row.Enabled = update.Pinyin, update.PinyinInitials, update.Initial, update.Enabled
if err := a.db.Save(&row).Error; err != nil {
breedWriteError(c, err)
return
}
ok(c, row)
}
func (a *API) setBreedEnabled(c *gin.Context) {
var in struct {
Enabled *bool `json:"enabled" binding:"required"`
}
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil {
fail(c, 400, "状态参数无效")
return
}
result := a.db.Model(&model.Breed{}).Where("id = ?", c.Param("id")).Update("enabled", *in.Enabled)
if result.Error != nil {
fail(c, 500, "更新状态失败")
return
}
if result.RowsAffected == 0 {
fail(c, 404, "品种不存在")
return
}
ok(c, gin.H{"id": c.Param("id"), "enabled": *in.Enabled})
}
func (a *API) breedDetail(c *gin.Context) {
var row model.Breed
if !validID(c.Param("id")) || a.db.Preload("References").Where("id = ? AND enabled = true", c.Param("id")).First(&row).Error != nil {
fail(c, 404, "品种不存在或已停用")
return
}
ok(c, row)
}
@@ -0,0 +1,269 @@
package httpapi
import (
"bytes"
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/sundynix/pets-be/internal/breeds"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/database"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
"net/http/httptest"
"os"
"testing"
"time"
)
func featureDB(t *testing.T) (*gorm.DB, *config.Config) {
t.Helper()
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()
t.Cleanup(func() { sqlDB.Close() })
tx := db.Begin()
if tx.Error != nil {
t.Fatal(tx.Error)
}
t.Cleanup(func() { tx.Rollback() })
return tx, cfg
}
func featureCall(handler gin.HandlerFunc, method, path string, body any, actorID, id string) *httptest.ResponseRecorder {
raw, _ := json.Marshal(body)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(method, path, bytes.NewReader(raw))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("actor_id", actorID)
c.Params = gin.Params{{Key: "id", Value: id}}
handler(c)
return rec
}
func pairFrom(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
if rec.Code != 200 && rec.Code != 201 {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
var result struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result.Data
}
func TestAccessTokenTwoHours(t *testing.T) {
api := New(nil, nil, &config.Config{JWT: config.JWTConfig{Secret: "test", ExpireHours: 168}})
raw, err := api.token(uuid.NewString(), "admin")
if err != nil {
t.Fatal(err)
}
c := &claims{}
_, err = jwt.ParseWithClaims(raw, c, func(*jwt.Token) (any, error) { return []byte("test"), nil })
if err != nil {
t.Fatal(err)
}
if c.ExpiresAt.Sub(c.IssuedAt.Time) != 2*time.Hour {
t.Fatal("access token lifetime is not two hours")
}
}
func TestRefreshRotationPostgres(t *testing.T) {
tx, cfg := featureDB(t)
a := New(tx, nil, cfg)
user := model.User{OpenID: "refresh-test:" + uuid.NewString(), Nickname: "测试家长"}
if err := tx.Create(&user).Error; err != nil {
t.Fatal(err)
}
pair, err := a.newSession(user.ID, "user")
if err != nil {
t.Fatal(err)
}
old := pair["refresh_token"].(string)
var session model.RefreshSession
if err := tx.Where("token_hash = ?", refreshHash(old)).First(&session).Error; err != nil {
t.Fatal(err)
}
if session.TokenHash == old {
t.Fatal("raw refresh stored")
}
call := func(raw, role string) *httptest.ResponseRecorder {
return featureCall(a.refreshToken(role), "POST", "/refresh", gin.H{"refresh_token": raw}, "", "")
}
if rec := call(old, "admin"); rec.Code != 401 {
t.Fatal("user credential accepted for admin")
}
next := pairFrom(t, call(old, "user"))
fresh := next["refresh_token"].(string)
if fresh == old || next["refresh_expires_at"].(float64) < float64(time.Now().Add(2*time.Hour-time.Second).Unix()) {
t.Fatal("rotation or two-hour expiry wrong")
}
if rec := call(old, "user"); rec.Code != 401 {
t.Fatal("old credential reusable")
}
revoked := featureCall(a.revokeRefresh("user"), "POST", "/logout", gin.H{"refresh_token": fresh}, "", "")
if revoked.Code != 200 {
t.Fatal(revoked.Body.String())
}
if rec := call(fresh, "user"); rec.Code != 401 {
t.Fatal("logout failed to revoke")
}
// Logout invalidates the access token tied to the same session too.
r := gin.New()
r.GET("/protected", a.requireRole("user"), func(c *gin.Context) { ok(c, true) })
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer "+next["token"].(string))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatal("revoked session access accepted")
}
pair, err = a.newSession(user.ID, "user")
if err != nil {
t.Fatal(err)
}
fresh = pair["refresh_token"].(string)
if err := tx.Model(&user).Update("disabled", true).Error; err != nil {
t.Fatal(err)
}
if rec := call(fresh, "user"); rec.Code != 401 {
t.Fatal("disabled user refreshed")
}
if err := tx.Model(&user).Update("disabled", false).Error; err != nil {
t.Fatal(err)
}
tx.Model(&model.RefreshSession{}).Where("token_hash = ?", refreshHash(fresh)).Update("expires_at", time.Now().Add(-time.Minute))
if rec := call(fresh, "user"); rec.Code != 401 {
t.Fatal("expired credential refreshed")
}
admin := model.Admin{Username: "refresh-test:" + uuid.NewString(), PasswordHash: "fixture"}
if err := tx.Create(&admin).Error; err != nil {
t.Fatal(err)
}
pair, err = a.newSession(admin.ID, "admin")
if err != nil {
t.Fatal(err)
}
pairFrom(t, call(pair["refresh_token"].(string), "admin"))
}
func TestBreedCatalogPostgres(t *testing.T) {
tx, cfg := featureDB(t)
a := New(tx, nil, cfg)
if added, err := breeds.Seed(tx); err != nil || added != 0 {
t.Fatalf("idempotent seed: %d %v", added, err)
}
var source model.Breed
if err := tx.Where("name = ?", "彭布罗克威尔士柯基").First(&source).Error; err != nil {
t.Fatal(err)
}
tx.Model(&source).Updates(map[string]any{"enabled": false, "name": "改名柯基测试"})
if added, err := breeds.Seed(tx); err != nil || added != 0 {
t.Fatalf("seed restored renamed record: %d %v", added, err)
}
tx.First(&source, "id = ?", source.ID)
if source.Enabled || source.Name != "改名柯基测试" {
t.Fatal("seed overwrote edits")
}
rec := featureCall(a.listBreeds, "GET", "/breeds?q=bianmu", nil, "", "")
if rec.Code != 200 {
t.Fatal(rec.Body.String())
}
// Short initials and Chinese aliases must both work.
for _, q := range []string{"边牧", "bjm", "bianjing"} {
rec = featureCall(a.listBreeds, "GET", "/breeds?q="+q, nil, "", "")
if rec.Code != 200 || !bytes.Contains(rec.Body.Bytes(), []byte("边境牧羊犬")) {
t.Fatalf("search %s: %s", q, rec.Body)
}
}
name := "测试品种" + uuid.NewString()
body := gin.H{"name": name, "aliases": "testbreed", "enabled": false}
created := pairFrom(t, featureCall(a.createBreed, "POST", "/breeds", body, "", ""))
id := created["id"].(string)
if created["enabled"].(bool) {
t.Fatal("disabled creation ignored")
}
pairFrom(t, featureCall(a.setBreedEnabled, "PUT", "/breeds", gin.H{"enabled": true}, "", id))
body["enabled"] = true
body["name"] = name + "更新"
pairFrom(t, featureCall(a.updateBreed, "PUT", "/breeds", body, "", id))
// Duplicate request runs in a savepoint because PostgreSQL aborts a transaction on unique violations.
tx.SavePoint("duplicate_breed")
rec = featureCall(a.createBreed, "POST", "/breeds", body, "", "")
if rec.Code != 409 {
t.Fatalf("duplicate status %d: %s", rec.Code, rec.Body)
}
tx.RollbackTo("duplicate_breed")
user := model.User{OpenID: "breed-test:" + uuid.NewString(), Nickname: "测试家长"}
tx.Create(&user)
selected, valid := a.buildPet(testPetContext(user.ID, ""), petInput{Name: "测试毛孩", Breed: "forged", BreedID: &id})
if !valid || selected.Breed != body["name"] {
t.Fatal("selected name not resolved by ID")
}
custom, valid := a.buildPet(testPetContext(user.ID, ""), petInput{Name: "测试毛孩", Breed: "未收录地方犬"})
if !valid || custom.BreedID != nil || custom.Breed != "未收录地方犬" {
t.Fatal("manual breed unavailable")
}
selected.UserID = user.ID
tx.Create(&selected)
tx.Model(&model.Breed{}).Where("id = ?", id).Update("enabled", false)
_, valid = a.buildPet(testPetContext(user.ID, ""), petInput{Name: "新宠物", BreedID: &id})
if valid {
t.Fatal("disabled breed newly selected")
}
_, valid = a.buildPet(testPetContext(user.ID, selected.ID), petInput{Name: "旧宠物", BreedID: &id})
if !valid {
t.Fatal("existing disabled breed cannot be retained")
}
}
func testPetContext(actorID, id string) *gin.Context {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set("actor_id", actorID)
c.Params = gin.Params{{Key: "id", Value: id}}
return c
}
func TestBreedSourcesAndEditorialProtection(t *testing.T) {
tx, cfg := featureDB(t)
a := New(tx, nil, cfg)
var breed model.Breed
if err := tx.Where("name = ?", "彭布罗克威尔士柯基").First(&breed).Error; err != nil {
t.Fatal(err)
}
detail := pairFrom(t, featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID))
if len(detail["references"].([]any)) != 4 {
t.Fatal("Corgi references missing")
}
if detail["field_sources"].(map[string]any)["exercise"] != "rkc" {
t.Fatal("exercise attribution wrong")
}
body := gin.H{"name": breed.Name, "english_name": breed.EnglishName, "enabled": true, "exercise": "编辑安排的散步活动", "summary": "新的中文介绍", "traits": gin.H{"运动活力": 2}}
pairFrom(t, featureCall(a.updateBreed, "PUT", "/breeds/"+breed.ID, body, "", breed.ID))
if _, err := breeds.Seed(tx); err != nil {
t.Fatal(err)
}
detail = pairFrom(t, featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID))
if detail["exercise"] != "编辑安排的散步活动" || detail["summary"] != "新的中文介绍" || detail["height"] != "" {
t.Fatal("import overwrote manual edits or cleared fields")
}
if detail["field_sources"].(map[string]any)["exercise"] != "editorial" || len(detail["references"].([]any)) != 4 {
t.Fatal("edited provenance or reference uniqueness lost")
}
body["traits"] = gin.H{"运动活力": 6}
if rec := featureCall(a.updateBreed, "PUT", "/breeds/"+breed.ID, body, "", breed.ID); rec.Code != 400 {
t.Fatalf("invalid rating accepted: %d", rec.Code)
}
tx.Model(&model.Breed{}).Where("id = ?", breed.ID).Update("enabled", false)
if rec := featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID); rec.Code != 404 {
t.Fatal("disabled encyclopedia visible")
}
}
+31
View File
@@ -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)
}
+1 -1
View File
@@ -132,7 +132,7 @@ func (a *API) petView(pet model.Pet) gin.H {
if pet.BirthDate != nil { if pet.BirthDate != nil {
birthDate = pet.BirthDate.Format("2006-01-02") birthDate = pet.BirthDate.Format("2006-01-02")
} }
return gin.H{"id": pet.ID, "name": pet.Name, "species": pet.Species, "breed": pet.Breed, return gin.H{"id": pet.ID, "name": pet.Name, "species": pet.Species, "breed": pet.Breed, "breed_id": pet.BreedID,
"gender": pet.Gender, "birth_date": birthDate, "weight_kg": pet.WeightKg, "gender": pet.Gender, "birth_date": birthDate, "weight_kg": pet.WeightKg,
"avatar_file_id": pet.AvatarFileID, "avatar_url": a.fileURL(pet.AvatarFileID), "avatar_file_id": pet.AvatarFileID, "avatar_url": a.fileURL(pet.AvatarFileID),
"coat": pet.Coat, "allergies": pet.Allergies, "care_notes": pet.CareNotes, "coat": pet.Coat, "allergies": pet.Allergies, "care_notes": pet.CareNotes,
+69
View File
@@ -0,0 +1,69 @@
package httpapi
import (
"os"
"regexp"
"testing"
"github.com/google/uuid"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/database"
)
func TestDefaultNicknamePostgres(t *testing.T) {
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()
api := New(tx, nil, cfg)
seen := map[string]bool{}
for range 12 {
openID := "nickname-test:" + uuid.NewString()
user, err := api.findOrCreateUser(openID, "")
if err != nil {
t.Fatal(err)
}
if !regexp.MustCompile(`^毛孩家长\d{4,6}$`).MatchString(user.Nickname) {
t.Fatalf("unexpected name %q", user.Nickname)
}
if seen[user.Nickname] {
t.Fatalf("duplicate name %q", user.Nickname)
}
seen[user.Nickname] = true
again, err := api.findOrCreateUser(openID, "不应覆盖的名字")
if err != nil {
t.Fatal(err)
}
if again.ID != user.ID || again.Nickname != user.Nickname {
t.Fatal("relogin changed existing user")
}
}
custom, err := api.findOrCreateUser("nickname-test:"+uuid.NewString(), "小豆的家长")
if err != nil {
t.Fatal(err)
}
if custom.Nickname != "小豆的家长" {
t.Fatal("custom nickname lost")
}
again, err := api.findOrCreateUser(custom.OpenID, "")
if err != nil {
t.Fatal(err)
}
if again.Nickname != custom.Nickname {
t.Fatal("relogin replaced custom nickname")
}
}
+24 -1
View File
@@ -15,6 +15,7 @@ type petInput struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Species string `json:"species"` Species string `json:"species"`
Breed string `json:"breed"` Breed string `json:"breed"`
BreedID *string `json:"breed_id"`
Gender string `json:"gender"` Gender string `json:"gender"`
BirthDate string `json:"birth_date"` BirthDate string `json:"birth_date"`
WeightKg float64 `json:"weight_kg"` WeightKg float64 `json:"weight_kg"`
@@ -81,6 +82,7 @@ func (a *API) updatePet(c *gin.Context) {
} }
pet.Name, pet.Species, pet.Breed, pet.Gender = updated.Name, updated.Species, updated.Breed, updated.Gender pet.Name, pet.Species, pet.Breed, pet.Gender = updated.Name, updated.Species, updated.Breed, updated.Gender
pet.BirthDate, pet.WeightKg, pet.AvatarFileID = updated.BirthDate, updated.WeightKg, updated.AvatarFileID pet.BirthDate, pet.WeightKg, pet.AvatarFileID = updated.BirthDate, updated.WeightKg, updated.AvatarFileID
pet.BreedID = updated.BreedID
pet.Coat, pet.Allergies, pet.CareNotes = updated.Coat, updated.Allergies, updated.CareNotes pet.Coat, pet.Allergies, pet.CareNotes = updated.Coat, updated.Allergies, updated.CareNotes
if err := a.db.Save(&pet).Error; err != nil { if err := a.db.Save(&pet).Error; err != nil {
fail(c, http.StatusInternalServerError, "更新宠物失败") fail(c, http.StatusInternalServerError, "更新宠物失败")
@@ -110,6 +112,27 @@ func (a *API) buildPet(c *gin.Context, in petInput) (model.Pet, bool) {
return pet, false return pet, false
} }
} }
in.Breed = strings.TrimSpace(in.Breed)
if len([]rune(in.Breed)) > 128 {
fail(c, http.StatusBadRequest, "品种名称过长")
return pet, false
}
if in.BreedID != nil {
var breed model.Breed
if !validID(*in.BreedID) || a.db.First(&breed, "id = ?", *in.BreedID).Error != nil {
fail(c, http.StatusBadRequest, "品种不存在,请重新选择或手动填写")
return pet, false
}
// A disabled breed can be retained on an existing pet, but not newly selected.
if !breed.Enabled {
var current model.Pet
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ? AND breed_id = ?", c.Param("id"), actor(c), *in.BreedID).First(&current).Error != nil {
fail(c, http.StatusBadRequest, "该品种已停用,请手动填写或重新选择")
return pet, false
}
}
in.Breed = breed.Name
}
var birthday *time.Time var birthday *time.Time
if in.BirthDate != "" { if in.BirthDate != "" {
d, err := parseDay(in.BirthDate) d, err := parseDay(in.BirthDate)
@@ -120,7 +143,7 @@ func (a *API) buildPet(c *gin.Context, in petInput) (model.Pet, bool) {
birthday = &d birthday = &d
} }
pet = model.Pet{Name: in.Name, Species: in.Species, Breed: strings.TrimSpace(in.Breed), pet = model.Pet{Name: in.Name, Species: in.Species, Breed: strings.TrimSpace(in.Breed),
Gender: in.Gender, BirthDate: birthday, WeightKg: in.WeightKg, BreedID: in.BreedID, Gender: in.Gender, BirthDate: birthday, WeightKg: in.WeightKg,
AvatarFileID: in.AvatarFileID, Coat: in.Coat, Allergies: in.Allergies, CareNotes: in.CareNotes} AvatarFileID: in.AvatarFileID, Coat: in.Coat, Allergies: in.Allergies, CareNotes: in.CareNotes}
return pet, true return pet, true
} }
+139
View File
@@ -0,0 +1,139 @@
package httpapi
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var errRefreshInvalid = errors.New("refresh credential invalid")
func refreshCredential() (string, error) {
data := make([]byte, 32)
if _, err := rand.Read(data); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(data), nil
}
func refreshHash(value string) string {
hash := sha256.Sum256([]byte(value))
return hex.EncodeToString(hash[:])
}
func (a *API) sessionPair(id, role, sessionID, refresh string, expires time.Time) (gin.H, error) {
token, err := a.sessionToken(id, role, sessionID)
if err != nil {
return nil, err
}
return gin.H{"token": token, "refresh_token": refresh, "expires_in": 7200, "expires_at": time.Now().Add(2 * time.Hour).Unix(), "refresh_expires_at": expires.Unix()}, nil
}
func (a *API) newSession(id, role string) (gin.H, error) {
raw, err := refreshCredential()
if err != nil {
return nil, err
}
expires := time.Now().Add(2 * time.Hour)
sessionID := uuid.NewString()
pair, err := a.sessionPair(id, role, sessionID, raw, expires)
if err != nil {
return nil, err
}
// Opportunistic cleanup bounded by indexed expiry, without a background scheduler.
if err := a.db.Where("expires_at <= ?", time.Now()).Delete(&model.RefreshSession{}).Error; err != nil {
return nil, err
}
err = a.db.Create(&model.RefreshSession{Base: model.Base{ID: sessionID}, ActorID: id, Role: role, TokenHash: refreshHash(raw), ExpiresAt: expires}).Error
return pair, err
}
func readRefresh(c *gin.Context) (string, bool) {
var in struct {
Token string `json:"refresh_token" binding:"required"`
}
if c.ShouldBindJSON(&in) != nil || len(in.Token) != 43 {
fail(c, http.StatusUnauthorized, "登录已过期,请重新登录")
return "", false
}
return in.Token, true
}
func (a *API) refreshToken(role string) gin.HandlerFunc {
return func(c *gin.Context) {
raw, valid := readRefresh(c)
if !valid {
return
}
var pair gin.H
err := a.db.Transaction(func(tx *gorm.DB) error {
var session model.RefreshSession
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("token_hash = ? AND role = ?", refreshHash(raw), role).First(&session).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return errRefreshInvalid
}
if err != nil {
return err
}
if session.Revoked || !session.ExpiresAt.After(time.Now()) {
return errRefreshInvalid
}
if role == "user" {
var user model.User
err = tx.Select("id", "disabled").First(&user, "id = ?", session.ActorID).Error
if errors.Is(err, gorm.ErrRecordNotFound) || (err == nil && user.Disabled) {
return errRefreshInvalid
}
if err != nil {
return err
}
} else {
var count int64
if err := tx.Model(&model.Admin{}).Where("id = ?", session.ActorID).Count(&count).Error; err != nil {
return err
}
if count != 1 {
return errRefreshInvalid
}
}
next, err := refreshCredential()
if err != nil {
return err
}
nextExpiry := time.Now().Add(2 * time.Hour)
pair, err = a.sessionPair(session.ActorID, role, session.ID, next, nextExpiry)
if err != nil {
return err
}
// Rotate inside the lock. The old credential can only succeed once.
return tx.Model(&session).Updates(map[string]any{"token_hash": refreshHash(next), "expires_at": nextExpiry}).Error
})
if errors.Is(err, errRefreshInvalid) {
fail(c, 401, "登录已过期,请重新登录")
return
}
if err != nil {
fail(c, 500, "暂时无法刷新登录,请重试")
return
}
ok(c, pair)
}
}
func (a *API) revokeRefresh(role string) gin.HandlerFunc {
return func(c *gin.Context) {
raw, valid := readRefresh(c)
if !valid {
return
}
if err := a.db.Model(&model.RefreshSession{}).Where("token_hash = ? AND role = ?", refreshHash(raw), role).Update("revoked", true).Error; err != nil {
fail(c, 500, "退出登录失败")
return
}
ok(c, gin.H{"logged_out": true})
}
}
+38 -8
View File
@@ -12,18 +12,20 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"github.com/sundynix/pets-be/internal/config" "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/model"
"github.com/sundynix/pets-be/internal/storage" "github.com/sundynix/pets-be/internal/storage"
) )
type API struct { type API struct {
env *environment.Client
db *gorm.DB db *gorm.DB
storage *storage.Storage storage *storage.Storage
cfg *config.Config cfg *config.Config
} }
func New(db *gorm.DB, storage *storage.Storage, cfg *config.Config) *API { 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) { func (a *API) Register(r *gin.Engine) {
@@ -35,10 +37,17 @@ func (a *API) Register(r *gin.Engine) {
api.POST("/auth/login", a.login) api.POST("/auth/login", a.login)
api.POST("/auth/dev", a.devLogin) api.POST("/auth/dev", a.devLogin)
api.POST("/admin/login", a.adminLogin) api.POST("/admin/login", a.adminLogin)
api.POST("/auth/refresh", a.refreshToken("user"))
api.POST("/admin/refresh", a.refreshToken("admin"))
api.POST("/auth/logout", a.revokeRefresh("user"))
api.POST("/admin/logout", a.revokeRefresh("admin"))
user := api.Group("") user := api.Group("")
user.Use(a.requireRole("user")) user.Use(a.requireRole("user"))
user.GET("/environment", a.environment)
user.GET("/me", a.me) user.GET("/me", a.me)
user.GET("/breeds", a.listBreeds)
user.GET("/breeds/:id", a.breedDetail)
user.GET("/pets", a.listPets) user.GET("/pets", a.listPets)
user.POST("/pets", a.createPet) user.POST("/pets", a.createPet)
user.GET("/pets/:id", a.getPet) user.GET("/pets/:id", a.getPet)
@@ -51,6 +60,7 @@ func (a *API) Register(r *gin.Engine) {
user.PUT("/tasks/:id", a.updateTask) user.PUT("/tasks/:id", a.updateTask)
user.DELETE("/tasks/:id", a.deleteTask) user.DELETE("/tasks/:id", a.deleteTask)
user.PUT("/tasks/:id/completion", a.setTaskCompletion) user.PUT("/tasks/:id/completion", a.setTaskCompletion)
user.PUT("/tasks/completions", a.completeTasks)
user.GET("/walks", a.listWalks) user.GET("/walks", a.listWalks)
user.POST("/walks", a.startWalk) user.POST("/walks", a.startWalk)
user.GET("/walks/:id", a.getWalk) user.GET("/walks/:id", a.getWalk)
@@ -68,28 +78,34 @@ func (a *API) Register(r *gin.Engine) {
admin := api.Group("/admin") admin := api.Group("/admin")
admin.Use(a.requireRole("admin")) admin.Use(a.requireRole("admin"))
admin.GET("/breeds", a.adminBreeds)
admin.POST("/breeds", a.createBreed)
admin.PUT("/breeds/:id", a.updateBreed)
admin.PUT("/breeds/:id/enabled", a.setBreedEnabled)
admin.GET("/stats", a.adminStats) admin.GET("/stats", a.adminStats)
admin.GET("/users", a.adminUsers) admin.GET("/users", a.adminUsers)
admin.GET("/users/:id/pets", a.adminUserPets)
admin.GET("/pets", a.adminPets) admin.GET("/pets", a.adminPets)
admin.GET("/walks", a.adminWalks) admin.GET("/walks", a.adminWalks)
admin.GET("/walks/:id", a.adminWalkDetail)
admin.GET("/meetups", a.adminMeetups) admin.GET("/meetups", a.adminMeetups)
admin.GET("/tasks", a.adminTasks)
admin.GET("/files", a.adminFiles)
admin.PUT("/users/:id/disabled", a.adminSetUserDisabled) admin.PUT("/users/:id/disabled", a.adminSetUserDisabled)
admin.PUT("/meetups/:id/status", a.adminSetMeetupStatus) admin.PUT("/meetups/:id/status", a.adminSetMeetupStatus)
} }
type claims struct { type claims struct {
Role string `json:"role"` Role string `json:"role"`
SessionID string `json:"sid,omitempty"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
func (a *API) token(id, role string) (string, error) { func (a *API) token(id, role string) (string, error) { return a.sessionToken(id, role, "") }
expire := a.cfg.JWT.ExpireHours func (a *API) sessionToken(id, role, sessionID string) (string, error) {
if expire <= 0 {
expire = 168
}
now := time.Now() now := time.Now()
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{Role: role, RegisteredClaims: jwt.RegisteredClaims{ t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{Role: role, SessionID: sessionID, RegisteredClaims: jwt.RegisteredClaims{
Subject: id, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(expire) * time.Hour)), ID: uuid.NewString(), Subject: id, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(2 * time.Hour)),
}}) }})
return t.SignedString([]byte(a.cfg.JWT.Secret)) return t.SignedString([]byte(a.cfg.JWT.Secret))
} }
@@ -119,6 +135,20 @@ func (a *API) requireRole(role string) gin.HandlerFunc {
c.Abort() c.Abort()
return return
} }
// Reject credentials issued with the old seven-day lifetime.
if cl.IssuedAt == nil || cl.ExpiresAt == nil || cl.ExpiresAt.Sub(cl.IssuedAt.Time) > 2*time.Hour {
fail(c, http.StatusUnauthorized, "登录已过期")
c.Abort()
return
}
if cl.SessionID != "" {
var count int64
if !validID(cl.SessionID) || a.db.Model(&model.RefreshSession{}).Where("id = ? AND actor_id = ? AND role = ? AND revoked = false AND expires_at > ?", cl.SessionID, cl.Subject, role, time.Now()).Count(&count).Error != nil || count != 1 {
fail(c, http.StatusUnauthorized, "登录已过期")
c.Abort()
return
}
}
if role == "user" { if role == "user" {
var user model.User var user model.User
if err := a.db.Select("id", "disabled").First(&user, "id = ?", cl.Subject).Error; err != nil || user.Disabled { if err := a.db.Select("id", "disabled").First(&user, "id = ?", cl.Subject).Error; err != nil || user.Disabled {
+91 -15
View File
@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/sundynix/pets-be/internal/model" "github.com/sundynix/pets-be/internal/model"
) )
@@ -15,7 +16,8 @@ import (
var clockPattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`) var clockPattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
type taskInput struct { type taskInput struct {
PetID string `json:"pet_id" binding:"required"` PetID string `json:"pet_id"`
AllPets bool `json:"all_pets"`
Title string `json:"title" binding:"required"` Title string `json:"title" binding:"required"`
Category string `json:"category"` Category string `json:"category"`
TimeOfDay string `json:"time_of_day"` TimeOfDay string `json:"time_of_day"`
@@ -49,7 +51,7 @@ func (a *API) listTasks(c *gin.Context) {
fail(c, http.StatusNotFound, "宠物不存在") fail(c, http.StatusNotFound, "宠物不存在")
return return
} }
query = query.Where("pet_id = ?", petID) query = query.Where("(pet_id = ? OR pet_id IS NULL)", petID)
} }
var tasks []model.CareTask var tasks []model.CareTask
if err := query.Order("time_of_day ASC").Find(&tasks).Error; err != nil { if err := query.Order("time_of_day ASC").Find(&tasks).Error; err != nil {
@@ -75,7 +77,7 @@ func (a *API) listTasks(c *gin.Context) {
} }
func taskView(task model.CareTask, completed bool) gin.H { func taskView(task model.CareTask, completed bool) gin.H {
return gin.H{"id": task.ID, "pet_id": task.PetID, "title": task.Title, return gin.H{"id": task.ID, "pet_id": task.PetID, "all_pets": task.PetID == nil, "title": task.Title,
"category": task.Category, "time_of_day": task.TimeOfDay, "category": task.Category, "time_of_day": task.TimeOfDay,
"repeat_rule": task.RepeatRule, "reminder_enabled": task.ReminderEnabled, "repeat_rule": task.RepeatRule, "reminder_enabled": task.ReminderEnabled,
"completed": completed} "completed": completed}
@@ -83,9 +85,26 @@ func taskView(task model.CareTask, completed bool) gin.H {
func (a *API) buildTask(c *gin.Context, in taskInput) (model.CareTask, bool) { func (a *API) buildTask(c *gin.Context, in taskInput) (model.CareTask, bool) {
task := model.CareTask{} task := model.CareTask{}
if _, err := a.ownedPet(actor(c), in.PetID); err != nil { var petID *string
if in.AllPets {
if in.PetID != "" {
fail(c, http.StatusBadRequest, "全部宠物与单只宠物不能同时选择")
return task, false
}
var count int64
if err := a.db.Model(&model.Pet{}).Where("user_id = ? AND archived = false", actor(c)).Count(&count).Error; err != nil {
fail(c, http.StatusInternalServerError, "读取宠物失败")
return task, false
}
if count == 0 {
fail(c, http.StatusBadRequest, "请先添加宠物")
return task, false
}
} else if _, err := a.ownedPet(actor(c), in.PetID); err != nil {
fail(c, http.StatusNotFound, "宠物不存在") fail(c, http.StatusNotFound, "宠物不存在")
return task, false return task, false
} else {
petID = &in.PetID
} }
in.Title = strings.TrimSpace(in.Title) in.Title = strings.TrimSpace(in.Title)
if in.Title == "" || len([]rune(in.Title)) > 128 { if in.Title == "" || len([]rune(in.Title)) > 128 {
@@ -109,7 +128,7 @@ func (a *API) buildTask(c *gin.Context, in taskInput) (model.CareTask, bool) {
fail(c, http.StatusBadRequest, "重复规则无效") fail(c, http.StatusBadRequest, "重复规则无效")
return task, false return task, false
} }
task = model.CareTask{UserID: actor(c), PetID: in.PetID, Title: in.Title, task = model.CareTask{UserID: actor(c), PetID: petID, Title: in.Title,
Category: in.Category, TimeOfDay: in.TimeOfDay, RepeatRule: in.RepeatRule, Category: in.Category, TimeOfDay: in.TimeOfDay, RepeatRule: in.RepeatRule,
ReminderEnabled: in.ReminderEnabled} ReminderEnabled: in.ReminderEnabled}
return task, true return task, true
@@ -179,11 +198,6 @@ func (a *API) deleteTask(c *gin.Context) {
} }
func (a *API) setTaskCompletion(c *gin.Context) { func (a *API) setTaskCompletion(c *gin.Context) {
var task model.CareTask
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error != nil {
fail(c, http.StatusNotFound, "任务不存在")
return
}
var in struct { var in struct {
Date string `json:"date" binding:"required"` Date string `json:"date" binding:"required"`
Completed bool `json:"completed"` Completed bool `json:"completed"`
@@ -192,20 +206,82 @@ func (a *API) setTaskCompletion(c *gin.Context) {
fail(c, http.StatusBadRequest, "完成状态参数无效") fail(c, http.StatusBadRequest, "完成状态参数无效")
return return
} }
if !in.Completed {
fail(c, http.StatusBadRequest, "已完成的任务不能取消完成")
return
}
var task model.CareTask
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error != nil {
fail(c, http.StatusNotFound, "任务不存在")
return
}
date, err := parseDay(in.Date) date, err := parseDay(in.Date)
if err != nil || !due(task, date) { if err != nil || !due(task, date) {
fail(c, http.StatusBadRequest, "该日期没有这项任务") fail(c, http.StatusBadRequest, "该日期没有这项任务")
return return
} }
if in.Completed {
item := model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()} item := model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()}
if err := a.db.Where("task_id = ? AND date = ?", task.ID, date).FirstOrCreate(&item).Error; err != nil { if err := a.db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "task_id"}, {Name: "date"}}, DoNothing: true}).Create(&item).Error; err != nil {
fail(c, http.StatusInternalServerError, "保存完成状态失败") fail(c, http.StatusInternalServerError, "保存完成状态失败")
return return
} }
} else if err := a.db.Where("task_id = ? AND date = ?", task.ID, date).Delete(&model.TaskCompletion{}).Error; err != nil { ok(c, gin.H{"id": task.ID, "date": in.Date, "completed": true})
fail(c, http.StatusInternalServerError, "取消完成失败") }
// Complete the displayed tasks atomically, allowing safe retries and concurrent taps.
func (a *API) completeTasks(c *gin.Context) {
var in struct {
Date string `json:"date" binding:"required"`
TaskIDs []string `json:"task_ids" binding:"required,min=1,max=200"`
}
if c.ShouldBindJSON(&in) != nil {
fail(c, http.StatusBadRequest, "请选择 1 至 200 项任务")
return return
} }
ok(c, gin.H{"id": task.ID, "date": in.Date, "completed": in.Completed}) date, err := parseDay(in.Date)
if err != nil {
fail(c, http.StatusBadRequest, "日期格式应为 YYYY-MM-DD")
return
}
ids := make([]string, 0, len(in.TaskIDs))
seen := map[string]bool{}
for _, id := range in.TaskIDs {
if !validID(id) {
fail(c, http.StatusBadRequest, "任务 ID 无效")
return
}
if !seen[id] {
ids = append(ids, id)
seen[id] = true
}
}
invalid := false
err = a.db.Transaction(func(tx *gorm.DB) error {
var tasks []model.CareTask
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ? AND user_id = ?", ids, actor(c)).Order("id").Find(&tasks).Error; err != nil {
return err
}
if len(tasks) != len(ids) {
invalid = true
return gorm.ErrRecordNotFound
}
items := make([]model.TaskCompletion, 0, len(tasks))
for _, task := range tasks {
if !due(task, date) {
invalid = true
return gorm.ErrRecordNotFound
}
items = append(items, model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()})
}
return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "task_id"}, {Name: "date"}}, DoNothing: true}).Create(&items).Error
})
if err != nil {
if invalid {
fail(c, http.StatusBadRequest, "包含不存在或该日期无需完成的任务")
} else {
fail(c, http.StatusInternalServerError, "完成任务失败,请重试")
}
return
}
ok(c, gin.H{"task_ids": ids, "date": in.Date, "completed": true})
} }
+145
View File
@@ -0,0 +1,145 @@
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"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 TestTaskCompletionCannotBeUndone(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/completion", bytes.NewBufferString(`{"date":"2026-10-05","completed":false}`))
api := New(nil, nil, &config.Config{})
api.setTaskCompletion(c)
if rec.Code != http.StatusBadRequest {
t.Fatalf("undo status %d", rec.Code)
}
}
// All fixtures and writes are rolled back, including inner batch transactions.
func TestTaskCompletionPostgres(t *testing.T) {
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()
// Verify the nullable pet association migration without changing live schema.
if err := tx.AutoMigrate(&model.CareTask{}); err != nil {
t.Fatal(err)
}
user := model.User{OpenID: "completion-test:" + uuid.NewString(), Nickname: "测试家长"}
if err := tx.Create(&user).Error; err != nil {
t.Fatal(err)
}
pet := model.Pet{UserID: user.ID, Name: "测试毛孩", Species: "dog", Gender: "male"}
if err := tx.Create(&pet).Error; err != nil {
t.Fatal(err)
}
tasks := []model.CareTask{{UserID: user.ID, PetID: &pet.ID, Title: "喝水", Category: "water", TimeOfDay: "08:00", RepeatRule: "daily"}, {UserID: user.ID, PetID: &pet.ID, Title: "喂食", Category: "food", TimeOfDay: "09:00", RepeatRule: "daily"}}
if err := tx.Create(&tasks).Error; err != nil {
t.Fatal(err)
}
api := New(tx, nil, cfg)
request := func(method, route, id string, body gin.H, handler gin.HandlerFunc) *httptest.ResponseRecorder {
t.Helper()
encoded, _ := json.Marshal(body)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Set("actor_id", user.ID)
c.Params = gin.Params{{Key: "id", Value: id}}
c.Request = httptest.NewRequest(method, route, bytes.NewReader(encoded))
handler(c)
return rec
}
allBody := gin.H{"all_pets": true, "title": "全部换水", "category": "water", "time_of_day": "10:00", "repeat_rule": "daily"}
rec := request("POST", "/tasks", "", allBody, api.createTask)
if rec.Code != 201 {
t.Fatalf("all-pet creation: %d %s", rec.Code, rec.Body.String())
}
var allTask model.CareTask
if err := tx.Where("user_id = ? AND title = ?", user.ID, "全部换水").First(&allTask).Error; err != nil || allTask.PetID != nil {
t.Fatalf("all-pet association should be null: %v", err)
}
// Single-pet filters include shared tasks; other users' tasks remain isolated.
rec = request("GET", "/tasks?date=2026-10-05&pet_id="+pet.ID, "", nil, api.listTasks)
var listing struct {
Data struct {
Items []struct {
ID string `json:"id"`
AllPets bool `json:"all_pets"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &listing); err != nil || rec.Code != 200 || len(listing.Data.Items) != 3 {
t.Fatalf("pet filter omitted shared task: %s", rec.Body.String())
}
if rec = request("PUT", "/tasks/"+allTask.ID, allTask.ID, gin.H{"pet_id": pet.ID, "title": "单只换水"}, api.updateTask); rec.Code != 200 {
t.Fatalf("switch to single pet: %s", rec.Body.String())
}
if rec = request("PUT", "/tasks/"+allTask.ID, allTask.ID, allBody, api.updateTask); rec.Code != 200 {
t.Fatalf("switch to all pets: %s", rec.Body.String())
}
conflicting := gin.H{"all_pets": true, "pet_id": pet.ID, "title": "冲突选择"}
if rec = request("POST", "/tasks", "", conflicting, api.createTask); rec.Code != 400 {
t.Fatalf("ambiguous selection accepted: %s", rec.Body.String())
}
call := func(ids []string) int {
t.Helper()
body, _ := json.Marshal(gin.H{"date": "2026-10-05", "task_ids": ids})
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Set("actor_id", user.ID)
c.Request = httptest.NewRequest(http.MethodPut, "/tasks/completions", bytes.NewReader(body))
api.completeTasks(c)
return rec.Code
}
if status := call([]string{tasks[0].ID, uuid.NewString()}); status != 400 {
t.Fatalf("invalid batch status %d", status)
}
var count int64
tx.Model(&model.TaskCompletion{}).Where("user_id = ?", user.ID).Count(&count)
if count != 0 {
t.Fatal("invalid batch partially completed")
}
ids := []string{tasks[0].ID, tasks[1].ID}
for range 2 {
if status := call(ids); status != 200 {
t.Fatalf("batch status %d", status)
}
}
tx.Model(&model.TaskCompletion{}).Where("user_id = ?", user.ID).Count(&count)
if count != 2 {
t.Fatalf("retry duplicated completions: %d", count)
}
if status := call([]string{allTask.ID}); status != 200 {
t.Fatalf("all-pet completion status %d", status)
}
tx.Model(&model.TaskCompletion{}).Where("task_id = ?", allTask.ID).Count(&count)
if count != 1 {
t.Fatalf("shared task should have one completion: %d", count)
}
}
+55 -3
View File
@@ -47,12 +47,64 @@ type Admin struct {
PasswordHash string `gorm:"size:255;not null" json:"-"` PasswordHash string `gorm:"size:255;not null" json:"-"`
} }
// RefreshSession stores only a hash of the rotating refresh credential.
type RefreshSession struct {
Base
ActorID string `gorm:"type:uuid;index;not null" json:"-"`
Role string `gorm:"size:16;index;not null" json:"-"`
TokenHash string `gorm:"size:64;uniqueIndex;not null" json:"-"`
ExpiresAt time.Time `gorm:"index;not null" json:"-"`
Revoked bool `gorm:"not null;default:false" json:"-"`
}
type Breed struct {
Base
LifeSpan string `gorm:"size:128" json:"life_span"`
Exercise string `gorm:"size:255" json:"exercise"`
Grooming string `gorm:"size:255" json:"grooming"`
EditorialLocked bool `gorm:"not null;default:false" json:"editorial_locked"`
FieldSources map[string]string `gorm:"serializer:json;type:jsonb" json:"field_sources"`
References []BreedReference `gorm:"foreignKey:BreedID" json:"references,omitempty"`
Origin string `gorm:"size:128" json:"origin"`
BreedGroup string `gorm:"size:255" json:"breed_group"`
FCINumber string `gorm:"size:16" json:"fci_number"`
Height string `gorm:"size:128" json:"height"`
Weight string `gorm:"size:128" json:"weight"`
Summary string `gorm:"type:text" json:"summary"`
Temperament string `gorm:"size:255" json:"temperament"`
Traits string `gorm:"type:text" json:"traits"`
CareNotes string `gorm:"type:text" json:"care_notes"`
EncyclopediaUpdatedAt string `gorm:"size:10" json:"encyclopedia_updated_at"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
EnglishName string `gorm:"size:128" json:"english_name"`
Aliases string `gorm:"size:512" json:"aliases"`
Pinyin string `gorm:"size:1024;index;not null" json:"pinyin"`
PinyinInitials string `gorm:"size:128" json:"pinyin_initials"`
Initial string `gorm:"size:1;index;not null" json:"initial"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
Source string `gorm:"size:128" json:"source"`
SourceKey *string `gorm:"size:128;uniqueIndex" json:"source_key"`
SourceURL string `gorm:"size:512" json:"source_url"`
}
// Facts contain short structured facts, never copied articles or remote images.
type BreedReference struct {
Base
BreedID string `gorm:"type:uuid;uniqueIndex:idx_breed_provider;not null" json:"breed_id"`
Provider string `gorm:"size:32;uniqueIndex:idx_breed_provider;not null" json:"provider"`
ExternalID string `gorm:"size:128;not null" json:"external_id"`
URL string `gorm:"size:512;not null" json:"url"`
RetrievedAt string `gorm:"size:10;not null" json:"retrieved_at"`
Facts map[string]string `gorm:"serializer:json;type:jsonb;not null" json:"facts"`
}
type Pet struct { type Pet struct {
Base Base
UserID string `gorm:"type:uuid;index;not null" json:"user_id"` UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
Name string `gorm:"size:64;not null" json:"name"` Name string `gorm:"size:64;not null" json:"name"`
Species string `gorm:"size:16;not null;default:dog" json:"species"` Species string `gorm:"size:16;not null;default:dog" json:"species"`
Breed string `gorm:"size:64" json:"breed"` Breed string `gorm:"size:128" json:"breed"`
BreedID *string `gorm:"type:uuid;index" json:"breed_id"`
Gender string `gorm:"size:16" json:"gender"` Gender string `gorm:"size:16" json:"gender"`
BirthDate *time.Time `gorm:"type:date" json:"birth_date"` BirthDate *time.Time `gorm:"type:date" json:"birth_date"`
WeightKg float64 `gorm:"type:numeric(6,2)" json:"weight_kg"` WeightKg float64 `gorm:"type:numeric(6,2)" json:"weight_kg"`
@@ -66,7 +118,7 @@ type Pet struct {
type CareTask struct { type CareTask struct {
Base Base
UserID string `gorm:"type:uuid;index;not null" json:"user_id"` UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
PetID string `gorm:"type:uuid;index;not null" json:"pet_id"` PetID *string `gorm:"type:uuid;index" json:"pet_id"` // nil applies to all of the user's pets.
Title string `gorm:"size:128;not null" json:"title"` Title string `gorm:"size:128;not null" json:"title"`
Category string `gorm:"size:32;not null" json:"category"` Category string `gorm:"size:32;not null" json:"category"`
TimeOfDay string `gorm:"size:5;not null" json:"time_of_day"` TimeOfDay string `gorm:"size:5;not null" json:"time_of_day"`
@@ -140,7 +192,7 @@ type MeetupPet struct {
func All() []any { func All() []any {
return []any{ return []any{
&File{}, &User{}, &Admin{}, &Pet{}, &CareTask{}, &TaskCompletion{}, &File{}, &User{}, &Admin{}, &RefreshSession{}, &Breed{}, &BreedReference{}, &Pet{}, &CareTask{}, &TaskCompletion{},
&Walk{}, &WalkPet{}, &WalkPoint{}, &Meetup{}, &MeetupParticipant{}, &MeetupPet{}, &Walk{}, &WalkPet{}, &WalkPoint{}, &Meetup{}, &MeetupParticipant{}, &MeetupPet{},
} }
} }
+204
View File
@@ -0,0 +1,204 @@
"""Collect official factual metadata. Run from repo root; no third-party packages.
HTML is cached outside the repository. No source prose or photos are persisted.
Mappings use FCI numbers and exact English names, never fuzzy breed matching.
"""
import argparse
from datetime import date
import concurrent.futures
import hashlib
import html
from html.parser import HTMLParser
import json
from pathlib import Path
import re
import unicodedata
import urllib.request
from urllib.parse import urljoin
ROOT = Path(__file__).resolve().parents[1] / 'internal/breeds'
CACHE = Path('/private/tmp/pawprint-breed-sources')
CACHE.mkdir(exist_ok=True)
DATE = date.today().isoformat()
class Node:
def __init__(self, tag='', attrs=None):
self.tag, self.attrs, self.children = tag, dict(attrs or []), []
def text(self):
return ''.join(c if isinstance(c, str) else c.text() for c in self.children)
def all(self):
yield self
for c in self.children:
if isinstance(c, Node):
yield from c.all()
class DOM(HTMLParser):
def __init__(self, text):
super().__init__(convert_charrefs=True)
self.root = Node(); self.stack = [self.root]; self.feed(text)
def handle_starttag(self, tag, attrs):
n = Node(tag, attrs); self.stack[-1].children.append(n)
if tag not in ('br','img','hr','meta','link','input','source','wbr','area','embed'):
self.stack.append(n)
def handle_startendtag(self, tag, attrs):
self.stack[-1].children.append(Node(tag,attrs))
def handle_endtag(self, tag):
for i in range(len(self.stack)-1,0,-1):
if self.stack[i].tag == tag:
self.stack = self.stack[:i]; return
def handle_data(self, data):
self.stack[-1].children.append(data)
def clean(value):
return re.sub(r'\s+', ' ', html.unescape(re.sub('<[^>]*>',' ',str(value or '')))).strip()
def norm(value):
return re.sub('[^a-z0-9]', '', unicodedata.normalize('NFKD', value).encode('ascii','ignore').decode().lower())
def fetch(url):
file = CACHE / (hashlib.sha256(url.encode()).hexdigest()+'.html')
if file.exists() and date.fromtimestamp(file.stat().st_mtime).isoformat()==DATE: return file.read_text()
req = urllib.request.Request(url, headers={'User-Agent':'PawprintPlan/1.0 (breed facts)'})
with urllib.request.urlopen(req, timeout=20) as r: text = r.read().decode('utf-8','replace')
file.write_text(text); return text
def references(provider, key, url, facts):
return {'provider':provider,'external_id':str(key),'url':url,'retrieved_at':DATE,
'facts':{k:clean(v) for k,v in facts.items() if v is not None and clean(v)}}
GROUPS=['牧羊犬与牧牛犬组','宾莎犬、雪纳瑞、獒犬与瑞士山地牧牛犬组','梗犬组','腊肠犬组','尖嘴犬与原始犬种组','嗅觉猎犬与相关犬种组','指示猎犬组','寻回犬、搜寻犬与水猎犬组','伴侣犬与玩具犬组','灵缇(视觉猎犬)组']
COUNTRIES={'AUSTRALIA':'澳大利亚','BELGIUM':'比利时','CROATIA':'克罗地亚','CZECHOSLOVAKIA':'捷克斯洛伐克','FRANCE':'法国','GERMANY':'德国','GREAT BRITAIN':'英国','HUNGARY':'匈牙利','ITALY':'意大利','POLAND':'波兰','PORTUGAL':'葡萄牙','ROMANIA':'罗马尼亚','RUSSIA':'俄罗斯','SLOVAKIA':'斯洛伐克','SPAIN':'西班牙','SWITZERLAND':'瑞士','THE NETHERLANDS':'荷兰','NETHERLANDS':'荷兰','AUSTRIA':'奥地利','DENMARK':'丹麦','FINLAND':'芬兰','IRELAND':'爱尔兰','NORWAY':'挪威','SWEDEN':'瑞典','USA':'美国','UNITED STATES OF AMERICA':'美国','CANADA':'加拿大','JAPAN':'日本','CHINA':'中国','KOREA':'韩国','ISRAEL':'以色列','MEXICO':'墨西哥','BRAZIL':'巴西','ARGENTINA':'阿根廷','AFGHANISTAN':'阿富汗','MIDDLE EAST':'中东','TURKEY':'土耳其','THAILAND':'泰国','SOUTH AFRICA':'南非','MOROCCO':'摩洛哥','MALI':'马里','ICELAND':'冰岛','GREENLAND':'格陵兰','MALTA':'马耳他','BOSNIA AND HERZEGOVINA':'波斯尼亚和黑塞哥维那','SERBIA':'塞尔维亚','MONTENEGRO':'黑山','GREECE':'希腊','URUGUAY':'乌拉圭','PERU':'秘鲁','INDONESIA':'印度尼西亚','LITHUANIA':'立陶宛','ESTONIA':'爱沙尼亚','BELGIUM, FRANCE':'比利时、法国','CENTRAL AFRICA':'非洲中部','CUBA':'古巴','CZECH REPUBLIC':'捷克','GERMANY, SWITZERLAND':'德国、瑞士','KOREA (REPUBLIC OF)':'韩国','NORTH MACEDONIA, SERBIA':'北马其顿、塞尔维亚','RUSSIAN FEDERATION':'俄罗斯','SLOVENIA':'斯洛文尼亚','TAIWAN':'中国台湾','TIBET (CHINA)':'中国西藏'}
def fci_group(link):
number, url=link
nodes=list(DOM(fetch(url)).root.all()); country=''; records={}
for n in nodes:
node_id=n.attrs.get('id','')
if re.search(r'_PaysLabel_\d+$',node_id): country=re.sub(r'^\d+\.\s*','',clean(n.text()))
if n.tag=='a' and 'RaceHyperLink' in node_id:
match=re.search(r'\((\d+)\)',n.text())
if not match: continue
key=match.group(1); title=clean(n.text()); en=re.findall(r'\(([^\d][^)]*)\)',title)
name = re.sub(r'-\d+\.html$', '', n.attrs['href'].split('/')[-1]).replace('-', ' ')
facts={'english_name':name.title(),'origin':COUNTRIES.get(country,country.title()),'breed_group':GROUPS[number-1],'fci_number':key}
records[key]=references('fci',key,urljoin(url,n.attrs['href']),facts)
return records
def akc_page(item):
name,url=item
nodes=list(DOM(fetch(url)).root.all()); props=None
for n in nodes:
if n.attrs.get('data-js-component')=='breedPage': props=json.loads(n.attrs['data-js-props']);break
if not props: return None
data=props['settings']['breed_data']; slug=props['settings']['current_breed']
basic=data.get('basics',{}).get(slug,{})
standard=data.get('standards',{}).get(slug,{})
traits=data.get('traits',{}).get(slug,{})
facts={'english_name':name,'life_span':basic.get('life_expectancy'),'height':standard.get('height_display'),'weight':standard.get('weight_display')}
scores={}
labels={'energy_level':'运动活力','trainability_level':'训练接受度','shedding_level':'掉毛程度','barking_level':'吠叫倾向','coat_grooming_frequency':'梳毛需求','affectionate_with_family':'亲近家人'}
for key, label in labels.items():
value=traits.get('traits',{}).get(key,{}).get('score')
if str(value) in ['1','2','3','4','5']:scores[label]=int(value)
if scores:facts['traits']=json.dumps(scores,ensure_ascii=False,separators=(',',':'))
words={'alert':'对环境敏锐','affectionate':'喜欢亲近家人','smart':'学习反应快','intelligent':'学习反应快','friendly':'亲人友善','active':'喜欢活动','energetic':'精力较充沛','playful':'喜欢玩耍','loyal':'重视与家人的联系','independent':'有自主性','gentle':'性情较温和','confident':'比较自信','courageous':'胆量较足','curious':'好奇心较强','calm':'较为沉着','outgoing':'乐于互动','devoted':'重视与家人的联系','lively':'喜欢活动','watchful':'对环境敏锐'}
tags=list(dict.fromkeys(words[w.strip().lower()] for w in traits.get('temperament','').split('/') if w.strip().lower() in words))
if tags:facts['temperament']='、'.join(tags)
return references('akc',slug,url,facts)
def localized(key, value):
value=clean(value).replace('(male)','(公犬)').replace('(female)','(母犬)').replace('up to','不超过').replace('years','年').replace('year','年')
unit, factor, label = ('inches',2.54,'cm') if key=='height' else ('pounds',0.45359237,'kg')
def convert(match):
low=f"{float(match.group(1))*factor:.1f}".rstrip('0').rstrip('.')
high=("–"+f"{float(match.group(2))*factor:.1f}".rstrip('0').rstrip('.')) if match.group(2) else ''
return low+high+' '+label
return re.sub(r'(\d+(?:\.\d+)?)(?:\s*[-–]\s*(\d+(?:\.\d+)?))?\s*'+unit,convert,value)
def main():
parser=argparse.ArgumentParser();parser.parse_args()
rows=json.loads((ROOT/'data.json').read_text())
home=list(DOM(fetch('https://www.fci.be/en/nomenclature/')).root.all())
links=[]
for n in home:
if n.tag=='a' and 'GroupesRepeater_GroupeHyperLink' in n.attrs.get('id',''):
links.append((int(clean(n.text()).split()[-1]),urljoin('https://www.fci.be',n.attrs['href'])))
fci={}
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
for result in pool.map(fci_group,links): fci.update(result)
print('FCI catalog:',len(fci),flush=True)
akc={}
for n in DOM(fetch('https://www.akc.org/dog-breeds/')).root.all():
if n.tag=='option' and re.fullmatch(r'https://www.akc.org/dog-breeds/[a-z0-9-]+/',n.attrs.get('value','')):
akc[norm(clean(n.text()))]=(clean(n.text()),n.attrs['value'])
matches={}; entries=[]
for row in rows:
refs=[];fields={};field_sources={}
existing={k:row.get(k,'') for k in ['english_name','origin','breed_group','fci_number','height','weight','temperament','traits'] if row.get(k)}
if row['source_key'].startswith('cku:'):
refs.append(references('cku',row['source_key'].split(':')[1],row['source_url'],{'name':row['name'],'aliases':row['aliases'],**existing}))
for k in existing:field_sources[k]='cku'
match=re.match(r'cku:(\d+)',row['source_key']); source=fci.get(match.group(1)) if match else None
if source:
source=json.loads(json.dumps(source))
if re.search(r'[A-Z]$',row['source_key']):source['facts']['scope']='犬种基础标准(包含多个变种)'
refs.append(source)
for k,v in source['facts'].items():
if k not in ['scope'] and not (k=='english_name' and re.search(r'[A-Z]$',row['source_key'])) and not row.get(k): fields[k]=v;field_sources[k]='fci'
candidates=[row.get('english_name',''),fields.get('english_name','')]
equivalents={'cku:39':'Pembroke Welsh Corgi','cku:38':'Cardigan Welsh Corgi','cku:167':'Cocker Spaniel'}
if row['source_key'] in equivalents:candidates.insert(0,equivalents[row['source_key']])
# Varieties are deliberately not collapsed into their parent breed.
for candidate in candidates:
if norm(candidate) in akc:
item=akc[norm(candidate)];matches[item]=None;break
entries.append({'source_key':row['source_key'],'name':row['name'],'fields':fields,'field_sources':field_sources,'references':refs})
print('Exact AKC matches:',len(matches),flush=True)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
futures={pool.submit(akc_page,item):item for item in matches}
done=0
for job in concurrent.futures.as_completed(futures):
item=futures[job]
try:matches[item]=job.result()
except Exception as e: print('AKC unavailable:',item[0],type(e).__name__,flush=True)
done+=1
if done%20==0:print('AKC progress:',done,'/',len(matches),flush=True)
by_name={norm(name):ref for (name,_),ref in matches.items() if ref}
for row,entry in zip(rows,entries):
names=[row.get('english_name',''),entry['fields'].get('english_name','')]
if row['source_key'] in equivalents:names.insert(0,equivalents[row['source_key']])
ref=next((by_name[norm(n)] for n in names if norm(n) in by_name),None)
if not ref:continue
ref=json.loads(json.dumps(ref))
if re.search(r'[A-Z]$',row['source_key']):
ref['facts']['scope']='父犬种资料,变种差异以对应标准为准'
entry['references'].append(ref);continue
entry['references'].append(ref)
for k,v in ref['facts'].items():
if k in ['height','weight','life_span'] or (not row.get(k) and not entry['fields'].get(k)):entry['fields'][k]=localized(k,v);entry['field_sources'][k]='akc'
# Lifestyle ratings have an explicit AKC source, keeping CKU ratings in the reference.
if ref['facts'].get('traits'):entry['fields']['traits']=ref['facts']['traits'];entry['field_sources']['traits']='akc'
rkc={r['source_key']:r for r in json.loads((ROOT/'rkc_facts.json').read_text())}
for row,entry in zip(rows,entries):
if row['source_key'] in rkc:
r=rkc[row['source_key']]
rkc_ref=references('rkc',r['external_id'],r['url'],r['facts']);rkc_ref['retrieved_at']=r['retrieved_at'];entry['references'].append(rkc_ref)
for k in ['exercise','grooming','life_span']:
if k=='life_span' and entry['fields'].get(k):continue
entry['fields'][k]=r['facts'][k];entry['field_sources'][k]='rkc'
if not entry['fields'].get('summary') and not row.get('summary'):
facts={**row,**entry['fields']};bits=[]
if facts.get('origin'):bits.append('这一犬种起源于'+facts['origin'])
if facts.get('breed_group'):bits.append('在 FCI 体系中归入'+facts['breed_group'])
if facts.get('temperament'):bits.append('常见性格倾向包括'+facts['temperament'])
if bits:entry['fields']['summary']='。'.join(bits)+'。';entry['field_sources']['summary']='editorial'
if row.get('summary'):entry['field_sources']['summary']='editorial'
if row.get('care_notes'):entry['field_sources']['care_notes']='editorial'
if entry['references']:
scores={}
try:scores=json.loads(entry['fields'].get('traits') or row.get('traits') or '{}')
except ValueError:pass
if not entry['fields'].get('exercise') and scores.get('运动活力'):
entry['fields']['exercise']='运动活力指数 '+str(scores['运动活力'])+'/5,结合年龄与体力安排散步和互动';entry['field_sources']['exercise']='editorial'
if not entry['fields'].get('grooming') and scores.get('梳毛需求'):
entry['fields']['grooming']='梳毛需求指数 '+str(scores['梳毛需求'])+'/5,按被毛情况安排梳理';entry['field_sources']['grooming']='editorial'
(ROOT/'enrichment.json').write_text(json.dumps(entries,ensure_ascii=False,indent=2)+'\n')
print('Saved',len(entries),'breeds,',sum(len(e['references']) for e in entries),'references',flush=True)
if __name__=='__main__':main()
+1 -1
View File
@@ -1,5 +1,5 @@
# 管理端 # 管理端
React + Vite + Tailwind + shadcn/ui。`npm ci && npm run build` 会把产物输出到 `../../internal/admin/dist`,由 Go 服务嵌入并提供 `/admin/`。 React + Vite + Tailwind + shadcn/ui。`npm ci && npm run build` 会把产物输出到 `../../internal/admin/dist`,由 Go 服务嵌入并提供 `/`。
开发时执行 `npm run dev`,Vite 会把 `/api` 代理到本地 9090 端口。 开发时执行 `npm run dev`,Vite 会把 `/api` 代理到本地 9090 端口。
+3 -3
View File
@@ -1,10 +1,10 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/brand-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title> <title>肉垫计划 · 管理后台</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+8 -8
View File
@@ -17,7 +17,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^7.18.1", "react-router-dom": "^7.18.4",
"recharts": "^3.10.1", "recharts": "^3.10.1",
"tailwind-merge": "^3.6.0" "tailwind-merge": "^3.6.0"
}, },
@@ -3091,9 +3091,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.18.1", "version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@@ -3113,12 +3113,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.18.1", "version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.18.1" "react-router": "7.18.4"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
+1 -1
View File
@@ -19,7 +19,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^7.18.1", "react-router-dom": "^7.18.4",
"recharts": "^3.10.1", "recharts": "^3.10.1",
"tailwind-merge": "^3.6.0" "tailwind-merge": "^3.6.0"
}, },
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

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

Before

Width:  |  Height:  |  Size: 4.9 KiB

+311 -248
View File
@@ -1,64 +1,111 @@
import { NavLink, Navigate, useNavigate, useLocation, useParams } from "react-router-dom";
import Breeds from "@/components/admin/Breeds";
import { useEffect, useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { import {
Users, Dog,
PawPrint,
Route,
CalendarDays, CalendarDays,
CheckCheck,
Files,
LayoutDashboard,
LoaderCircle,
LogOut, LogOut,
PawPrint,
RefreshCw, RefreshCw,
Route,
Users,
X,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { api, clearToken, getToken, setToken } from "@/lib/api"; import {
Dialog,
type Tab = "overview" | "users" | "pets" | "walks" | "meetups"; DialogContent,
const labels: Record<Tab, string> = { DialogHeader,
overview: "总览", DialogTitle,
users: "用户", } from "@/components/ui/dialog";
pets: "宠物", import {
walks: "遛狗记录", api,
meetups: "约遛活动", clearToken,
}; getToken,
setToken,
type Row,
type Section,
type Stats,
} from "@/lib/api";
import { labels } from "@/components/admin/format";
import Overview from "@/components/admin/Overview";
import Records from "@/components/admin/Records";
import Details from "@/components/admin/Details";
const icons = { const icons = {
overview: PawPrint, overview: LayoutDashboard,
users: Users, users: Users,
pets: PawPrint, pets: PawPrint,
breeds: Dog,
walks: Route, walks: Route,
meetups: CalendarDays, meetups: CalendarDays,
tasks: CheckCheck,
files: Files,
}; };
const fmt = (v: string) => (v ? new Date(v).toLocaleString("zh-CN") : "—"); type Tab = Section | "overview" | "breeds";
export default function App() { export default function App() {
const [token, auth] = useState(getToken()), const routerNavigate = useNavigate(), location = useLocation(), { section } = useParams();
const tab: Tab = section && section in labels ? section as Tab : "overview";
useEffect(() => { setDetail(null); setConfirm(null); setError(""); setNotice(""); setBusy(false); }, [location.pathname]);
const [token, setAuth] = useState(getToken()),
[username, setUsername] = useState("sundynix"), [username, setUsername] = useState("sundynix"),
[password, setPassword] = useState(""); [password, setPassword] = useState("");
const [tab, setTab] = useState<Tab>("overview"), const [revision, refresh] = useState(0),
[items, setItems] = useState<any[]>([]), [stats, setStats] = useState<Stats | null>(null);
[stats, setStats] = useState<Record<string, number>>({});
const [error, setError] = useState(""), const [error, setError] = useState(""),
[busy, setBusy] = useState(false); [busy, setBusy] = useState(false),
async function load() { [actionBusy, setActionBusy] = useState(false),
[notice, setNotice] = useState("");
const [detail, setDetail] = useState<{ section: Section; row: Row } | null>(
null,
),
[confirm, setConfirm] = useState<{ section: Section; row: Row } | null>(
null,
);
useEffect(() => {
const expired = () => {
setAuth("");
setDetail(null);
setConfirm(null);
setError("登录已过期,请重新登录");
};
const sync = () => setAuth(getToken());
window.addEventListener("storage", sync);
window.addEventListener("admin-auth-expired", expired);
return () => { window.removeEventListener("admin-auth-expired", expired); window.removeEventListener("storage", sync); };
}, []);
useEffect(() => {
if (!token || tab !== "overview") return;
const controller = new AbortController();
setBusy(true); setBusy(true);
setError(""); setError("");
try { api
if (tab === "overview") setStats(await api.stats()); .stats(controller.signal)
else setItems(await api[tab]()); .then((value) => {
} catch (e) { if (!controller.signal.aborted) setStats(value);
setError((e as Error).message); })
} finally { .catch((e) => {
setBusy(false); if (!controller.signal.aborted) setError(e.message);
} })
} .finally(() => {
useEffect(() => { if (!controller.signal.aborted) setBusy(false);
if (token) void load(); });
}, [tab, token]); return () => controller.abort();
}, [tab, token, revision]);
async function login(e: FormEvent) { async function login(e: FormEvent) {
e.preventDefault(); e.preventDefault();
if (busy) return;
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
const r = await api.login(username, password); const value = await api.login(username.trim(), password);
setToken(r.token); setToken(value);
auth(r.token); setAuth(value.token);
setPassword("");
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
} finally { } finally {
@@ -66,56 +113,125 @@ export default function App() {
} }
} }
function logout() { function logout() {
void api.logout().catch(() => {});
clearToken(); clearToken();
auth(""); setAuth("");
setPassword(""); setPassword("");
setStats(null);
setDetail(null);
setConfirm(null);
setError("");
setNotice("");
} }
async function toggle(row: any) { function navigate(section: Tab) {
setBusy(false);
routerNavigate(`/admin/${section}`);
setError("");
setNotice("");
}
async function applyAction() {
if (!confirm || actionBusy) return;
setActionBusy(true);
setError("");
try { try {
await api.setUserDisabled(row.id, !row.disabled); if (confirm.section === "users")
await load(); await api.setUserDisabled(confirm.row.id, !confirm.row.disabled);
else await api.cancelMeetup(confirm.row.id);
setNotice(
confirm.section === "users"
? confirm.row.disabled
? "用户已启用"
: "用户已停用,位置分享已关闭"
: "活动已取消",
);
setConfirm(null);
refresh((value) => value + 1);
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
} finally {
setActionBusy(false);
} }
} }
async function cancel(row: any) { const actionTitle =
if (!window.confirm(`取消「${row.title}」?`)) return; confirm?.section === "users"
try { ? confirm.row.disabled
await api.setMeetupStatus(row.id, "cancelled"); ? "启用用户"
await load(); : "停用用户"
} catch (e) { : "取消约遛活动";
setError((e as Error).message); if (section !== "login" && !(section && Object.hasOwn(labels, section))) return <Navigate to="/admin/overview" replace />;
} if (!token && section !== "login") return <Navigate to="/admin/login" replace state={{ from: location.pathname }} />;
if (token && section === "login") {
const from = location.state?.from;
return <Navigate to={typeof from === "string" && /^\/admin\/(overview|users|pets|breeds|walks|meetups|tasks|files)$/.test(from) ? from : "/admin/overview"} replace />;
} }
if (!token) if (!token)
return ( return (
<main className="login-page"> <main className="login-page">
<section className="login-story">
<img src="/brand-logo.png" alt="肉垫计划" />
<p className="eyebrow">PAWPRINT PLAN</p>
<h1>
每一段陪伴,
<br />
都有迹可循。
</h1>
<p>
让宠物的日常更有温度,
<br />
让每一次出门都值得记录。
</p>
<div className="story-tags">
<span>
<PawPrint size={16} />
宠物档案
</span>
<span>
<Route size={16} />
快乐出门
</span>
<span>
<CheckCheck size={16} />
日常照顾
</span>
</div>
</section>
<form className="login-card" onSubmit={login}> <form className="login-card" onSubmit={login}>
<div className="login-mark">🐾</div> <span className="login-mark">
<p className="eyebrow">肉垫计划 · 管理端</p> <PawPrint size={26} />
<h1>欢迎回来</h1> </span>
<p className="muted">照顾每一只小短腿的快乐日常。</p> <p className="eyebrow">肉垫计划 · 管理后台</p>
<label> <h2>欢迎回来</h2>
用户名 <p className="muted">今天也一起,照顾好每一个小日常。</p>
<label htmlFor="username">管理员账号</label>
<Input <Input
id="username"
autoComplete="username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
required required
maxLength={64}
/> />
</label> <label htmlFor="password">密码</label>
<label>
密码
<Input <Input
id="password"
type="password" type="password"
autoComplete="current-password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
/> />
</label> {error && (
{error && <p className="error">{error}</p>} <p className="error-banner" role="alert">
<Button disabled={busy} className="w-full"> {error}
登录管理后台 </p>
)}
<Button disabled={busy} className="w-full login-submit">
{busy && <LoaderCircle size={16} className="animate-spin" />}
{busy ? "正在登录" : "进入管理后台"}
</Button> </Button>
<small className="login-footnote">
肉垫计划 · 给陪伴一个温暖的地方
</small>
</form> </form>
</main> </main>
); );
@@ -123,219 +239,166 @@ export default function App() {
<div className="app-shell"> <div className="app-shell">
<aside className="sidebar"> <aside className="sidebar">
<div className="brand"> <div className="brand">
<span>🐾</span> <img src="/brand-logo.png" alt="" />
<div> <div>
<strong>肉垫计划</strong> <strong>肉垫计划</strong>
<small>ADMIN STUDIO</small> <small>PAWPRINT PLAN</small>
</div> </div>
</div> </div>
<p className="side-caption">工作台</p> <p className="side-caption">陪伴工作台</p>
<nav> <nav aria-label="管理导航">
{(Object.keys(labels) as Tab[]).map((key) => { {(Object.keys(labels) as Tab[]).map((key) => {
const Icon = icons[key]; const Icon = icons[key];
return ( return (
<button <NavLink
to={`/admin/${key}`}
key={key} key={key}
className={tab === key ? "nav-item active" : "nav-item"} className={`nav-item ${tab === key ? "active" : ""}`}
onClick={() => setTab(key)} aria-current={tab === key ? "page" : undefined}
title={labels[key]}
> >
<Icon size={18} /> <Icon size={19} />
{labels[key]} <span>{labels[key]}</span>
</button> {tab === key && <span className="nav-dot" />}
</NavLink>
); );
})} })}
</nav> </nav>
<div className="side-note">
<PawPrint size={22} />
<p>
陪伴它们的每一天,
<br />
都值得记录。
</p>
</div>
<div className="side-bottom"> <div className="side-bottom">
<span>让每次出门都值得记录</span> <span className="admin-indicator">管理员</span>
<button onClick={logout}> <Button variant="ghost" onClick={logout} title="退出登录">
<LogOut size={16} /> 退出登录 <LogOut size={16} />
</button> <span>退出登录</span>
</Button>
</div> </div>
</aside> </aside>
<main className="content"> <main className="content">
<header className="topbar"> <header className="topbar">
<div> <div>
<p className="eyebrow">PAWPRINT DASHBOARD</p> <p className="eyebrow">
肉垫计划 / {tab === "overview" ? "今天的日常" : "数据管理"}
</p>
<h1>{labels[tab]}</h1> <h1>{labels[tab]}</h1>
</div> </div>
<Button variant="outline" onClick={() => void load()} disabled={busy}> <div className="topbar-actions">
<RefreshCw size={16} /> 刷新 <span className="today-date">
{new Date().toLocaleDateString("zh-CN", {
month: "long",
day: "numeric",
weekday: "long",
})}
</span>
<Button
variant="outline"
disabled={busy || actionBusy}
onClick={() => refresh((value) => value + 1)}
>
<RefreshCw size={16} className={busy ? "animate-spin" : ""} />
刷新
</Button> </Button>
<Button
variant="ghost"
className="mobile-logout"
size="icon"
onClick={logout}
aria-label="退出登录"
>
<LogOut size={18} />
</Button>
</div>
</header> </header>
{error && <div className="error-banner">{error}</div>} {error && !confirm && (
<div className="error-banner" role="alert">
{error}
<Button variant="ghost" size="sm" onClick={() => setError("")}>
关闭
</Button>
</div>
)}
{notice && (
<div className="notice" role="status">
<CheckCheck size={16} />
{notice}
<button aria-label="关闭提示" onClick={() => setNotice("")}>
<X size={16} />
</button>
</div>
)}
{tab === "overview" ? ( {tab === "overview" ? (
<> <Overview stats={stats} navigate={navigate} />
<div className="hero"> ) : tab === "breeds" ? (
<div> <Breeds revision={revision} />
<p>每一段陪伴,都有迹可循</p>
<h2>你好,铲屎官管理者 👋</h2>
<span>这里汇总肉垫计划的日常活动。</span>
</div>
<div className="hero-paw">🐕</div>
</div>
<div className="stat-grid">
{(
[
["users", "注册用户", "👥"],
["pets", "宠物档案", "🐶"],
["walks", "遛狗记录", "🐾"],
["meetups", "约遛活动", "🌿"],
] as const
).map(([key, label, icon]) => (
<article className="stat-card" key={key}>
<div className="stat-icon">{icon}</div>
<span>{label}</span>
<strong>{stats[key] ?? "—"}</strong>
</article>
))}
</div>
</>
) : ( ) : (
<section className="table-card"> <Records
<div className="table-head"> key={tab}
<h2>{labels[tab]}</h2> section={tab}
<p>最近 {items.length} 条记录</p> revision={revision}
</div> action={(section, row) => {
<div className="table-scroll"> setError("");
<table> setConfirm({ section, row });
<thead> }}
<tr> detail={(section, row) => setDetail({ section, row })}
{tab === "users" ? ( actionBusy={actionBusy}
<> />
<th>用户</th>
<th>注册时间</th>
<th>状态</th>
<th>操作</th>
</>
) : tab === "pets" ? (
<>
<th>宠物</th>
<th>品种</th>
<th>主人 ID</th>
<th>状态</th>
</>
) : tab === "walks" ? (
<>
<th>记录 ID</th>
<th>用户 ID</th>
<th>开始时间</th>
<th>距离</th>
<th>状态</th>
</>
) : (
<>
<th>活动</th>
<th>地点</th>
<th>时间</th>
<th>人数</th>
<th>状态</th>
<th>操作</th>
</>
)}
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={row.id}>
{tab === "users" ? (
<>
<td>
<b>{row.nickname}</b>
<small>{row.id}</small>
</td>
<td>{fmt(row.created_at)}</td>
<td>
<span
className={
"pill " + (row.disabled ? "red" : "green")
}
>
{row.disabled ? "已停用" : "正常"}
</span>
</td>
<td>
<Button
variant="outline"
size="sm"
onClick={() => void toggle(row)}
>
{row.disabled ? "启用" : "停用"}
</Button>
</td>
</>
) : tab === "pets" ? (
<>
<td>
<b>{row.name}</b>
<small>{row.id}</small>
</td>
<td>{row.breed || row.species}</td>
<td className="mono">{row.user_id}</td>
<td>
<span
className={
"pill " + (row.archived ? "red" : "green")
}
>
{row.archived ? "已归档" : "在用"}
</span>
</td>
</>
) : tab === "walks" ? (
<>
<td className="mono">{row.id}</td>
<td className="mono">{row.user_id}</td>
<td>{fmt(row.started_at)}</td>
<td>{(row.distance_meters / 1000).toFixed(2)} km</td>
<td>
<span className="pill green">{row.status}</span>
</td>
</>
) : (
<>
<td>
<b>{row.title}</b>
<small>{row.id}</small>
</td>
<td>{row.place_name}</td>
<td>{fmt(row.starts_at)}</td>
<td>
{row.participants}/{row.max_people}
</td>
<td>
<span
className={
"pill " +
(row.status === "open" ? "green" : "red")
}
>
{row.status}
</span>
</td>
<td>
{row.status === "open" && (
<Button
variant="outline"
size="sm"
onClick={() => void cancel(row)}
>
取消
</Button>
)}
</td>
</>
)}
</tr>
))}
</tbody>
</table>
{items.length === 0 && (
<div className="empty">还没有记录,新的足迹会出现在这里 🐾</div>
)}
</div>
</section>
)} )}
<footer className="page-footer">
肉垫计划 · 把每一份陪伴,认真记下来
</footer>
</main> </main>
<Details value={detail} close={() => setDetail(null)} />
<Dialog
open={!!confirm}
onOpenChange={(open) => {
if (!open && !actionBusy) {
setConfirm(null);
setError("");
}
}}
>
<DialogContent aria-describedby="action-description">
<DialogHeader>
<DialogTitle>{actionTitle}</DialogTitle>
</DialogHeader>
<p id="action-description" className="confirm-copy">
{confirm?.section === "users"
? confirm.row.disabled
? `确认恢复「${confirm.row.nickname || "该用户"}」的访问权限?`
: `停用「${confirm.row.nickname || "该用户"}」后,将无法使用小程序接口,现有位置分享也会关闭。`
: `确认取消「${confirm?.row.title}」?取消后活动将停止接受报名。`}
</p>
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
<div className="dialog-actions">
<Button
variant="outline"
disabled={actionBusy}
onClick={() => {
setConfirm(null);
setError("");
}}
>
暂不操作
</Button>
<Button disabled={actionBusy} onClick={() => void applyAction()}>
{actionBusy && (
<LoaderCircle size={16} className="animate-spin" />
)}
确认{actionTitle}
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
@@ -0,0 +1,660 @@
import { useEffect, useState, type FormEvent } from "react";
import {
ChevronLeft,
ChevronRight,
LoaderCircle,
Plus,
Search,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import { api, type Breed, type BreedInput, type BreedPage } from "@/lib/api";
const empty: BreedInput = {
name: "",
english_name: "",
aliases: "",
enabled: true,
origin: "",
breed_group: "",
height: "",
weight: "",
summary: "",
temperament: "",
care_notes: "",
fci_number: "",
life_span: "",
exercise: "",
grooming: "",
traits: {},
};
const providerNames: Record<string, string> = {
cku: "CKU",
fci: "FCI",
akc: "AKC",
rkc: "英国皇家犬业俱乐部",
editorial: "编辑整理",
};
const factLabels: Record<string, string> = {
name: "中文名",
english_name: "英文名",
aliases: "别名",
origin: "起源地",
breed_group: "分组",
fci_number: "FCI 编号",
height: "肩高",
weight: "体重",
life_span: "寿命参考",
exercise: "运动参考",
grooming: "梳理参考",
temperament: "性格倾向",
traits: "指数",
size: "体型",
coat_length: "毛长",
sheds: "掉毛情况",
scope: "标准范围",
};
function parseTraits(value: string): Record<string, number> {
try {
return JSON.parse(value) || {};
} catch {
return {};
}
}
export default function Breeds({ revision }: { revision: number }) {
const [page, setPage] = useState(1),
[query, setQuery] = useState(""),
[draft, setDraft] = useState(""),
[initial, setInitial] = useState(""),
[status, setStatus] = useState("");
const [data, setData] = useState<BreedPage | null>(null),
[busy, setBusy] = useState(true),
[retry, setRetry] = useState(0),
[error, setError] = useState(""),
[notice, setNotice] = useState("");
const [sourceView, setSourceView] = useState(false);
const [editing, setEditing] = useState<Breed | "new" | null>(null),
[form, setForm] = useState<BreedInput>(empty),
[confirm, setConfirm] = useState<Breed | null>(null),
[saving, setSaving] = useState(false),
[writeError, setWriteError] = useState("");
useEffect(() => {
const controller = new AbortController();
setBusy(true);
setError("");
api
.breeds(
{ page, page_size: 10, q: query, status, initial },
controller.signal,
)
.then((result) => {
if (controller.signal.aborted) return;
const last = Math.max(1, Math.ceil(result.total / 10));
if (page > last) {
setPage(last);
return;
}
setData(result);
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setBusy(false);
});
return () => controller.abort();
}, [page, query, initial, status, revision, retry]);
function edit(row: Breed | "new") {
setEditing(row);
setSourceView(false);
setForm(
row === "new"
? { ...empty }
: {
name: row.name,
english_name: row.english_name,
aliases: row.aliases,
enabled: row.enabled,
origin: row.origin || "",
breed_group: row.breed_group || "",
height: row.height || "",
weight: row.weight || "",
summary: row.summary || "",
temperament: row.temperament || "",
care_notes: row.care_notes || "",
fci_number: row.fci_number || "",
life_span: row.life_span || "",
exercise: row.exercise || "",
grooming: row.grooming || "",
traits: parseTraits(row.traits),
},
);
setWriteError("");
}
async function save(e: FormEvent) {
e.preventDefault();
if (saving || !editing) return;
setSaving(true);
setWriteError("");
try {
await api.saveBreed(editing === "new" ? "" : editing.id, {
...form,
name: form.name.trim(),
});
setEditing(null);
setNotice("品种资料已保存");
setRetry((v) => v + 1);
} catch (e) {
setWriteError((e as Error).message);
} finally {
setSaving(false);
}
}
async function toggle() {
if (!confirm || saving) return;
setSaving(true);
setWriteError("");
try {
await api.setBreedEnabled(confirm.id, !confirm.enabled);
setNotice(
confirm.enabled ? "品种已停用,已有宠物档案保持原资料" : "品种已启用",
);
setConfirm(null);
setRetry((v) => v + 1);
} catch (e) {
setWriteError((e as Error).message);
} finally {
setSaving(false);
}
}
const pages = Math.max(1, Math.ceil((data?.total || 0) / 10));
return (
<section className="panel records">
<div className="records-heading">
<div>
<h2>狗狗品种</h2>
<p>
{data ? `共 ${data.total} 个品种` : "读取品种中"} · 按中文名称拼音
A–Z 排序
</p>
</div>
<Button onClick={() => edit("new")}>
<Plus size={16} />
新增品种
</Button>
</div>
{notice && (
<div className="notice" role="status">
{notice}
<Button variant="ghost" size="sm" onClick={() => setNotice("")}>
关闭
</Button>
</div>
)}
<div className="toolbar">
<form
className="search-form"
onSubmit={(e) => {
e.preventDefault();
setPage(1);
setQuery(draft.trim());
setRetry((v) => v + 1);
}}
>
<Search size={17} />
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
maxLength={128}
placeholder="搜索品种、别名或拼音"
aria-label="搜索品种、别名或拼音"
/>
<Button variant="outline" disabled={busy}>
搜索
</Button>
</form>
<select
value={status}
aria-label="品种状态"
onChange={(e) => {
setStatus(e.target.value);
setPage(1);
}}
>
<option value="">全部状态</option>
<option value="enabled">已启用</option>
<option value="disabled">已停用</option>
</select>
</div>
<div className="breed-alphabet" aria-label="拼音首字母筛选">
{["", ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ", "#"].map((letter) => (
<button
key={letter}
aria-pressed={initial === letter}
className={initial === letter ? "active" : ""}
onClick={() => {
setInitial(letter);
setPage(1);
}}
>
{letter || "全部"}
</button>
))}
</div>
{error ? (
<div className="empty error-text" role="alert">
<p>{error}</p>
<Button variant="outline" onClick={() => setRetry((v) => v + 1)}>
重新加载
</Button>
</div>
) : busy ? (
<div className="empty" role="status">
<LoaderCircle className="animate-spin" />
<p>正在加载品种</p>
</div>
) : !data?.items.length ? (
<div className="empty">
<p>没有符合条件的品种,可以新增补充。</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
{["字母", "品种", "别名", "来源", "状态", "操作"].map((label) => (
<TableHead key={label}>{label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((row) => (
<TableRow key={row.id}>
<TableCell>
<span className="per-page">{row.initial}</span>
</TableCell>
<TableCell>
<strong>{row.name}</strong>
<small>{row.english_name || row.pinyin}</small>
<small>{row.summary ? "已整理百科" : "百科待补充"}</small>
</TableCell>
<TableCell className="breed-aliases">
{row.aliases || "—"}
</TableCell>
<TableCell>
{row.references?.length ? (
<div className="breed-source-badges">
{row.references.map((ref) => (
<a
key={ref.id}
href={ref.url}
target="_blank"
rel="noreferrer"
className="per-page"
>
{providerNames[ref.provider] || ref.provider}
</a>
))}
</div>
) : row.source_url ? (
<a
href={row.source_url}
target="_blank"
rel="noreferrer"
className="text-action"
>
{row.source}
</a>
) : (
row.source
)}
</TableCell>
<TableCell>
<span className="per-page">
{row.enabled ? "已启用" : "已停用"}
</span>
</TableCell>
<TableCell>
<div className="row-actions">
<Button size="sm" variant="ghost" onClick={() => edit(row)}>
查看 / 编辑
</Button>
<Button
size="sm"
variant="outline"
disabled={saving}
onClick={() => {
setConfirm(row);
setWriteError("");
}}
>
{row.enabled ? "停用" : "启用"}
</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 条"}{" "}
· 每页 10 条
</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>
<Dialog
open={!!editing}
onOpenChange={(open) => {
if (!open && !saving) setEditing(null);
}}
>
<DialogContent className="breed-dialog" aria-describedby="breed-help">
<DialogHeader>
<DialogTitle>
{editing === "new" ? "新增品种" : "编辑品种"}
</DialogTitle>
</DialogHeader>
<p id="breed-help" className="muted">
拼音和 A–Z 分组自动生成。别名用于搜索,例如“边牧”。
</p>
<div className="breed-editor-tabs">
<Button
type="button"
variant={!sourceView ? "default" : "outline"}
onClick={() => setSourceView(false)}
>
展示资料
</Button>
<Button
type="button"
variant={sourceView ? "default" : "outline"}
disabled={editing === "new"}
onClick={() => setSourceView(true)}
>
官方来源 ·{" "}
{editing && editing !== "new"
? editing.references?.length || 0
: 0}
</Button>
</div>
{sourceView && editing && editing !== "new" ? (
<div className="breed-source-list">
<p className="muted">
各机构的分类、标准与指数单独保留。展示资料由编辑整理,保存后后续导入会保留人工修改。
</p>
{editing.references?.length ? (
editing.references.map((ref) => (
<article key={ref.id} className="breed-source-card">
<header>
<strong>
{providerNames[ref.provider] || ref.provider}
</strong>
<a
href={ref.url}
target="_blank"
rel="noreferrer"
className="text-action"
>
查看原始资料
</a>
</header>
<small>
采集于 {ref.retrieved_at} · {ref.external_id}
</small>
<dl>
{Object.entries(ref.facts).map(([key, value]) => (
<div key={key}>
<dt>{factLabels[key] || key}</dt>
<dd>
{key === "traits"
? Object.entries(parseTraits(value))
.map(
([label, score]) => `${label} ${score}/5`,
)
.join(" · ")
: value}
</dd>
</div>
))}
</dl>
</article>
))
) : (
<p className="muted">这是手动录入的犬种,暂未关联官方资料。</p>
)}
</div>
) : (
<form onSubmit={save} className="breed-form">
<label>
中文名称 *
<Input
value={form.name}
required
maxLength={128}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</label>
<label>
英文名称(选填)
<Input
value={form.english_name}
maxLength={128}
onChange={(e) =>
setForm({ ...form, english_name: e.target.value })
}
/>
</label>
<label>
别名(选填,多个用逗号分隔)
<Input
value={form.aliases}
maxLength={512}
onChange={(e) =>
setForm({ ...form, aliases: e.target.value })
}
/>
</label>
<div className="breed-fact-fields">
{(
[
["origin", "起源地"],
["breed_group", "FCI 分组"],
["fci_number", "FCI 编号"],
["life_span", "寿命参考"],
["height", "参考肩高(含单位)"],
["weight", "参考体重(含单位)"],
["exercise", "运动参考"],
["grooming", "毛发护理"],
] as const
).map(([key, label]) => (
<label key={key}>
{label}
<Input
value={form[key]}
maxLength={
["breed_group", "exercise", "grooming"].includes(key)
? 255
: key === "fci_number"
? 16
: 128
}
onChange={(e) =>
setForm({ ...form, [key]: e.target.value })
}
/>
</label>
))}
</div>
<label>
简短介绍
<textarea
value={form.summary}
maxLength={2000}
rows={3}
onChange={(e) =>
setForm({ ...form, summary: e.target.value })
}
/>
</label>
<label>
性格倾向
<Input
value={form.temperament}
maxLength={255}
onChange={(e) =>
setForm({ ...form, temperament: e.target.value })
}
/>
</label>
<div className="breed-fact-fields">
{[
"运动需求",
"运动活力",
"训练接受度",
"掉毛程度",
"吠叫倾向",
"梳毛需求",
"亲近家人",
].map((label) => (
<label key={label}>
{label}
<select
value={form.traits?.[label] || ""}
onChange={(e) => {
const traits = { ...form.traits };
if (e.target.value)
traits[label] = Number(e.target.value);
else delete traits[label];
setForm({ ...form, traits });
}}
>
<option value="">暂无指数</option>
{[1, 2, 3, 4, 5].map((score) => (
<option key={score} value={score}>
{score} / 5
</option>
))}
</select>
</label>
))}
</div>
<label>
相处与照顾笔记
<textarea
value={form.care_notes}
maxLength={2000}
rows={3}
onChange={(e) =>
setForm({ ...form, care_notes: e.target.value })
}
/>
</label>
<label className="breed-enabled">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) =>
setForm({ ...form, enabled: e.target.checked })
}
/>
在小程序品种列表中显示
</label>
{writeError && (
<p className="error-banner" role="alert">
{writeError}
</p>
)}
<div className="dialog-actions">
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => setEditing(null)}
>
取消
</Button>
<Button disabled={saving}>
{saving && (
<LoaderCircle size={16} className="animate-spin" />
)}
保存品种
</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
<Dialog
open={!!confirm}
onOpenChange={(open) => {
if (!open && !saving) setConfirm(null);
}}
>
<DialogContent aria-describedby="breed-toggle-help">
<DialogHeader>
<DialogTitle>{confirm?.enabled ? "停用" : "启用"}品种</DialogTitle>
</DialogHeader>
<p id="breed-toggle-help">
确认{confirm?.enabled ? "停用" : "启用"}「{confirm?.name}
」?停用后小程序不再提供此选项,已有宠物档案保留。
</p>
{writeError && (
<p className="error-banner" role="alert">
{writeError}
</p>
)}
<div className="dialog-actions">
<Button
variant="outline"
disabled={saving}
onClick={() => setConfirm(null)}
>
取消
</Button>
<Button disabled={saving} onClick={() => void toggle()}>
确认
</Button>
</div>
</DialogContent>
</Dialog>
</section>
);
}
@@ -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,57 @@
import type { Section } from "@/lib/api";
export const labels: Record<Section | "overview" | "breeds", string> = {
overview: "工作台",
users: "用户管理",
pets: "宠物档案",
breeds: "狗狗品种",
walks: "遛狗记录",
meetups: "约遛活动",
tasks: "日常任务",
files: "文件管理",
};
export const fmt = (value?: string) =>
value && !Number.isNaN(Date.parse(value))
? new Date(value).toLocaleString("zh-CN", { hour12: false })
: "—";
export const bytes = (value = 0) =>
value >= 1048576
? `${(value / 1048576).toFixed(1)} MB`
: `${(value / 1024).toFixed(1)} KB`;
export const duration = (value = 0) =>
`${Math.floor(value / 3600)}小时 ${Math.floor((value % 3600) / 60)}分钟`;
export const categories: Record<string, string> = {
food: "喂食",
water: "饮水",
walk: "运动",
medicine: "用药",
grooming: "清洁",
other: "其他",
};
export const filters: Partial<Record<Section, [string, string][]>> = {
users: [
["enabled", "正常"],
["disabled", "已停用"],
],
pets: [
["active", "在用"],
["archived", "已归档"],
],
walks: [
["active", "遛狗中"],
["paused", "已暂停"],
["finished", "已结束"],
],
meetups: [
["open", "开放"],
["cancelled", "已取消"],
],
tasks: Object.entries(categories),
};
export const placeholders: Record<Section, string> = {
users: "搜索用户昵称或 ID",
pets: "搜索宠物名字、品种或主人 ID",
walks: "搜索记录 ID 或用户 ID",
meetups: "搜索活动名称、地点或 ID",
tasks: "搜索任务名称、用户或宠物 ID",
files: "搜索文件名、MD5 或 ID",
};
@@ -40,4 +40,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
) )
Button.displayName = 'Button' Button.displayName = 'Button'
export { Button, buttonVariants } export { Button }
+36 -15
View File
@@ -1,11 +1,11 @@
import * as React from 'react' import * as React from "react";
import * as DialogPrimitive from '@radix-ui/react-dialog' import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from 'lucide-react' import { X } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger const DialogTrigger = DialogPrimitive.Trigger;
const DialogClose = DialogPrimitive.Close const DialogClose = DialogPrimitive.Close;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
@@ -16,7 +16,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg rounded-xl', "fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg rounded-xl",
className, className,
)} )}
{...props} {...props}
@@ -24,18 +24,39 @@ const DialogContent = React.forwardRef<
{children} {children}
<DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 hover:opacity-100"> <DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 hover:opacity-100">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">关闭</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPrimitive.Portal> </DialogPrimitive.Portal>
)) ));
DialogContent.displayName = 'DialogContent' DialogContent.displayName = "DialogContent";
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { function DialogHeader({
return <div className={cn('flex flex-col space-y-1.5', className)} {...props} /> className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("flex flex-col space-y-1.5", className)} {...props} />
);
} }
function DialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) { function DialogTitle({
return <h2 className={cn('text-lg font-semibold', className)} {...props} /> className,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
} }
export { Dialog, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogTitle } export {
Dialog,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
};
File diff suppressed because it is too large Load Diff
+310 -17
View File
@@ -1,11 +1,242 @@
import axios from "axios"; import axios from "axios";
const key = "pets_admin_token"; 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 BreedReference {
id: string;
provider: string;
external_id: string;
url: string;
retrieved_at: string;
facts: Record<string, string>;
}
export interface Breed {
id: string;
name: string;
english_name: string;
aliases: string;
fci_number: string;
life_span: string;
exercise: string;
grooming: string;
traits: string;
references?: BreedReference[];
field_sources?: Record<string, string>;
editorial_locked: boolean;
initial: string;
pinyin: string;
enabled: boolean;
source: string;
source_url: string;
origin: string;
breed_group: string;
height: string;
weight: string;
summary: string;
temperament: string;
care_notes: string;
}
export type BreedInput = Pick<
Breed,
| "name"
| "english_name"
| "aliases"
| "enabled"
| "origin"
| "breed_group"
| "height"
| "weight"
| "summary"
| "temperament"
| "care_notes"
| "fci_number"
| "life_span"
| "exercise"
| "grooming"
> & { traits?: Record<string, number> };
export interface BreedPage {
items: Breed[];
total: number;
page: number;
page_size: number;
}
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 }[];
}
export interface Session {
token: string;
refresh_token: string;
expires_at: number;
refresh_expires_at: number;
}
const key = "pets_admin_token",
refreshKey = "pets_admin_refresh_token",
expiryKey = "pets_admin_expires_at";
export const getToken = () => localStorage.getItem(key) || ""; export const getToken = () => localStorage.getItem(key) || "";
export const setToken = (value: string) => localStorage.setItem(key, value); export const setToken = (value: Session) => {
export const clearToken = () => localStorage.removeItem(key); localStorage.setItem(refreshKey, value.refresh_token);
const http = axios.create({ baseURL: "/api" }); localStorage.setItem(expiryKey, String(value.expires_at));
http.interceptors.request.use((config) => { localStorage.setItem(key, value.token);
const token = getToken(); };
export const clearToken = () => {
localStorage.removeItem(key);
localStorage.removeItem(refreshKey);
localStorage.removeItem(expiryKey);
};
const raw = axios.create({ baseURL: "/api", timeout: 20000 });
const http = axios.create({ baseURL: "/api", timeout: 20000 });
let refreshing: Promise<string> | null = null;
function expireSession(usedRefresh: string) {
if (localStorage.getItem(refreshKey) !== usedRefresh) return;
clearToken();
window.dispatchEvent(new Event("admin-auth-expired"));
}
async function performRefresh(): Promise<string> {
const used = localStorage.getItem(refreshKey) || "";
if (!used) {
clearToken();
window.dispatchEvent(new Event("admin-auth-expired"));
throw new Error("登录已过期,请重新登录");
}
try {
const response = await raw.post("/admin/refresh", { refresh_token: used });
if (response.data?.code !== 0 || !response.data.data?.refresh_token)
throw new Error(response.data?.message || "刷新登录失败");
// Logout while the request is in flight must not resurrect the session.
if (localStorage.getItem(refreshKey) !== used) {
if (getToken()) return getToken();
throw new Error("已退出登录");
}
setToken(response.data.data);
return response.data.data.token;
} catch (error) {
if (
axios.isAxiosError(error) &&
[401, 403].includes(error.response?.status || 0)
) {
if (
localStorage.getItem(refreshKey) &&
localStorage.getItem(refreshKey) !== used
)
return getToken();
expireSession(used);
}
throw new Error(
axios.isAxiosError(error)
? error.response?.data?.message || "暂时无法刷新登录,请重试"
: (error as Error).message,
);
}
}
function refreshSession(usedAccess?: string): Promise<string> {
if (refreshing) return refreshing;
const work = () => {
if (usedAccess && getToken() !== usedAccess && getToken())
return Promise.resolve(getToken());
const expires = Number(localStorage.getItem(expiryKey));
if (!usedAccess && expires > Date.now() / 1000 + 60 && getToken())
return Promise.resolve(getToken());
return performRefresh();
};
// Serialize browser tabs as well as requests in this tab when Web Locks are available.
refreshing = (
navigator.locks
? navigator.locks.request("pets-admin-refresh", work)
: work()
).finally(() => {
refreshing = null;
});
return refreshing;
}
http.interceptors.request.use(async (config) => {
let token = getToken();
const expires = Number(localStorage.getItem(expiryKey));
if (
token &&
config.url !== "/admin/login" &&
expires &&
expires <= Date.now() / 1000 + 60
)
token = await refreshSession();
if (token) config.headers.Authorization = `Bearer ${token}`; if (token) config.headers.Authorization = `Bearer ${token}`;
return config; return config;
}); });
@@ -15,21 +246,83 @@ http.interceptors.response.use(
if (body?.code === 0) return body.data; if (body?.code === 0) return body.data;
throw new Error(body?.message || "请求失败"); throw new Error(body?.message || "请求失败");
}, },
(error) => { async (error) => {
if (error.response?.status === 401) clearToken(); if (axios.isCancel(error)) return Promise.reject(error);
throw new Error(error.response?.data?.message || error.message); const config = error.config as
| (import("axios").InternalAxiosRequestConfig & { retried?: boolean })
| undefined;
if (
error.response?.status === 401 &&
config &&
config.url !== "/admin/login" &&
!config.retried
) {
config.retried = true;
const usedAccess = String(config.headers.Authorization || "").replace(
/^Bearer /,
"",
);
const next = await refreshSession(usedAccess);
config.headers.Authorization = `Bearer ${next}`;
return http.request(config);
}
if (error.response?.status === 401 && config?.url !== "/admin/login") {
clearToken();
window.dispatchEvent(new Event("admin-auth-expired"));
}
return Promise.reject(
new Error(
error.response?.data?.message ||
(error.code === "ECONNABORTED"
? "请求超时,请重试"
: error.message || "无法连接服务器,请稍后重试"),
),
);
}, },
); );
export const api = { export const api = {
logout: () => {
const refresh_token = localStorage.getItem(refreshKey);
return refresh_token
? raw.post("/admin/logout", { refresh_token })
: Promise.resolve();
},
breeds: (
params: {
page: number;
page_size: number;
q: string;
status: string;
initial: string;
},
signal?: AbortSignal,
) => http.get<unknown, BreedPage>("/admin/breeds", { params, signal }),
saveBreed: (id: string, data: BreedInput) =>
id
? http.put(`/admin/breeds/${id}`, data)
: http.post("/admin/breeds", data),
setBreedEnabled: (id: string, enabled: boolean) =>
http.put(`/admin/breeds/${id}/enabled`, { enabled }),
login: (username: string, password: string) => login: (username: string, password: string) =>
http.post<any, { token: string }>("/admin/login", { username, password }), http.post<unknown, Session>("/admin/login", {
stats: () => http.get<any, Record<string, number>>("/admin/stats"), username,
users: () => http.get<any, any[]>("/admin/users"), password,
pets: () => http.get<any, any[]>("/admin/pets"), }),
walks: () => http.get<any, any[]>("/admin/walks"), stats: (signal?: AbortSignal) =>
meetups: () => http.get<any, any[]>("/admin/meetups"), http.get<unknown, Stats>("/admin/stats", { signal }),
list: (
section: Section,
params: { page: number; page_size: number; q: string; status: string },
signal?: AbortSignal,
) => http.get<unknown, PageResult>(`/admin/${section}`, { params, signal }),
userPets: (id: string, signal?: AbortSignal) =>
http.get<unknown, Row[]>(`/admin/users/${id}/pets`, { signal }),
walk: (id: string, signal?: AbortSignal) =>
http.get<unknown, { id: string; points: Point[] }>(`/admin/walks/${id}`, {
signal,
}),
setUserDisabled: (id: string, disabled: boolean) => setUserDisabled: (id: string, disabled: boolean) =>
http.put(`/admin/users/${id}/disabled`, { disabled }), http.put(`/admin/users/${id}/disabled`, { disabled }),
setMeetupStatus: (id: string, status: string) => cancelMeetup: (id: string) =>
http.put(`/admin/meetups/${id}/status`, { status }), http.put(`/admin/meetups/${id}/status`, { status: "cancelled" }),
}; };
+7 -4
View File
@@ -1,13 +1,16 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<BrowserRouter basename="/admin"> <BrowserRouter><Routes>
<App /> <Route path="/" element={<Navigate to="/admin/overview" replace />} />
</BrowserRouter> <Route path="/admin" element={<Navigate to="/admin/overview" replace />} />
<Route path="/admin/:section" element={<App />} />
<Route path="*" element={<Navigate to="/admin/overview" replace />} />
</Routes></BrowserRouter>
</StrictMode>, </StrictMode>,
) )
+1 -1
View File
@@ -4,7 +4,7 @@ import path from 'node:path'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
base: '/admin/', base: '/',
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
+22
View File
@@ -0,0 +1,22 @@
# 肉垫计划小程序
用微信开发者工具导入 `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` 为合法请求域名。
底部导航顺序:日常、任务、遛狗、宠物、知识。遛狗页面内切换轨迹记录和约遛;知识提供犬种 A–Z 搜索与百科详情,宠物表单支持选品种或手动填写。访问与刷新 token 均为两小时,到期前自动续期,刷新失效时重新微信登录。
+32 -17
View File
@@ -1,34 +1,49 @@
App({ App({
globalData: { token: "", user: null }, globalData: { token: "", user: null, expiresAt: 0 },
onLaunch() { onLaunch() {
this.globalData.token = wx.getStorageSync("pet_token") || ""; this.globalData.token = wx.getStorageSync("pet_token") || "";
this.globalData.expiresAt = Number(wx.getStorageSync("pet_token_expires_at")) || 0;
// Start immediately; pages await the same promise and display failures themselves.
this.login().catch(() => {});
}, },
async login() { async login() {
if (this.globalData.token) return this.globalData.token; if (this.loginPromise) return this.loginPromise;
if (!this.loginPromise) if (this.globalData.token && (!this.globalData.expiresAt || this.globalData.expiresAt > Date.now() / 1000 + 60)) return this.globalData.token;
this.loginPromise = (async () => { this.loginPromise = (async () => {
const { isSimulator } = require("./utils/config");
const { request } = require("./utils/api"); const { request } = require("./utils/api");
let data; const refreshToken = wx.getStorageSync("pet_refresh_token");
if (refreshToken) {
try { try {
const { code } = await wx.login(); const pair = await request("/auth/refresh", "POST", { refresh_token: refreshToken }, false);
data = await request("/auth/login", "POST", { code }, false); return this.saveSession(pair);
} catch (error) { } catch (error) {
if (!isSimulator) throw error; if (error.status !== 401 && error.status !== 403) throw error;
data = await request( wx.removeStorageSync("pet_refresh_token");
"/auth/dev",
"POST",
{ key: "wechat-devtools", nickname: "本地铲屎官" },
false,
);
} }
this.globalData.token = data.token; }
const { code } = await new Promise((resolve, reject) =>
wx.login({
timeout: 10000,
success: resolve,
fail: () => reject(new Error("暂时无法连接微信,请重新连接")),
}),
);
if (!code) throw new Error("微信登录未取得凭证,请重新连接");
const data = await request("/auth/login", "POST", { code }, false);
this.globalData.user = data.user; this.globalData.user = data.user;
wx.setStorageSync("pet_token", data.token); return this.saveSession(data);
return data.token;
})().finally(() => { })().finally(() => {
this.loginPromise = null; this.loginPromise = null;
}); });
return this.loginPromise; return this.loginPromise;
}, },
saveSession(data) {
if (!data || typeof data.token !== "string" || !data.token) throw new Error("登录未完成,请重新连接");
this.globalData.token = data.token;
this.globalData.expiresAt = Number(data.expires_at) || 0;
wx.setStorageSync("pet_token", data.token);
wx.setStorageSync("pet_token_expires_at", this.globalData.expiresAt);
if (data.refresh_token) wx.setStorageSync("pet_refresh_token", data.refresh_token);
return data.token;
},
}); });
+31 -4
View File
@@ -1,8 +1,35 @@
{ {
"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": [
"window": { "navigationStyle": "custom", "backgroundColor": "#f6f9f6", "backgroundTextStyle": "dark" }, "pages/home/home",
"permission": { "scope.userLocation": { "desc": "遛狗时记录轨迹,并在你主动开启分享后展示实时位置" } }, "pages/walk/walk",
"requiredPrivateInfos": ["getLocation", "chooseLocation"], "pages/tasks/tasks",
"pages/pets/pets",
"pages/pet-edit/pet-edit",
"pages/meetup-edit/meetup-edit",
"pages/share/share",
"pages/walk-records/walk-records",
"pages/knowledge/knowledge",
"pages/breed-detail/breed-detail"
],
"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", "sitemapLocation": "sitemap.json",
"style": "v2", "style": "v2",
"lazyCodeLoading": "requiredComponents" "lazyCodeLoading": "requiredComponents"
+75 -27
View File
@@ -1,14 +1,25 @@
page { page {
background: #f6f9f6; height: 100vh;
color: #173e36; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; background: #fff9f3;
color: #604a40;
font-family:
ui-rounded, "Hiragino Maru Gothic ProN", "PingFang SC", sans-serif;
font-size: 28rpx; font-size: 28rpx;
line-height: 1.5;
}
::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
color: transparent;
} }
view, view,
text, text,
button, button,
input, input,
textarea { textarea,
picker {
box-sizing: border-box; box-sizing: border-box;
} }
button { button {
@@ -18,51 +29,45 @@ button::after {
border: 0; border: 0;
} }
.screen { .screen {
min-height: 100vh; min-height: 100%;
padding: calc(env(safe-area-inset-top) + 36rpx) 32rpx 170rpx; padding: 16rpx 32rpx calc(170rpx + env(safe-area-inset-bottom));
} }
.eyebrow { .eyebrow {
color: #8fa39b; color: #886c5f;
font-size: 22rpx; font-size: 22rpx;
font-weight: 600; font-weight: 600;
letter-spacing: 2rpx; letter-spacing: 2rpx;
} }
.title {
font-size: 48rpx;
font-weight: 800;
line-height: 1.2;
margin: 12rpx 0 26rpx;
}
.section-title { .section-title {
font-size: 34rpx; font-size: 34rpx;
font-weight: 800; font-weight: 600;
margin: 38rpx 0 20rpx; margin: 38rpx 0 20rpx;
} }
.muted { .muted {
color: #8b9f96; color: #886c5f;
} }
.card { .card {
background: #fff; background: #fff;
border-radius: 34rpx; 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; padding: 28rpx;
margin-bottom: 20rpx; margin-bottom: 20rpx;
} }
.primary { .primary {
background: #194f42; background: #b95738;
color: white; color: white;
border-radius: 23rpx; border-radius: 23rpx;
font-size: 28rpx; font-size: 28rpx;
font-weight: 700; font-weight: 600;
padding: 22rpx 30rpx; padding: 22rpx 30rpx;
line-height: 1.3; line-height: 1.3;
} }
.secondary { .secondary {
background: #e7f0e7; background: #fff0e2;
color: #28674e; color: #b95738;
border-radius: 23rpx; border-radius: 23rpx;
font-size: 27rpx; font-size: 27rpx;
font-weight: 700; font-weight: 600;
padding: 20rpx; padding: 20rpx;
} }
.row { .row {
@@ -74,14 +79,14 @@ button::after {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
border-radius: 99rpx; border-radius: 99rpx;
background: #e9f3e8; background: #fff0e2;
color: #36805c; color: #b95738;
padding: 8rpx 18rpx; padding: 8rpx 18rpx;
font-size: 23rpx; font-size: 23rpx;
} }
.empty { .empty {
text-align: center; text-align: center;
color: #9daf9f; color: #886c5f;
padding: 64rpx 24rpx; padding: 64rpx 24rpx;
} }
.field { .field {
@@ -89,12 +94,11 @@ button::after {
} }
.field-label { .field-label {
font-size: 24rpx; font-size: 24rpx;
color: #718b7e; color: #886c5f;
margin-bottom: 12rpx; margin-bottom: 12rpx;
} }
.field input, .field input,
.field textarea, .field textarea {
.field picker {
width: 100%; width: 100%;
background: #fff; background: #fff;
border-radius: 20rpx; border-radius: 20rpx;
@@ -102,6 +106,50 @@ button::after {
font-size: 28rpx; font-size: 28rpx;
min-height: 76rpx; min-height: 76rpx;
} }
.field picker {
display: block;
width: 100%;
}
.field textarea { .field textarea {
height: 170rpx; height: 170rpx;
} }
.completion-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin: 8rpx 0 20rpx;
font-size: 23rpx;
color: #886c5f;
}
.finish-all {
margin: 0;
padding: 12rpx 22rpx;
background: #fff0e2;
color: #b95738;
font-size: 24rpx;
line-height: 1.4;
border-radius: 99rpx;
}
.all-done {
color: #b95738;
}
.task-completed .check {
animation: completion-pop 0.35s ease-out;
}
.task-completed .task-name,
.task-completed .info strong {
color: #9d8779;
}
@keyframes completion-pop {
0% {
transform: scale(0.7);
}
60% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
+7
View File
@@ -0,0 +1,7 @@
Component({
properties: {
kind: { type: String, value: "paw" },
size: { type: Number, value: 48 },
color: { type: String, value: "#b95738" },
},
});
+1
View File
@@ -0,0 +1 @@
{ "component": true }
+13
View File
@@ -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>
+21
View File
@@ -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; }
+88
View File
@@ -0,0 +1,88 @@
const { loadBreeds, groupBreeds } = require("../../utils/breeds");
Component({
properties: {
value: { type: String, value: "" },
breedId: { type: String, value: "" },
},
data: {
visible: false,
loading: false,
error: "",
query: "",
groups: [],
count: 0,
anchor: "",
manual: false,
custom: "",
},
methods: {
async open() {
this.setData({
visible: true,
query: "",
manual: false,
custom: this.data.value,
});
await this.load();
},
close() {
this.setData({ visible: false });
},
noop() {},
async load(event) {
if (this.data.loading) return;
this.setData({ loading: true, error: "" });
try {
this.rows = await loadBreeds(!!event);
this.filter();
} catch (error) {
this.setData({
error: error.message || "品种列表暂不可用,仍可手动填写",
});
} finally {
this.setData({ loading: false });
}
},
filter() {
const groups = groupBreeds(this.rows || [], this.data.query);
this.setData({
groups,
count: groups.reduce((n, g) => n + g.items.length, 0),
});
},
search(e) {
this.setData({ query: e.detail.value, anchor: "" });
this.filter();
},
jump(e) {
this.setData({ anchor: e.currentTarget.dataset.anchor });
},
select(e) {
const { id, name } = e.currentTarget.dataset;
this.triggerEvent("change", { id, name });
this.close();
},
enterManual() {
this.setData({
manual: true,
custom: this.data.query || this.data.value,
});
},
customInput(e) {
this.setData({ custom: e.detail.value });
},
useCustom() {
const name = this.data.custom.trim();
if (!name) {
wx.showToast({ title: "请输入品种名称", icon: "none" });
return;
}
this.triggerEvent("change", { id: null, name });
this.close();
},
clear() {
this.triggerEvent("change", { id: null, name: "" });
this.close();
},
},
});
@@ -0,0 +1 @@
{"component":true}
@@ -0,0 +1,27 @@
<view class="breed-field" bindtap="open" aria-role="button" aria-label="选择或填写狗狗品种"><text class="{{value?'':'placeholder'}}">{{value||'选择品种,也可以手动填写'}}</text><view class="chevron"></view></view>
<root-portal wx:if="{{visible}}"><view class="overlay" catchtap="close" catchtouchmove="noop">
<view class="sheet" catchtap="noop">
<view class="sheet-head"><view><text class="title">{{manual?'填写它的品种':'找到它的品种'}}</text><text class="subtitle">{{manual?'混种、地方犬种也都有自己的名字':'按拼音 A–Z 排序,支持名称、别名和拼音搜索'}}</text></view><view class="close" bindtap="close" aria-role="button" aria-label="关闭">×</view></view>
<block wx:if="{{!manual}}">
<input class="search" value="{{query}}" bindinput="search" placeholder="搜品种,比如:柯基、泰迪、keji" maxlength="128" />
<view wx:if="{{loading}}" class="hint">正在寻找毛孩子的品种…</view>
<view wx:elif="{{error}}" class="hint"><text>{{error}}</text><text class="retry" bindtap="load">重新加载</text></view>
<view wx:else class="catalog">
<scroll-view class="breed-list" scroll-y enhanced show-scrollbar="{{false}}" scroll-into-view="{{anchor}}" scroll-with-animation>
<view wx:for="{{groups}}" wx:for-item="group" wx:key="letter" id="{{group.anchor}}"><view class="letter">{{group.letter}}</view><view wx:for="{{group.items}}" wx:key="id" class="breed-row {{breedId===item.id?'selected':''}}" data-id="{{item.id}}" data-name="{{item.name}}" bindtap="select"><text>{{item.name}}</text><text wx:if="{{breedId===item.id}}" class="selected-label">已选</text></view></view>
<view wx:if="{{!count}}" class="hint">没有找到?可以在下方手动填写</view>
</scroll-view>
<view wx:if="{{!query}}" class="letter-rail"><text wx:for="{{groups}}" wx:key="letter" data-anchor="{{item.anchor}}" bindtap="jump">{{item.letter}}</text></view>
</view>
<view class="footer"><button class="manual-button" bindtap="enterManual">没找到品种?手动填写</button><text class="skip" bindtap="clear">暂不填写</text></view>
</block>
<block wx:else>
<input class="custom-input" value="{{custom}}" bindinput="customInput" placeholder="例如:中华田园犬、柯基混种" maxlength="128" focus="{{true}}" />
<text class="manual-note">填写的名称仅用于这只宠物的档案。</text>
<button class="confirm" bindtap="useCustom">使用这个名称</button>
<view class="back" bindtap="open">返回品种列表</view>
</block>
</view>
</view>
</root-portal>
@@ -0,0 +1,30 @@
:host { display:block; }
.breed-field { box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;gap:18rpx;min-height:104rpx;padding:26rpx;background:#fff;border-radius:20rpx;color:#604a40;font-size:28rpx; }
.breed-field text { flex:1;min-width:0; }
.placeholder { color:#999; }
.chevron { width:12rpx;height:12rpx;border-right:3rpx solid #a48d7e;border-bottom:3rpx solid #a48d7e;transform:rotate(45deg);flex:none; }
.overlay { position:fixed;top:0;bottom:0;left:0;right:0;z-index:100;background:#604a4066;display:flex;align-items:flex-end; }
.sheet { width:100%;box-sizing:border-box;padding:30rpx 32rpx calc(env(safe-area-inset-bottom) + 24rpx);border-radius:36rpx 36rpx 0 0;background:#fff9f3; }
.sheet-head { display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:24rpx;gap:12rpx; }
.title { display:block;font-size:32rpx;font-weight:600;color:#604a40; }
.subtitle { display:block;font-size:21rpx;color:#9d8779;margin-top:8rpx; }
.close { font-size:40rpx;color:#9d8779;line-height:1;padding:8rpx; }
.search,.custom-input { box-sizing:border-box;width:100%;height:92rpx;padding:0 24rpx;border-radius:20rpx;background:#fff;color:#604a40;font-size:26rpx; }
.catalog { position:relative;margin-top:16rpx; }
.breed-list { height:48vh; }
.letter { font-size:24rpx;font-weight:600;color:#b95738;padding:14rpx 20rpx;background:#fff0e2;border-radius:12rpx;margin:10rpx 38rpx 6rpx 0; }
.breed-row { padding:23rpx 46rpx 23rpx 20rpx;font-size:26rpx;color:#604a40;display:flex;gap:12rpx;justify-content:space-between;border-bottom:1rpx solid #eee3da; }
.selected { color:#b95738;background:#fff2e6; }
.selected-label { font-size:21rpx;flex:none; }
.letter-rail { position:absolute;top:0;right:-12rpx;bottom:0;display:flex;flex-direction:column;justify-content:center;width:40rpx; }
.letter-rail text { font-size:19rpx;color:#b95738;text-align:center;line-height:1.25; }
.hint { padding:60rpx 24rpx;text-align:center;color:#9d8779;font-size:24rpx; }
.retry { display:block;margin-top:20rpx;color:#b95738; }
.footer { margin-top:20rpx; }
button { border:0; }
button::after { border:0; }
.manual-button,.confirm { border-radius:20rpx;background:#fff0e2;color:#b95738;font-size:26rpx;line-height:1.4;padding:22rpx; }
.skip,.back { display:block;text-align:center;font-size:23rpx;color:#9d8779;margin-top:20rpx;padding:6rpx; }
.manual-note { display:block;font-size:23rpx;color:#9d8779;margin:20rpx 0 30rpx; }
.confirm { background:#b95738;color:#fff; }
::-webkit-scrollbar { display:none;width:0;height:0; }
+143
View File
@@ -0,0 +1,143 @@
const { secure, toast } = require("../../utils/api");
Component({
options: { styleIsolation: "apply-shared" },
lifetimes: {
attached() {
this.load();
},
},
pageLifetimes: {
show() {
this.load();
},
},
data: {
meetups: [],
pets: [],
selected: [],
active: null,
userId: "",
joined: false,
busy: false,
},
methods: {
async load() {
if (this.loading) return;
this.loading = true;
try {
const [meetups, pets, me] = await Promise.all([
secure("/meetups"),
secure("/pets"),
secure("/me"),
]);
this.setData({
meetups: meetups.map((m) => ({
...m,
date: new Date(m.starts_at).toLocaleString("zh-CN"),
})),
pets,
userId: me.id,
});
} catch (e) {
toast(e);
} finally {
this.loading = false;
}
},
create() {
wx.navigateTo({ url: "/pages/meetup-edit/meetup-edit" });
},
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, 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();
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();
await this.load();
} catch (e) {
toast(e);
} finally {
this.setData({ busy: false });
}
},
},
});
@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"pet-picker": "/components/pet-picker/index"
}
}
@@ -0,0 +1,60 @@
<view class="screen">
<view class="banner">
<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="add" bindtap="create">+ 发起约遛</text>
</view>
<view
wx:for="{{meetups}}"
wx:key="id"
class="card meetup"
data-index="{{index}}"
bindtap="open"
>
<view class="row">
<text class="badge">
{{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="desc">{{item.description}}</view>
</view>
<view wx:if="{{!meetups.length}}" class="empty">
还没有约遛,发起第一场吧
</view>
<button class="primary" bindtap="create">+ 发起一场约遛</button>
</view>
<root-portal wx:if="{{active}}"><view class="overlay">
<view class="sheet">
<view class="row">
<strong>{{active.title}}</strong>
<text bindtap="close">×</text>
</view>
<view class="detail">
<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&&!joined}}">
<view class="select-title">带哪几只?可以多选</view>
<pet-picker pets="{{pets}}" selected="{{selected}}" bind:change="pick" />
<button class="primary" bindtap="join" disabled="{{busy||active.participants>=active.max_people}}">{{active.participants>=active.max_people?'人数已满':'带狗狗报名'}}</button>
</view>
<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>
</root-portal>
@@ -1,37 +1,42 @@
.banner { .banner {
background: #dfeee2; background: #edf2fb;
border-radius: 34rpx; border-radius: 34rpx;
min-height: 245rpx; min-height: 245rpx;
padding: 36rpx; padding: 30rpx;
position: relative; display: flex;
overflow: hidden; align-items: center;
gap: 12rpx;
} }
.banner text, .banner-copy {
.banner strong { flex: 1;
min-width: 0;
}
.banner-copy text,
.banner-title {
display: block; display: block;
position: relative;
z-index: 2;
} }
.banner text { .banner-copy text {
color: #549375; color: #576c88;
font-size: 22rpx; font-size: 22rpx;
} }
.banner strong { .banner-title {
font-size: 35rpx; font-size: 35rpx;
line-height: 1.3; line-height: 1.3;
font-weight: 600;
white-space: nowrap;
}
.banner-title.first {
margin-top: 16rpx; margin-top: 16rpx;
} }
.banner image { .banner image {
width: 290rpx; width: 52%;
height: 220rpx; height: 190rpx;
position: absolute; flex-shrink: 0;
right: -30rpx;
bottom: 0;
} }
.add { .add {
font-size: 24rpx; font-size: 24rpx;
color: #328965; color: #b95738;
font-weight: 700; font-weight: 600;
} }
.meetup strong { .meetup strong {
display: block; display: block;
@@ -39,30 +44,30 @@
margin: 15rpx 0; margin: 15rpx 0;
} }
.badge { .badge {
background: #e8f4ea; background: #edf2fb;
color: #429369; color: #b95738;
border-radius: 99rpx; border-radius: 99rpx;
padding: 8rpx 15rpx; padding: 8rpx 15rpx;
font-size: 22rpx; font-size: 22rpx;
} }
.arrow { .arrow {
font-size: 39rpx; font-size: 39rpx;
color: #a1b2a8; color: #9d8779;
} }
.meta { .meta {
font-size: 24rpx; font-size: 24rpx;
color: #748c7d; color: #886c5f;
margin-top: 9rpx; margin-top: 9rpx;
} }
.desc { .desc {
font-size: 24rpx; font-size: 24rpx;
color: #9aaa9f; color: #886c5f;
margin-top: 15rpx; margin-top: 15rpx;
} }
.overlay { .overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: #102a24a0; background: #604a40a0;
z-index: 30; z-index: 30;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@@ -81,13 +86,13 @@
} }
.detail { .detail {
font-size: 25rpx; font-size: 25rpx;
color: #61816e; color: #886c5f;
line-height: 2; line-height: 2;
margin: 25rpx 0; margin: 25rpx 0;
} }
.select-title { .select-title {
font-size: 25rpx; font-size: 25rpx;
font-weight: 700; font-weight: 600;
margin: 30rpx 0 15rpx; margin: 30rpx 0 15rpx;
} }
.sheet button { .sheet button {
+38
View File
@@ -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 }
+15
View File
@@ -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>
+108
View File
@@ -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;
}
+55
View File
@@ -0,0 +1,55 @@
const { profile, renderCard, saveToAlbum } = require("../../utils/pet-card");
const { toast } = require("../../utils/api");
Component({
properties: { pet: { type: Object, value: {} } },
data: { card: {}, saving: false },
observers: {
pet(value) {
this.setData({ card: profile(value || {}) });
},
},
methods: {
async more() {
if (this.data.saving) return;
try {
const result = await wx.showActionSheet({
itemList: ["编辑档案", "保存身份卡到相册"],
itemColor: "#795a4b",
});
if (result.tapIndex === 0) this.edit();
else if (result.tapIndex === 1) await this.save();
} catch (error) {
if (!/cancel/.test(error.errMsg || "")) toast(error);
}
},
edit() {
this.triggerEvent("edit", { id: this.data.pet.id });
},
async generate() {
const canvas = await new Promise((resolve, reject) =>
this.createSelectorQuery()
.select("#card-canvas")
.fields({ node: true, size: true })
.exec((result) =>
result[0] && result[0].node
? resolve(result[0].node)
: reject(new Error("卡片还未准备好,请重试")),
),
);
return renderCard(canvas, this.data.pet);
},
async save() {
if (this.data.saving) return;
this.setData({ saving: true });
try {
const path = await this.generate();
if (await saveToAlbum(path))
wx.showToast({ title: "身份卡已保存到相册", icon: "success" });
} catch (error) {
toast(error);
} finally {
this.setData({ saving: false });
}
},
},
});
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": { "app-icon": "/components/app-icon/index" }
}
@@ -0,0 +1,9 @@
<view class="identity">
<view class="id-top"><view class="brand"><image src="/assets/logo-corgi-v2.png" /><view><text class="brand-name">肉垫计划</text><text class="brand-en">PAWPRINT ID</text></view></view><view class="id-heading"><text class="id-title">毛孩身份卡</text><button class="more" bindtap="more" disabled="{{saving}}" aria-label="身份卡更多操作"><view></view><view></view><view></view></button></view></view>
<view class="id-body" bindtap="edit" aria-role="button" aria-label="编辑宠物档案">
<view class="portrait"><image wx:if="{{card.avatar_url}}" src="{{card.avatar_url}}" mode="aspectFill" /><app-icon wx:else size="80" color="#d18b67" /></view>
<view class="details"><text class="pet-name">{{card.name}}</text><text class="breed">{{card.breed||'品种未填写'}} · {{card.genderLabel}}</text><view class="facts"><view><text class="label">生日</text><text class="value">{{card.birthday}}</text></view><view><text class="label">体重</text><text class="value">{{card.weightLabel}}</text></view></view><view class="coat"><text class="label">毛色</text><text class="value">{{card.coat||'未填写'}}</text></view></view>
</view>
<view class="id-bottom"><view><text class="label">陪伴编号</text><text class="number">{{card.cardNumber}}</text></view><text class="motto">{{saving?'正在生成身份卡…':'把陪伴,认真记下来'}}</text></view>
</view>
<canvas type="2d" id="card-canvas" class="export-canvas" />
+176
View File
@@ -0,0 +1,176 @@
:host {
display: block;
margin-bottom: 30rpx;
}
.identity {
position: relative;
overflow: hidden;
border-radius: 28rpx;
border: 1rpx solid #ead8ca;
padding: 25rpx;
background: linear-gradient(125deg, #fff3e5, #f0e9f8);
box-shadow: 0 14rpx 35rpx #79543a0b;
color: #604a40;
}
.id-top {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #dfcdbf;
}
.brand {
display: flex;
gap: 9rpx;
align-items: center;
}
.brand image {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
border: 3rpx solid #fffaf4;
box-shadow: 0 0 0 1rpx #dfcdbf;
}
.brand-name,
.brand-en {
display: block;
}
.brand-name {
font-size: 18rpx;
font-weight: 600;
color: #795a4b;
}
.brand-en {
font-size: 11rpx;
letter-spacing: 2rpx;
color: #a48d7e;
}
.id-title {
font-size: 23rpx;
letter-spacing: 3rpx;
font-weight: 600;
}
.id-body {
display: flex;
gap: 24rpx;
padding: 24rpx 0;
}
.portrait {
width: 166rpx;
height: 180rpx;
flex: none;
border: 6rpx solid #fffaf4;
border-radius: 18rpx;
overflow: hidden;
background: #fffaf4;
display: flex;
align-items: center;
justify-content: center;
}
.portrait image {
width: 100%;
height: 100%;
}
.details {
flex: 1;
min-width: 0;
}
.pet-name {
display: block;
font-size: 34rpx;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.breed {
display: block;
font-size: 21rpx;
color: #886c5f;
margin: 2rpx 0 12rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.facts {
display: flex;
gap: 22rpx;
}
.facts > view {
flex: 1;
min-width: 0;
}
.label {
display: block;
font-size: 17rpx;
color: #a48d7e;
}
.value {
display: block;
font-size: 21rpx;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.coat {
margin-top: 9rpx;
}
.id-bottom {
border-top: 1rpx solid #dfcdbf;
padding-top: 15rpx;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 10rpx;
}
.number {
display: block;
font-size: 18rpx;
letter-spacing: 1rpx;
color: #795a4b;
}
.motto {
font-size: 16rpx;
color: #886c5f;
max-width: 200rpx;
text-align: right;
}
.id-heading {
display: flex;
align-items: center;
gap: 14rpx;
}
.more {
display: flex;
align-items: center;
justify-content: center;
gap: 5rpx;
margin: -10rpx -10rpx -10rpx 0;
width: 60rpx;
height: 60rpx;
padding: 0;
background: transparent;
border-radius: 50%;
}
.more::after {
border: 0;
}
.more view {
width: 5rpx;
height: 5rpx;
border-radius: 50%;
background: #9d8779;
}
.more[disabled] {
opacity: 0.4;
background: transparent;
}
.export-canvas {
position: fixed;
left: -2000px;
top: 0;
width: 600px;
height: 380px;
pointer-events: none;
}
+8
View File
@@ -2,6 +2,7 @@ Component({
properties: { properties: {
pets: { type: Array, value: [] }, pets: { type: Array, value: [] },
selected: { type: Array, value: [] }, selected: { type: Array, value: [] },
disabled: { type: Boolean, value: false },
}, },
data: { viewPets: [] }, data: { viewPets: [] },
observers: { observers: {
@@ -16,11 +17,18 @@ Component({
}, },
methods: { methods: {
toggle(e) { toggle(e) {
if (this.data.disabled) return;
const id = e.currentTarget.dataset.id; const id = e.currentTarget.dataset.id;
const selected = this.data.selected.includes(id) const selected = this.data.selected.includes(id)
? this.data.selected.filter((x) => x !== id) ? this.data.selected.filter((x) => x !== id)
: [...this.data.selected, id]; : [...this.data.selected, id];
this.triggerEvent("change", { selected }); 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 });
},
}, },
}); });
+6 -1
View File
@@ -1 +1,6 @@
{ "component": true } {
"component": true,
"usingComponents": {
"app-icon": "/components/app-icon/index"
}
}
+4 -1
View File
@@ -1,4 +1,7 @@
<view class="picker"> <view class="picker">
<view wx:if="{{pets.length>1&&!disabled}}" class="chip select-all" bindtap="selectAll">
<text>✓</text><text>全选 / 清空</text>
</view>
<view <view
wx:for="{{viewPets}}" wx:for="{{viewPets}}"
wx:key="id" wx:key="id"
@@ -7,7 +10,7 @@
bindtap="toggle" bindtap="toggle"
> >
<image wx:if="{{item.avatar_url}}" src="{{item.avatar_url}}" /> <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>{{item.name}}</text>
<text class="check">{{item.chosen?'✓':'+'}}</text> <text class="check">{{item.chosen?'✓':'+'}}</text>
</view> </view>
+9 -5
View File
@@ -8,16 +8,16 @@
align-items: center; align-items: center;
gap: 8rpx; gap: 8rpx;
background: white; background: white;
border: 2rpx solid #e0eae2; border: 2rpx solid #ece2d8;
color: #527164; color: #886c5f;
padding: 11rpx 18rpx; padding: 11rpx 18rpx;
border-radius: 99rpx; border-radius: 99rpx;
font-size: 24rpx; font-size: 24rpx;
} }
.chip.chosen { .chip.chosen {
background: #dff0e1; background: #fff0e2;
border-color: #55a273; border-color: #b95738;
color: #145441; color: #b95738;
font-weight: 700; font-weight: 700;
} }
.chip image { .chip image {
@@ -29,3 +29,7 @@
margin-left: 3rpx; margin-left: 3rpx;
font-weight: 800; font-weight: 800;
} }
.select-all {
border-style: dashed;
color: #b95738;
}
@@ -0,0 +1,8 @@
Component({
properties: { error: String },
methods: {
retry() {
this.triggerEvent("retry");
},
},
});
@@ -0,0 +1 @@
{ "component": true }
@@ -0,0 +1,16 @@
<view class="startup {{error?'failed':''}}">
<view class="glow"></view>
<view class="brand-wrap"><image class="brand" src="/assets/logo-corgi-v2.png" mode="aspectFit" /></view>
<view class="brand-name">肉垫计划</view>
<view class="brand-tagline">给陪伴一个温暖的地方</view>
<view wx:if="{{error}}" class="connection-error">
<view class="message">暂时没能连接</view>
<view class="error-copy">{{error}}</view>
<button class="retry" bindtap="retry">重新连接</button>
</view>
<view wx:else class="preparing" role="status">
<view class="dots"><view></view><view></view><view></view></view>
<view class="message">正在准备今天的小日常</view>
</view>
<view class="footer">陪伴它们的每一天,都值得记录</view>
</view>
+128
View File
@@ -0,0 +1,128 @@
.startup {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 80;
background: linear-gradient(160deg, #fff9f3, #fff0e2 65%, #f7edf7);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx;
box-sizing: border-box;
overflow: hidden;
color: #694c3f;
}
.glow {
position: absolute;
width: 520rpx;
height: 520rpx;
border-radius: 50%;
background: radial-gradient(circle, #ffe1c2, transparent 70%);
top: 18%;
pointer-events: none;
}
.brand-wrap {
position: relative;
animation: breathe 2.4s ease-in-out infinite;
}
.brand {
width: 172rpx;
height: 172rpx;
border-radius: 55rpx;
box-shadow: 0 20rpx 60rpx #bd84551c;
}
.brand-name {
font-size: 45rpx;
font-weight: 600;
letter-spacing: 5rpx;
margin-top: 34rpx;
}
.brand-tagline {
font-size: 23rpx;
color: #9d7965;
margin-top: 14rpx;
letter-spacing: 2rpx;
}
.preparing,
.connection-error {
min-height: 170rpx;
margin-top: 64rpx;
text-align: center;
position: relative;
}
.message {
font-size: 25rpx;
color: #886c5f;
}
.dots {
display: flex;
justify-content: center;
gap: 12rpx;
margin-bottom: 25rpx;
}
.dots view {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #d99a75;
animation: pulse 1.2s ease-in-out infinite;
}
.dots view:nth-child(2) {
animation-delay: 0.15s;
}
.dots view:nth-child(3) {
animation-delay: 0.3s;
}
.error-copy {
font-size: 23rpx;
line-height: 1.8;
color: #9d7965;
margin-top: 15rpx;
max-width: 570rpx;
overflow-wrap: break-word;
}
.retry {
font-size: 25rpx;
background: #b95738;
color: white;
border-radius: 24rpx;
padding: 0 44rpx;
margin-top: 26rpx;
line-height: 82rpx;
}
.retry::after {
border: 0;
}
.failed .brand-wrap {
animation: none;
}
.footer {
position: absolute;
bottom: calc(65rpx + env(safe-area-inset-bottom));
font-size: 20rpx;
color: #a88c78;
letter-spacing: 1rpx;
}
@keyframes breathe {
0%,
100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-10rpx) scale(1.025);
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.35;
transform: scale(0.85);
}
50% {
opacity: 1;
transform: scale(1.1);
}
}
+5 -5
View File
@@ -1,9 +1,9 @@
const tabs = [ const tabs = [
{ path: "/pages/home/home", label: "首页", icon: "⌂" }, { path: "/pages/home/home", label: "日常", key: "daily" },
{ path: "/pages/meetups/meetups", label: "约遛", icon: "♡" }, { path: "/pages/tasks/tasks", label: "任务", key: "tasks" },
{ path: "/pages/walk/walk", label: "遛狗", icon: "🐾" }, { path: "/pages/walk/walk", label: "遛狗", key: "walk" },
{ path: "/pages/tasks/tasks", label: "任务", icon: "☑" }, { path: "/pages/pets/pets", label: "宠物", key: "pets" },
{ path: "/pages/pets/pets", label: "宠物", icon: "♙" }, { path: "/pages/knowledge/knowledge", label: "知识", key: "knowledge" },
]; ];
Component({ Component({
properties: { current: String }, properties: { current: String },
+1 -1
View File
@@ -1 +1 @@
{ "component": true } { "component": true, "usingComponents": { "app-icon": "/components/app-icon/index" } }

Some files were not shown because too many files have changed in this diff Show More