Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3175936266 | ||
|
|
fbc1eda561 | ||
|
|
9d7cf720a5 | ||
|
|
607d709100 | ||
|
|
d6cb582ffe | ||
|
|
b1439b0fe5 | ||
|
|
cb4ffa13e9 | ||
|
|
f0ee63e642 | ||
|
|
42aea10101 | ||
|
|
4916c31edd | ||
|
|
6c37785ce1 | ||
|
|
3655a6a89e | ||
|
|
7fc50f9212 | ||
|
|
c2b51f96e0 | ||
|
|
adfd0574cc | ||
|
|
46ac8f8a47 | ||
|
|
8a3036f0db | ||
|
|
87dfc4f335 | ||
|
|
c72cf24f56 | ||
|
|
5688db4d63 | ||
|
|
fd4f7708ba | ||
|
|
8de48cb44d | ||
|
|
e9828d6149 | ||
|
|
6f065e3783 | ||
|
|
78ab1917f3 | ||
|
|
e102f5c610 | ||
|
|
abc7ad3c84 |
@@ -1,30 +1,41 @@
|
||||
name: deploy
|
||||
name: ci-cd
|
||||
|
||||
# 只认 main(含 PR 合并入 main)。
|
||||
# runner 上构建单个应用镜像 → docker save 打包 → scp 镜像包 + compose 到服务器
|
||||
# → 服务器 docker load + docker compose up -d(用预构建镜像,不在服务器上 build)。
|
||||
# 服务器上只有 compose + .env(+ 临时镜像包),没有源码。
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
on: [push]
|
||||
|
||||
env:
|
||||
REMOTE_DIR: /home/workspace/sundynix-pet
|
||||
IMAGE: sundynix/pets-be:latest
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 检出代码
|
||||
env:
|
||||
GITEA_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fLsS -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"https://git.sundynix.cn/${{ github.repository }}/archive/${{ github.sha }}.tar.gz" \
|
||||
| tar -xz --strip-components=1
|
||||
|
||||
- name: 测试并构建后端与管理端
|
||||
run: docker build -f pets-be/Dockerfile -t "sundynix/pets-be:ci-${{ github.sha }}" pets-be
|
||||
|
||||
deploy:
|
||||
needs: verify
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest # 须匹配已注册 runner 的标签
|
||||
steps:
|
||||
# 不用 actions/checkout(它要去 github.com 下载 action,内网 runner 连不上会 EOF);
|
||||
# 直接从内网 Gitea 用工作流自带 token 拉本仓库当前分支。
|
||||
# Gitea 的 Git HTTP pack 当前不可用,改用带 job token 的归档接口检出当前提交。
|
||||
- name: 检出代码(内网 Gitea)
|
||||
env:
|
||||
GITEA_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
git init -q .
|
||||
git remote add origin "https://token:${{ github.token }}@git.sundynix.cn/${{ github.repository }}.git"
|
||||
git fetch -q --depth 1 origin "${{ github.ref_name }}"
|
||||
git checkout -q FETCH_HEAD
|
||||
set -euo pipefail
|
||||
curl -fLsS -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"https://git.sundynix.cn/${{ github.repository }}/archive/${{ github.sha }}.tar.gz" \
|
||||
| tar -xz --strip-components=1
|
||||
|
||||
# 单镜像:内部先用 node 构建管理后台前端,再 Go 编译内嵌进二进制。
|
||||
# 构建上下文是 pets-be/(go.mod 和 web/admin 都在里面)。
|
||||
@@ -11,9 +11,14 @@ MINIO_SECRET_KEY=sundynix
|
||||
MINIO_BUCKET=sundynix-pet
|
||||
MINIO_USE_SSL=true
|
||||
|
||||
JWT_EXPIRE_HOURS=2
|
||||
JWT_REFRESH_HOURS=2
|
||||
JWT_SECRET=
|
||||
WECHAT_APP_ID=wx52dfc635739a9c19
|
||||
WECHAT_APP_SECRET=
|
||||
ADMIN_USERNAME=sundynix
|
||||
ADMIN_PASSWORD=sundynix
|
||||
TAG=latest
|
||||
|
||||
# 高德 Web 服务 Key
|
||||
AMAP_KEY=
|
||||
|
||||
@@ -5,6 +5,7 @@ services:
|
||||
ports:
|
||||
- "4000:9090"
|
||||
environment:
|
||||
PETS_AMAP_KEY: ${AMAP_KEY:-}
|
||||
PETS_SERVER_PORT: "9090"
|
||||
PETS_SERVER_MODE: release
|
||||
PETS_POSTGRES_HOST: ${POSTGRES_HOST:-192.168.100.127}
|
||||
@@ -19,6 +20,8 @@ services:
|
||||
PETS_MINIO_BUCKET: ${MINIO_BUCKET:-sundynix-pet}
|
||||
PETS_MINIO_USE_SSL: ${MINIO_USE_SSL:-true}
|
||||
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_SECRET: ${WECHAT_APP_SECRET:?}
|
||||
PETS_AUTH_DEV_LOGIN: "false"
|
||||
|
||||
@@ -24,6 +24,7 @@ RUN go mod download
|
||||
COPY . .
|
||||
# 用真实构建产物覆盖 embed 目录(internal/admin/embed.go 里 //go:embed all:dist)
|
||||
COPY --from=admin /app/dist ./internal/admin/dist
|
||||
RUN go test ./...
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/pets-be ./cmd/server
|
||||
|
||||
# ── 2. 运行镜像 ──
|
||||
|
||||
@@ -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`。
|
||||
3. 若数据库还不存在,在 `pets-be` 目录执行一次 `go run ./cmd/dbinit`。
|
||||
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 模式开启。
|
||||
|
||||
@@ -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 控制台页面,不能作为图片直链。
|
||||
|
||||
## 管理端路由与犬种百科
|
||||
|
||||
菜单使用独立路由:`/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 会要求重新登录。时长按业务约束固定两小时,旧环境变量不会把期限拉长。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -19,7 +19,8 @@ minio:
|
||||
|
||||
jwt:
|
||||
secret: "" # 用 PETS_JWT_SECRET 注入
|
||||
expire_hours: 168
|
||||
expire_hours: 2
|
||||
refresh_hours: 2
|
||||
|
||||
wechat:
|
||||
app_id: ""
|
||||
@@ -31,3 +32,7 @@ auth:
|
||||
admin:
|
||||
username: sundynix
|
||||
password: sundynix
|
||||
|
||||
# 高德 Web 服务:首页所在城市与实况天气
|
||||
amap:
|
||||
key: ""
|
||||
|
||||
@@ -18,6 +18,8 @@ minio:
|
||||
use_ssl: true
|
||||
|
||||
jwt:
|
||||
expire_hours: 2
|
||||
refresh_hours: 2
|
||||
secret: kFdKDNCXpyJO2W+htdxobhHNzUIYuwcaZV94aJbIGHQ= # 生产用 PETS_JWT_SECRET 覆盖
|
||||
|
||||
wechat:
|
||||
@@ -30,3 +32,7 @@ auth:
|
||||
admin:
|
||||
username: sundynix
|
||||
password: sundynix
|
||||
|
||||
# 高德 Web 服务:首页所在城市与实况天气
|
||||
amap:
|
||||
key: "c6e0887a6cd0aeaf8f1fd3dd5704b8c3"
|
||||
|
||||
@@ -7,6 +7,7 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/minio/minio-go/v7 v7.2.1
|
||||
github.com/mozillazg/go-pinyin v0.21.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
|
||||
@@ -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/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
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/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
|
||||
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,12 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<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" />
|
||||
<title>admin</title>
|
||||
<script type="module" crossorigin src="/admin/assets/index-DmUsbLpt.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-BQA5NViF.css">
|
||||
<title>肉垫计划 · 管理后台</title>
|
||||
<script type="module" crossorigin src="/assets/index-N2FSM0pD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CkSlqiFn.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// Register 把内嵌的后台 SPA 挂到 /admin 下,index.html 做 SPA fallback
|
||||
// Register 把内嵌的后台 SPA 挂到根路径,保留 /api 给业务接口。
|
||||
func Register(r *gin.Engine) {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
@@ -21,9 +21,7 @@ func Register(r *gin.Engine) {
|
||||
fileServer := http.FileServer(http.FS(sub))
|
||||
|
||||
handler := func(c *gin.Context) {
|
||||
// 去掉前缀 /admin,交给内嵌文件服务器
|
||||
reqPath := strings.TrimPrefix(c.Request.URL.Path, "/admin")
|
||||
reqPath = strings.TrimPrefix(reqPath, "/")
|
||||
reqPath := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||
|
||||
// 若请求的是真实存在的静态资源,直出;否则回退 index.html(前端路由)
|
||||
if reqPath != "" {
|
||||
@@ -33,6 +31,10 @@ func Register(r *gin.Engine) {
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(reqPath, "assets/") {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
// SPA fallback
|
||||
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)
|
||||
}
|
||||
|
||||
r.GET("/admin", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/") })
|
||||
r.GET("/admin/*filepath", handler)
|
||||
r.GET("/", 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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`。内容无变化时跳过资料更新。导入不会恢复管理员改名或停用的品种,人工编辑后的展示内容不会被刷新覆盖。
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -9,7 +9,12 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type AMapConfig struct {
|
||||
Key string `mapstructure:"key"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
AMap AMapConfig `mapstructure:"amap"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Postgres PostgresConfig `mapstructure:"postgres"`
|
||||
MinIO MinIOConfig `mapstructure:"minio"`
|
||||
@@ -49,6 +54,7 @@ type MinIOConfig struct {
|
||||
type JWTConfig struct {
|
||||
Secret string `mapstructure:"secret"`
|
||||
ExpireHours int `mapstructure:"expire_hours"`
|
||||
RefreshHours int `mapstructure:"refresh_hours"`
|
||||
}
|
||||
|
||||
type WeChatConfig struct {
|
||||
@@ -67,6 +73,7 @@ type AdminConfig struct {
|
||||
|
||||
func Load() (*Config, error) {
|
||||
v := viper.New()
|
||||
v.SetDefault("amap.key", "")
|
||||
v.SetDefault("server.port", 9090)
|
||||
v.SetDefault("server.mode", "debug")
|
||||
v.SetDefault("postgres.host", "192.168.100.127")
|
||||
@@ -81,7 +88,8 @@ func Load() (*Config, error) {
|
||||
v.SetDefault("minio.bucket", "sundynix-pet")
|
||||
v.SetDefault("minio.use_ssl", true)
|
||||
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_secret", "")
|
||||
v.SetDefault("auth.dev_login", false)
|
||||
@@ -114,5 +122,8 @@ func Load() (*Config, error) {
|
||||
if cfg.Server.Mode == "release" && cfg.Auth.DevLogin {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/breeds"
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package environment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotConfigured = errors.New("位置和天气服务暂未配置")
|
||||
var ErrCoordinates = errors.New("定位坐标无效")
|
||||
|
||||
type Weather struct {
|
||||
Description string `json:"description"`
|
||||
Temperature string `json:"temperature"`
|
||||
ReportedAt string `json:"reported_at"`
|
||||
}
|
||||
type Result struct {
|
||||
LocationName string `json:"location_name"`
|
||||
City string `json:"city"`
|
||||
District string `json:"district"`
|
||||
Weather *Weather `json:"weather"`
|
||||
}
|
||||
type cached struct {
|
||||
result Result
|
||||
expires time.Time
|
||||
}
|
||||
type Client struct {
|
||||
key string
|
||||
baseURL string
|
||||
http *http.Client
|
||||
mu sync.Mutex
|
||||
cache map[string]cached
|
||||
}
|
||||
|
||||
func New(key string) *Client {
|
||||
return &Client{key: key, baseURL: "https://restapi.amap.com", http: &http.Client{Timeout: 5 * time.Second}, cache: make(map[string]cached)}
|
||||
}
|
||||
|
||||
// AMap returns [] instead of an empty string for some address components.
|
||||
func addressText(raw json.RawMessage) string {
|
||||
var text string
|
||||
_ = json.Unmarshal(raw, &text)
|
||||
return text
|
||||
}
|
||||
func (c *Client) get(ctx context.Context, path string, params url.Values, target any) error {
|
||||
params.Set("key", c.key)
|
||||
params.Set("output", "JSON")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path+"?"+params.Encode(), nil)
|
||||
if err != nil {
|
||||
return errors.New("位置服务请求失败")
|
||||
}
|
||||
res, err := c.http.Do(req)
|
||||
// Never return the transport error: it may contain the URL and API key.
|
||||
if err != nil {
|
||||
return errors.New("位置服务暂时不可用")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return errors.New("位置服务暂时不可用")
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(target); err != nil {
|
||||
return errors.New("位置服务响应无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *Client) Lookup(ctx context.Context, latitude, longitude float64) (Result, error) {
|
||||
if math.IsNaN(latitude) || math.IsNaN(longitude) || math.IsInf(latitude, 0) || math.IsInf(longitude, 0) || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180 {
|
||||
return Result{}, ErrCoordinates
|
||||
}
|
||||
if c.key == "" {
|
||||
return Result{}, ErrNotConfigured
|
||||
}
|
||||
cacheKey := fmt.Sprintf("%.3f,%.3f", longitude, latitude)
|
||||
c.mu.Lock()
|
||||
hit, found := c.cache[cacheKey]
|
||||
c.mu.Unlock()
|
||||
if found && time.Now().Before(hit.expires) {
|
||||
return hit.result, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
var geo struct {
|
||||
Status string `json:"status"`
|
||||
Regeocode struct {
|
||||
Address struct {
|
||||
Province json.RawMessage `json:"province"`
|
||||
City json.RawMessage `json:"city"`
|
||||
District json.RawMessage `json:"district"`
|
||||
Adcode string `json:"adcode"`
|
||||
} `json:"addressComponent"`
|
||||
} `json:"regeocode"`
|
||||
}
|
||||
err := c.get(ctx, "/v3/geocode/regeo", url.Values{"location": {fmt.Sprintf("%.6f,%.6f", longitude, latitude)}, "extensions": {"base"}}, &geo)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if geo.Status != "1" {
|
||||
return Result{}, errors.New("所在城市获取失败,请稍后重试")
|
||||
}
|
||||
address := geo.Regeocode.Address
|
||||
city := addressText(address.City)
|
||||
if city == "" {
|
||||
city = addressText(address.Province)
|
||||
}
|
||||
district := addressText(address.District)
|
||||
if city == "" || address.Adcode == "" {
|
||||
return Result{}, errors.New("当前位置暂不支持天气查询")
|
||||
}
|
||||
result := Result{City: city, District: district, LocationName: city}
|
||||
if district != "" && district != city {
|
||||
result.LocationName += " · " + district
|
||||
}
|
||||
var weather struct {
|
||||
Status string `json:"status"`
|
||||
Lives []struct {
|
||||
Description string `json:"weather"`
|
||||
Temperature string `json:"temperature"`
|
||||
ReportedAt string `json:"reporttime"`
|
||||
} `json:"lives"`
|
||||
}
|
||||
err = c.get(ctx, "/v3/weather/weatherInfo", url.Values{"city": {address.Adcode}, "extensions": {"base"}}, &weather)
|
||||
if err == nil && weather.Status == "1" && len(weather.Lives) > 0 {
|
||||
live := weather.Lives[0]
|
||||
if strings.TrimSpace(live.Description) != "" && live.Temperature != "" {
|
||||
result.Weather = &Weather{live.Description, live.Temperature, live.ReportedAt}
|
||||
}
|
||||
}
|
||||
// Keep the city even if the weather provider fails; retry missing weather next time.
|
||||
if result.Weather != nil {
|
||||
c.mu.Lock()
|
||||
if len(c.cache) >= 256 {
|
||||
clear(c.cache)
|
||||
}
|
||||
c.cache[cacheKey] = cached{result, time.Now().Add(15 * time.Minute)}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package environment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLookupMunicipalityAndCache(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.URL.Query().Get("key") != "test-key" {
|
||||
t.Error("key missing")
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/v3/geocode/regeo":
|
||||
if r.URL.Query().Get("location") != "121.473700,31.230400" {
|
||||
t.Errorf("wrong coordinate order: %s", r.URL.Query().Get("location"))
|
||||
}
|
||||
fmt.Fprint(w, `{"status":"1","regeocode":{"addressComponent":{"province":"上海市","city":[],"district":"黄浦区","adcode":"310101"}}}`)
|
||||
case "/v3/weather/weatherInfo":
|
||||
if r.URL.Query().Get("city") != "310101" {
|
||||
t.Error("weather must use adcode")
|
||||
}
|
||||
fmt.Fprint(w, `{"status":"1","lives":[{"weather":"晴","temperature":"24","reporttime":"2026-10-04 10:00:00"}]}`)
|
||||
default:
|
||||
t.Error("unexpected path")
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
client := New("test-key")
|
||||
client.baseURL = srv.URL
|
||||
for range 2 {
|
||||
result, err := client.Lookup(context.Background(), 31.2304, 121.4737)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.LocationName != "上海市 · 黄浦区" || result.Weather == nil || result.Weather.Temperature != "24" {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Errorf("cache did not reduce calls: %d", calls.Load())
|
||||
}
|
||||
}
|
||||
func TestPartialWeatherFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v3/geocode/regeo" {
|
||||
fmt.Fprint(w, `{"status":"1","regeocode":{"addressComponent":{"province":"浙江省","city":"杭州市","district":"西湖区","adcode":"330106"}}}`)
|
||||
} else {
|
||||
fmt.Fprint(w, `{"status":"0","info":"INVALID_USER_KEY"}`)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
client := New("test-key")
|
||||
client.baseURL = srv.URL
|
||||
result, err := client.Lookup(context.Background(), 30.2, 120.1)
|
||||
if err != nil || result.LocationName != "杭州市 · 西湖区" || result.Weather != nil {
|
||||
t.Fatalf("must retain city without fabricated weather: %+v %v", result, err)
|
||||
}
|
||||
}
|
||||
func TestInvalidInputsAndProviderError(t *testing.T) {
|
||||
client := New("")
|
||||
if _, err := client.Lookup(context.Background(), 30, 120); err != ErrNotConfigured {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, latitude := range []float64{91, math.NaN(), math.Inf(1)} {
|
||||
if _, err := client.Lookup(context.Background(), latitude, 120); err != ErrCoordinates {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"status":"0","info":"INVALID_USER_KEY"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
client = New("secret-key")
|
||||
client.baseURL = srv.URL
|
||||
if _, err := client.Lookup(context.Background(), 30, 120); err == nil {
|
||||
t.Fatal("provider rejection accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,98 +1,468 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"net/http"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type adminPage struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
Items any `json:"items"`
|
||||
}
|
||||
|
||||
func pageParams(c *gin.Context) (adminPage, bool) {
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, sizeErr := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||
if err != nil || sizeErr != nil || page < 1 || page > 1000000 || size < 1 || size > 100 || len([]rune(c.Query("q"))) > 128 {
|
||||
fail(c, http.StatusBadRequest, "分页或搜索参数无效")
|
||||
return adminPage{}, false
|
||||
}
|
||||
return adminPage{Page: page, PageSize: size}, true
|
||||
}
|
||||
func adminRows[T any](c *gin.Context, query *gorm.DB, order string) ([]T, adminPage, bool) {
|
||||
page, valid := pageParams(c)
|
||||
if !valid {
|
||||
return nil, page, false
|
||||
}
|
||||
if err := query.Count(&page.Total).Error; err != nil {
|
||||
fail(c, 500, "读取记录数量失败")
|
||||
return nil, page, false
|
||||
}
|
||||
rows := make([]T, 0)
|
||||
if err := query.Order(order).Offset((page.Page - 1) * page.PageSize).Limit(page.PageSize).Find(&rows).Error; err != nil {
|
||||
fail(c, 500, "读取记录失败")
|
||||
return nil, page, false
|
||||
}
|
||||
return rows, page, true
|
||||
}
|
||||
func search(query *gorm.DB, c *gin.Context, columns ...string) *gorm.DB {
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
if q == "" {
|
||||
return query
|
||||
}
|
||||
// Only allow literal substring search. Columns are constants supplied by handlers.
|
||||
value := "%" + strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(q) + "%"
|
||||
clauses := make([]string, 0, len(columns))
|
||||
args := make([]any, 0, len(columns))
|
||||
for _, column := range columns {
|
||||
clauses = append(clauses, column+" ILIKE ?")
|
||||
args = append(args, value)
|
||||
}
|
||||
return query.Where("("+strings.Join(clauses, " OR ")+")", args...)
|
||||
}
|
||||
func filterStatus(c *gin.Context, query *gorm.DB, column string, allowed ...string) (*gorm.DB, bool) {
|
||||
status := c.Query("status")
|
||||
if status == "" {
|
||||
return query, true
|
||||
}
|
||||
for _, value := range allowed {
|
||||
if status == value {
|
||||
return query.Where(column+" = ?", status), true
|
||||
}
|
||||
}
|
||||
fail(c, 400, "筛选状态无效")
|
||||
return query, false
|
||||
}
|
||||
func (a *API) userNames(ids []string) (map[string]string, error) {
|
||||
names := map[string]string{}
|
||||
if len(ids) == 0 {
|
||||
return names, nil
|
||||
}
|
||||
var users []model.User
|
||||
err := a.db.Select("id", "nickname").Where("id IN ?", ids).Find(&users).Error
|
||||
for _, user := range users {
|
||||
names[user.ID] = user.Nickname
|
||||
}
|
||||
return names, err
|
||||
}
|
||||
func (a *API) adminStats(c *gin.Context) {
|
||||
counts := gin.H{}
|
||||
for _, pair := range []struct {
|
||||
name string
|
||||
value any
|
||||
}{{"users", &model.User{}}, {"pets", &model.Pet{}}, {"walks", &model.Walk{}}, {"meetups", &model.Meetup{}}} {
|
||||
}{{"users", &model.User{}}, {"pets", &model.Pet{}}, {"walks", &model.Walk{}}, {"meetups", &model.Meetup{}}, {"tasks", &model.CareTask{}}, {"files", &model.File{}}} {
|
||||
var n int64
|
||||
a.db.Model(pair.value).Count(&n)
|
||||
if err := a.db.Model(pair.value).Count(&n).Error; err != nil {
|
||||
fail(c, 500, "读取统计失败")
|
||||
return
|
||||
}
|
||||
counts[pair.name] = n
|
||||
}
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
for _, pair := range []struct {
|
||||
name string
|
||||
query *gorm.DB
|
||||
}{
|
||||
{"today_walks", a.db.Model(&model.Walk{}).Where("started_at >= ?", today)},
|
||||
{"active_walks", a.db.Model(&model.Walk{}).Where("status IN ?", []string{"active", "paused"})},
|
||||
{"upcoming_meetups", a.db.Model(&model.Meetup{}).Where("status = ? AND starts_at > ?", "open", now)},
|
||||
{"today_completions", a.db.Model(&model.TaskCompletion{}).Where("date = ?", today)},
|
||||
} {
|
||||
var n int64
|
||||
if pair.query.Count(&n).Error != nil {
|
||||
fail(c, 500, "读取统计失败")
|
||||
return
|
||||
}
|
||||
counts[pair.name] = n
|
||||
}
|
||||
var metrics struct {
|
||||
Distance float64
|
||||
Duration int64
|
||||
Storage int64
|
||||
}
|
||||
if a.db.Model(&model.Walk{}).Select("COALESCE(SUM(distance_meters),0) AS distance, COALESCE(SUM(duration_seconds),0) AS duration").Scan(&metrics).Error != nil || a.db.Model(&model.File{}).Select("COALESCE(SUM(size),0) AS storage").Scan(&metrics).Error != nil {
|
||||
fail(c, 500, "读取统计失败")
|
||||
return
|
||||
}
|
||||
counts["distance_meters"] = metrics.Distance
|
||||
counts["duration_seconds"] = metrics.Duration
|
||||
counts["storage_bytes"] = metrics.Storage
|
||||
type dayCount struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
var grouped []dayCount
|
||||
since := today.AddDate(0, 0, -6)
|
||||
if a.db.Model(&model.Walk{}).Select("to_char(started_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, COUNT(*) AS count").Where("started_at >= ?", since).Group("date").Scan(&grouped).Error != nil {
|
||||
fail(c, 500, "读取趋势失败")
|
||||
return
|
||||
}
|
||||
daily := make([]dayCount, 0, 7)
|
||||
for day := since; !day.After(today); day = day.AddDate(0, 0, 1) {
|
||||
entry := dayCount{Date: day.Format("2006-01-02")}
|
||||
for _, row := range grouped {
|
||||
if row.Date == entry.Date {
|
||||
entry.Count = row.Count
|
||||
}
|
||||
}
|
||||
daily = append(daily, entry)
|
||||
}
|
||||
counts["daily_walks"] = daily
|
||||
ok(c, counts)
|
||||
}
|
||||
func (a *API) adminUsers(c *gin.Context) {
|
||||
var rows []model.User
|
||||
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
|
||||
fail(c, 500, "读取用户失败")
|
||||
query := search(a.db.Model(&model.User{}), c, "nickname", "id::text")
|
||||
switch c.Query("status") {
|
||||
case "":
|
||||
case "enabled":
|
||||
query = query.Where("disabled = false")
|
||||
case "disabled":
|
||||
query = query.Where("disabled = true")
|
||||
default:
|
||||
fail(c, 400, "筛选状态无效")
|
||||
return
|
||||
}
|
||||
rows, page, valid := adminRows[model.User](c, query, "created_at DESC, id DESC")
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
ids := []string{}
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
var totals []struct {
|
||||
UserID string
|
||||
Count int64
|
||||
}
|
||||
if len(ids) > 0 && a.db.Model(&model.Pet{}).Select("user_id, COUNT(*) AS count").Where("user_id IN ?", ids).Group("user_id").Scan(&totals).Error != nil {
|
||||
fail(c, 500, "读取宠物数量失败")
|
||||
return
|
||||
}
|
||||
counts := map[string]int64{}
|
||||
for _, row := range totals {
|
||||
counts[row.UserID] = row.Count
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, u := range rows {
|
||||
v := a.userView(u)
|
||||
v["disabled"] = u.Disabled
|
||||
items = append(items, v)
|
||||
view := a.userView(u)
|
||||
view["disabled"] = u.Disabled
|
||||
view["pet_count"] = counts[u.ID]
|
||||
items = append(items, view)
|
||||
}
|
||||
page.Items = items
|
||||
ok(c, page)
|
||||
}
|
||||
func (a *API) adminUserPets(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if !validID(id) {
|
||||
fail(c, 400, "用户 ID 无效")
|
||||
return
|
||||
}
|
||||
var user model.User
|
||||
if err := a.db.First(&user, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
fail(c, 404, "用户不存在")
|
||||
} else {
|
||||
fail(c, 500, "读取用户失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
var rows []model.Pet
|
||||
if a.db.Where("user_id = ?", id).Order("archived ASC, created_at DESC, id DESC").Find(&rows).Error != nil {
|
||||
fail(c, 500, "读取宠物失败")
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, pet := range rows {
|
||||
view := a.petView(pet)
|
||||
view["archived"] = pet.Archived
|
||||
view["user_id"] = id
|
||||
view["user_name"] = user.Nickname
|
||||
items = append(items, view)
|
||||
}
|
||||
ok(c, items)
|
||||
}
|
||||
func (a *API) adminPets(c *gin.Context) {
|
||||
var rows []model.Pet
|
||||
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
|
||||
fail(c, 500, "读取宠物失败")
|
||||
query := search(a.db.Model(&model.Pet{}), c, "name", "breed", "id::text", "user_id::text")
|
||||
switch c.Query("status") {
|
||||
case "":
|
||||
case "active":
|
||||
query = query.Where("archived = false")
|
||||
case "archived":
|
||||
query = query.Where("archived = true")
|
||||
default:
|
||||
fail(c, 400, "筛选状态无效")
|
||||
return
|
||||
}
|
||||
rows, page, valid := adminRows[model.Pet](c, query, "created_at DESC, id DESC")
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
ids := []string{}
|
||||
for _, p := range rows {
|
||||
ids = append(ids, p.UserID)
|
||||
}
|
||||
names, err := a.userNames(ids)
|
||||
if err != nil {
|
||||
fail(c, 500, "读取主人信息失败")
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, p := range rows {
|
||||
v := a.petView(p)
|
||||
v["user_id"] = p.UserID
|
||||
v["user_name"] = names[p.UserID]
|
||||
v["archived"] = p.Archived
|
||||
items = append(items, v)
|
||||
}
|
||||
ok(c, items)
|
||||
page.Items = items
|
||||
ok(c, page)
|
||||
}
|
||||
func (a *API) adminWalks(c *gin.Context) {
|
||||
var rows []model.Walk
|
||||
if a.db.Order("started_at DESC").Limit(200).Find(&rows).Error != nil {
|
||||
fail(c, 500, "读取遛狗失败")
|
||||
query := search(a.db.Model(&model.Walk{}), c, "id::text", "user_id::text")
|
||||
query, valid := filterStatus(c, query, "status", "active", "paused", "finished")
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
ok(c, rows)
|
||||
rows, page, valid := adminRows[model.Walk](c, query, "started_at DESC, id DESC")
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
ids := []string{}
|
||||
walkIDs := []string{}
|
||||
for _, w := range rows {
|
||||
ids = append(ids, w.UserID)
|
||||
walkIDs = append(walkIDs, w.ID)
|
||||
}
|
||||
names, err := a.userNames(ids)
|
||||
if err != nil {
|
||||
fail(c, 500, "读取用户失败")
|
||||
return
|
||||
}
|
||||
var links []struct {
|
||||
WalkID string
|
||||
PetName string
|
||||
}
|
||||
if len(walkIDs) > 0 && a.db.Model(&model.WalkPet{}).Select("sundynix_walk_pet.walk_id, p.name AS pet_name").Joins("JOIN sundynix_pet p ON p.id = sundynix_walk_pet.pet_id").Where("walk_id IN ?", walkIDs).Order("p.created_at ASC").Scan(&links).Error != nil {
|
||||
fail(c, 500, "读取同行宠物失败")
|
||||
return
|
||||
}
|
||||
pets := map[string][]string{}
|
||||
for _, link := range links {
|
||||
pets[link.WalkID] = append(pets[link.WalkID], link.PetName)
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, w := range rows {
|
||||
items = append(items, gin.H{"id": w.ID, "user_id": w.UserID, "user_name": names[w.UserID], "pet_names": pets[w.ID], "status": w.Status, "started_at": w.StartedAt, "ended_at": w.EndedAt, "duration_seconds": w.DurationSeconds, "distance_meters": w.DistanceMeters, "share_enabled": w.ShareEnabled})
|
||||
}
|
||||
page.Items = items
|
||||
ok(c, page)
|
||||
}
|
||||
func (a *API) adminWalkDetail(c *gin.Context) {
|
||||
if !validID(c.Param("id")) {
|
||||
fail(c, 400, "记录 ID 无效")
|
||||
return
|
||||
}
|
||||
var walk model.Walk
|
||||
if err := a.db.First(&walk, "id = ?", c.Param("id")).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
fail(c, 404, "遛狗记录不存在")
|
||||
} else {
|
||||
fail(c, 500, "读取记录失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
var points []model.WalkPoint
|
||||
if a.db.Where("walk_id = ?", walk.ID).Order("recorded_at ASC, id ASC").Find(&points).Error != nil {
|
||||
fail(c, 500, "读取足迹失败")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"id": walk.ID, "points": points})
|
||||
}
|
||||
func (a *API) adminMeetups(c *gin.Context) {
|
||||
var rows []model.Meetup
|
||||
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
|
||||
fail(c, 500, "读取约遛失败")
|
||||
query := search(a.db.Model(&model.Meetup{}), c, "title", "place_name", "id::text")
|
||||
query, valid := filterStatus(c, query, "status", "open", "cancelled")
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
rows, page, valid := adminRows[model.Meetup](c, query, "created_at DESC, id DESC")
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
ids := []string{}
|
||||
for _, m := range rows {
|
||||
ids = append(ids, m.HostUserID)
|
||||
}
|
||||
names, err := a.userNames(ids)
|
||||
if err != nil {
|
||||
fail(c, 500, "读取发起人失败")
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, m := range rows {
|
||||
items = append(items, a.meetupView(m))
|
||||
v := a.meetupView(m)
|
||||
v["user_name"] = names[m.HostUserID]
|
||||
items = append(items, v)
|
||||
}
|
||||
ok(c, items)
|
||||
page.Items = items
|
||||
ok(c, page)
|
||||
}
|
||||
func (a *API) adminTasks(c *gin.Context) {
|
||||
query := search(a.db.Model(&model.CareTask{}), c, "title", "id::text", "user_id::text", "pet_id::text")
|
||||
query, valid := filterStatus(c, query, "category", "food", "water", "walk", "medicine", "grooming", "other")
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
rows, page, valid := adminRows[model.CareTask](c, query, "created_at DESC, id DESC")
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
ids, petIDs, taskIDs := []string{}, []string{}, []string{}
|
||||
for _, t := range rows {
|
||||
ids = append(ids, t.UserID)
|
||||
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) {
|
||||
var in struct {
|
||||
Disabled bool `json:"disabled"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
}
|
||||
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil {
|
||||
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || in.Disabled == nil {
|
||||
fail(c, 400, "参数无效")
|
||||
return
|
||||
}
|
||||
r := a.db.Model(&model.User{}).Where("id = ?", c.Param("id")).Update("disabled", in.Disabled)
|
||||
if r.Error != nil || r.RowsAffected == 0 {
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&model.User{}).Where("id = ?", c.Param("id")).Update("disabled", *in.Disabled)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
// Disabling an account also revokes any publicly shared locations.
|
||||
if *in.Disabled {
|
||||
return tx.Model(&model.Walk{}).Where("user_id = ?", c.Param("id")).Updates(map[string]any{"share_enabled": false, "share_expires_at": nil}).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
fail(c, 404, "用户不存在")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"disabled": in.Disabled})
|
||||
if err != nil {
|
||||
fail(c, 500, "更新用户失败")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"disabled": *in.Disabled})
|
||||
}
|
||||
func (a *API) adminSetMeetupStatus(c *gin.Context) {
|
||||
var in struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || (in.Status != "open" && in.Status != "cancelled") {
|
||||
fail(c, http.StatusBadRequest, "状态无效")
|
||||
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || in.Status != "cancelled" {
|
||||
fail(c, 400, "状态无效")
|
||||
return
|
||||
}
|
||||
r := a.db.Model(&model.Meetup{}).Where("id = ?", c.Param("id")).Update("status", in.Status)
|
||||
if r.Error != nil || r.RowsAffected == 0 {
|
||||
fail(c, 404, "约遛不存在")
|
||||
result := a.db.Model(&model.Meetup{}).Where("id = ? AND status = ?", c.Param("id"), "open").Update("status", in.Status)
|
||||
if result.Error != nil {
|
||||
fail(c, 500, "更新约遛失败")
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
fail(c, 409, "约遛不存在或已取消")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"status": in.Status})
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/database"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
func TestAdminPageValidation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, query := range []string{"", "?page=2&page_size=10", "?page=0", "?page_size=101", "?page=abc", "?page=-1", "?page_size=0"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
context.Request = httptest.NewRequest(http.MethodGet, "/api/admin/users"+query, nil)
|
||||
page, valid := pageParams(context)
|
||||
expected := query == "" || query == "?page=2&page_size=10"
|
||||
if valid != expected {
|
||||
t.Errorf("%s: valid=%v", query, valid)
|
||||
}
|
||||
if query == "" && (page.Page != 1 || page.PageSize != 10) {
|
||||
t.Fatalf("default pagination: %+v", page)
|
||||
}
|
||||
if !valid && recorder.Code != 400 {
|
||||
t.Errorf("%s: status=%d", query, recorder.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestAdminListsRequireAdminRole(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{}
|
||||
cfg.JWT.Secret = "test-secret"
|
||||
api := New(nil, nil, cfg)
|
||||
router := gin.New()
|
||||
api.Register(router)
|
||||
token, err := api.token(uuid.NewString(), "user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, route := range []string{"users", "users/" + uuid.NewString() + "/pets", "pets", "walks", "walks/" + uuid.NewString(), "meetups", "tasks", "files", "stats"} {
|
||||
for _, auth := range []bool{false, true} {
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/admin/"+route, nil)
|
||||
if auth {
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
expected := http.StatusUnauthorized
|
||||
if auth {
|
||||
expected = http.StatusForbidden
|
||||
}
|
||||
if recorder.Code != expected {
|
||||
t.Errorf("%s auth=%v: status=%d", route, auth, recorder.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Opt-in read-only verification against the configured PostgreSQL; never seeds or changes data.
|
||||
func TestAdminPostgresReadOnly(t *testing.T) {
|
||||
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
|
||||
t.Skip("set PETS_ADMIN_INTEGRATION=1 for PostgreSQL verification")
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := database.Open(cfg.Postgres, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
defer sqlDB.Close()
|
||||
gin.SetMode(gin.TestMode)
|
||||
api := New(db, nil, cfg)
|
||||
router := gin.New()
|
||||
api.Register(router)
|
||||
token, err := api.token(uuid.NewString(), "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
read := func(path string) json.RawMessage {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != 200 {
|
||||
t.Fatalf("GET %s: %d %s", path, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body.Data
|
||||
}
|
||||
read("/api/admin/stats")
|
||||
for _, section := range []string{"users", "pets", "walks", "meetups", "tasks", "files"} {
|
||||
var page struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
PetNames []string `json:"pet_names"`
|
||||
} `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
PageSize int `json:"page_size"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
if err := json.Unmarshal(read("/api/admin/"+section+"?page=1&page_size=10"), &page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Page != 1 || page.PageSize != 10 || len(page.Items) > 10 || page.Total < int64(len(page.Items)) {
|
||||
t.Fatalf("invalid %s pagination: %+v", section, page)
|
||||
}
|
||||
read("/api/admin/" + section + "?page=2&page_size=10")
|
||||
read("/api/admin/" + section + "?q=%25%27%5F")
|
||||
if len(page.Items) > 0 && section == "users" {
|
||||
read("/api/admin/users/" + page.Items[0].ID + "/pets")
|
||||
}
|
||||
if len(page.Items) > 0 && section == "walks" {
|
||||
read("/api/admin/walks/" + page.Items[0].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fixtures live inside a transaction and are always rolled back.
|
||||
func TestAdminPaginationAndWalkRelations(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
|
||||
t.Skip("PostgreSQL integration disabled")
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := database.Open(cfg.Postgres, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
defer sqlDB.Close()
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatal(tx.Error)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
prefix := "admin-test-" + uuid.NewString()
|
||||
users := make([]model.User, 11)
|
||||
for i := range users {
|
||||
users[i] = model.User{Nickname: prefix, OpenID: uuid.NewString()}
|
||||
}
|
||||
if tx.Create(&users).Error != nil {
|
||||
t.Fatal("create temporary users")
|
||||
}
|
||||
pets := []model.Pet{{UserID: users[0].ID, Name: "奶盖", Species: "dog"}, {UserID: users[0].ID, Name: "布丁", Species: "dog"}}
|
||||
if tx.Create(&pets).Error != nil {
|
||||
t.Fatal("create temporary pets")
|
||||
}
|
||||
now := time.Now()
|
||||
walk := model.Walk{UserID: users[0].ID, Status: "finished", StartedAt: now, SegmentStartedAt: now, ShareToken: uuid.NewString()}
|
||||
if err := tx.Create(&walk).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, pet := range pets {
|
||||
if err := tx.Create(&model.WalkPet{WalkID: walk.ID, PetID: pet.ID}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
api := New(tx, nil, cfg)
|
||||
router := gin.New()
|
||||
api.Register(router)
|
||||
token, _ := api.token(uuid.NewString(), "admin")
|
||||
read := func(path string) json.RawMessage {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != 200 {
|
||||
t.Fatalf("%s: %d %s", path, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
json.Unmarshal(recorder.Body.Bytes(), &body)
|
||||
return body.Data
|
||||
}
|
||||
for _, sample := range []struct {
|
||||
page string
|
||||
size int
|
||||
}{{"1", 10}, {"2", 1}} {
|
||||
var result struct {
|
||||
Items []json.RawMessage `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(read("/api/admin/users?q="+prefix+"&page="+sample.page), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Total != 11 || len(result.Items) != sample.size {
|
||||
t.Fatalf("pagination got %d rows / %d total", len(result.Items), result.Total)
|
||||
}
|
||||
}
|
||||
var children []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
json.Unmarshal(read("/api/admin/users/"+users[0].ID+"/pets"), &children)
|
||||
if len(children) != 2 {
|
||||
t.Fatalf("user tree lost pets: %+v", children)
|
||||
}
|
||||
var result struct {
|
||||
Items []struct {
|
||||
UserID string `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
PetNames []string `json:"pet_names"`
|
||||
} `json:"items"`
|
||||
}
|
||||
json.Unmarshal(read("/api/admin/walks?q="+walk.ID), &result)
|
||||
if len(result.Items) != 1 || result.Items[0].UserID != users[0].ID || result.Items[0].UserName != prefix || len(result.Items[0].PetNames) != 2 {
|
||||
t.Fatalf("walk relation incomplete: %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -57,12 +59,13 @@ func (a *API) adminLogin(c *gin.Context) {
|
||||
fail(c, http.StatusUnauthorized, "用户名或密码错误")
|
||||
return
|
||||
}
|
||||
token, err := a.token(admin.ID, "admin")
|
||||
pair, err := a.newSession(admin.ID, "admin")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "签发令牌失败")
|
||||
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) {
|
||||
@@ -79,8 +82,9 @@ func (a *API) devLogin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(in.Nickname)
|
||||
if name == "" {
|
||||
name = "柯基家长"
|
||||
if len([]rune(name)) > 64 {
|
||||
fail(c, http.StatusBadRequest, "昵称不能超过 64 字")
|
||||
return
|
||||
}
|
||||
user, err := a.findOrCreateUser("dev:"+in.Key, name)
|
||||
if err != nil {
|
||||
@@ -123,7 +127,7 @@ func (a *API) login(c *gin.Context) {
|
||||
fail(c, http.StatusUnauthorized, "微信登录凭证无效")
|
||||
return
|
||||
}
|
||||
user, err := a.findOrCreateUser(wx.OpenID, "柯基家长")
|
||||
user, err := a.findOrCreateUser(wx.OpenID, "")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "登录失败")
|
||||
return
|
||||
@@ -140,28 +144,73 @@ func (a *API) findOrCreateUser(openID, nickname string) (model.User, error) {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return user, err
|
||||
}
|
||||
user = model.User{OpenID: openID, Nickname: nickname}
|
||||
if err := a.db.Create(&user).Error; err != nil {
|
||||
// A concurrent login may have inserted the same open_id.
|
||||
if lookup := a.db.Where("open_id = ?", openID).First(&user).Error; lookup == nil {
|
||||
return user, nil
|
||||
// Serialize new-user naming across server instances without requiring custom nicknames to be unique.
|
||||
err = a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(7270139527)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 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, 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) {
|
||||
if user.Disabled {
|
||||
fail(c, http.StatusForbidden, "账号已停用")
|
||||
return
|
||||
}
|
||||
token, err := a.token(user.ID, "user")
|
||||
pair, err := a.newSession(user.ID, "user")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "签发令牌失败")
|
||||
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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (a *API) petView(pet model.Pet) gin.H {
|
||||
if pet.BirthDate != nil {
|
||||
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,
|
||||
"avatar_file_id": pet.AvatarFileID, "avatar_url": a.fileURL(pet.AvatarFileID),
|
||||
"coat": pet.Coat, "allergies": pet.Allergies, "care_notes": pet.CareNotes,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ type petInput struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Species string `json:"species"`
|
||||
Breed string `json:"breed"`
|
||||
BreedID *string `json:"breed_id"`
|
||||
Gender string `json:"gender"`
|
||||
BirthDate string `json:"birth_date"`
|
||||
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.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
|
||||
if err := a.db.Save(&pet).Error; err != nil {
|
||||
fail(c, http.StatusInternalServerError, "更新宠物失败")
|
||||
@@ -110,6 +112,27 @@ func (a *API) buildPet(c *gin.Context, in petInput) (model.Pet, bool) {
|
||||
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(¤t).Error != nil {
|
||||
fail(c, http.StatusBadRequest, "该品种已停用,请手动填写或重新选择")
|
||||
return pet, false
|
||||
}
|
||||
}
|
||||
in.Breed = breed.Name
|
||||
}
|
||||
var birthday *time.Time
|
||||
if 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
|
||||
}
|
||||
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}
|
||||
return pet, true
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,20 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/environment"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"github.com/sundynix/pets-be/internal/storage"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
env *environment.Client
|
||||
db *gorm.DB
|
||||
storage *storage.Storage
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, storage *storage.Storage, cfg *config.Config) *API {
|
||||
return &API{db: db, storage: storage, cfg: cfg}
|
||||
return &API{db: db, storage: storage, cfg: cfg, env: environment.New(cfg.AMap.Key)}
|
||||
}
|
||||
|
||||
func (a *API) Register(r *gin.Engine) {
|
||||
@@ -35,10 +37,17 @@ func (a *API) Register(r *gin.Engine) {
|
||||
api.POST("/auth/login", a.login)
|
||||
api.POST("/auth/dev", a.devLogin)
|
||||
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.Use(a.requireRole("user"))
|
||||
user.GET("/environment", a.environment)
|
||||
user.GET("/me", a.me)
|
||||
user.GET("/breeds", a.listBreeds)
|
||||
user.GET("/breeds/:id", a.breedDetail)
|
||||
user.GET("/pets", a.listPets)
|
||||
user.POST("/pets", a.createPet)
|
||||
user.GET("/pets/:id", a.getPet)
|
||||
@@ -51,6 +60,7 @@ func (a *API) Register(r *gin.Engine) {
|
||||
user.PUT("/tasks/:id", a.updateTask)
|
||||
user.DELETE("/tasks/:id", a.deleteTask)
|
||||
user.PUT("/tasks/:id/completion", a.setTaskCompletion)
|
||||
user.PUT("/tasks/completions", a.completeTasks)
|
||||
user.GET("/walks", a.listWalks)
|
||||
user.POST("/walks", a.startWalk)
|
||||
user.GET("/walks/:id", a.getWalk)
|
||||
@@ -68,28 +78,34 @@ func (a *API) Register(r *gin.Engine) {
|
||||
|
||||
admin := api.Group("/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("/users", a.adminUsers)
|
||||
admin.GET("/users/:id/pets", a.adminUserPets)
|
||||
admin.GET("/pets", a.adminPets)
|
||||
admin.GET("/walks", a.adminWalks)
|
||||
admin.GET("/walks/:id", a.adminWalkDetail)
|
||||
admin.GET("/meetups", a.adminMeetups)
|
||||
admin.GET("/tasks", a.adminTasks)
|
||||
admin.GET("/files", a.adminFiles)
|
||||
admin.PUT("/users/:id/disabled", a.adminSetUserDisabled)
|
||||
admin.PUT("/meetups/:id/status", a.adminSetMeetupStatus)
|
||||
}
|
||||
|
||||
type claims struct {
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"sid,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func (a *API) token(id, role string) (string, error) {
|
||||
expire := a.cfg.JWT.ExpireHours
|
||||
if expire <= 0 {
|
||||
expire = 168
|
||||
}
|
||||
func (a *API) token(id, role string) (string, error) { return a.sessionToken(id, role, "") }
|
||||
func (a *API) sessionToken(id, role, sessionID string) (string, error) {
|
||||
now := time.Now()
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{Role: role, RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: id, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(expire) * time.Hour)),
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{Role: role, SessionID: sessionID, RegisteredClaims: jwt.RegisteredClaims{
|
||||
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))
|
||||
}
|
||||
@@ -119,6 +135,20 @@ func (a *API) requireRole(role string) gin.HandlerFunc {
|
||||
c.Abort()
|
||||
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" {
|
||||
var user model.User
|
||||
if err := a.db.Select("id", "disabled").First(&user, "id = ?", cl.Subject).Error; err != nil || user.Disabled {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"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]$`)
|
||||
|
||||
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"`
|
||||
Category string `json:"category"`
|
||||
TimeOfDay string `json:"time_of_day"`
|
||||
@@ -49,7 +51,7 @@ func (a *API) listTasks(c *gin.Context) {
|
||||
fail(c, http.StatusNotFound, "宠物不存在")
|
||||
return
|
||||
}
|
||||
query = query.Where("pet_id = ?", petID)
|
||||
query = query.Where("(pet_id = ? OR pet_id IS NULL)", petID)
|
||||
}
|
||||
var tasks []model.CareTask
|
||||
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 {
|
||||
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,
|
||||
"repeat_rule": task.RepeatRule, "reminder_enabled": task.ReminderEnabled,
|
||||
"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) {
|
||||
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, "宠物不存在")
|
||||
return task, false
|
||||
} else {
|
||||
petID = &in.PetID
|
||||
}
|
||||
in.Title = strings.TrimSpace(in.Title)
|
||||
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, "重复规则无效")
|
||||
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,
|
||||
ReminderEnabled: in.ReminderEnabled}
|
||||
return task, true
|
||||
@@ -179,11 +198,6 @@ func (a *API) deleteTask(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 {
|
||||
Date string `json:"date" binding:"required"`
|
||||
Completed bool `json:"completed"`
|
||||
@@ -192,20 +206,82 @@ func (a *API) setTaskCompletion(c *gin.Context) {
|
||||
fail(c, http.StatusBadRequest, "完成状态参数无效")
|
||||
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)
|
||||
if err != nil || !due(task, date) {
|
||||
fail(c, http.StatusBadRequest, "该日期没有这项任务")
|
||||
return
|
||||
}
|
||||
if in.Completed {
|
||||
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, "保存完成状态失败")
|
||||
return
|
||||
}
|
||||
} else if err := a.db.Where("task_id = ? AND date = ?", task.ID, date).Delete(&model.TaskCompletion{}).Error; err != nil {
|
||||
fail(c, http.StatusInternalServerError, "取消完成失败")
|
||||
ok(c, gin.H{"id": task.ID, "date": in.Date, "completed": true})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -47,12 +47,64 @@ type Admin struct {
|
||||
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 {
|
||||
Base
|
||||
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
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"`
|
||||
BirthDate *time.Time `gorm:"type:date" json:"birth_date"`
|
||||
WeightKg float64 `gorm:"type:numeric(6,2)" json:"weight_kg"`
|
||||
@@ -66,7 +118,7 @@ type Pet struct {
|
||||
type CareTask struct {
|
||||
Base
|
||||
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"`
|
||||
Category string `gorm:"size:32;not null" json:"category"`
|
||||
TimeOfDay string `gorm:"size:5;not null" json:"time_of_day"`
|
||||
@@ -140,7 +192,7 @@ type MeetupPet struct {
|
||||
|
||||
func All() []any {
|
||||
return []any{
|
||||
&File{}, &User{}, &Admin{}, &Pet{}, &CareTask{}, &TaskCompletion{},
|
||||
&File{}, &User{}, &Admin{}, &RefreshSession{}, &Breed{}, &BreedReference{}, &Pet{}, &CareTask{}, &TaskCompletion{},
|
||||
&Walk{}, &WalkPet{}, &WalkPoint{}, &Meetup{}, &MeetupParticipant{}, &MeetupPet{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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 端口。
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<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" />
|
||||
<title>admin</title>
|
||||
<title>肉垫计划 · 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
@@ -3091,9 +3091,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
|
||||
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
|
||||
"version": "7.18.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
|
||||
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -3113,12 +3113,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.18.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
|
||||
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
|
||||
"version": "7.18.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
|
||||
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.18.1"
|
||||
"react-router": "7.18.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
|
||||
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -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 {
|
||||
Users,
|
||||
PawPrint,
|
||||
Route,
|
||||
Dog,
|
||||
CalendarDays,
|
||||
CheckCheck,
|
||||
Files,
|
||||
LayoutDashboard,
|
||||
LoaderCircle,
|
||||
LogOut,
|
||||
PawPrint,
|
||||
RefreshCw,
|
||||
Route,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api, clearToken, getToken, setToken } from "@/lib/api";
|
||||
|
||||
type Tab = "overview" | "users" | "pets" | "walks" | "meetups";
|
||||
const labels: Record<Tab, string> = {
|
||||
overview: "总览",
|
||||
users: "用户",
|
||||
pets: "宠物",
|
||||
walks: "遛狗记录",
|
||||
meetups: "约遛活动",
|
||||
};
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
api,
|
||||
clearToken,
|
||||
getToken,
|
||||
setToken,
|
||||
type Row,
|
||||
type Section,
|
||||
type Stats,
|
||||
} from "@/lib/api";
|
||||
import { labels } from "@/components/admin/format";
|
||||
import Overview from "@/components/admin/Overview";
|
||||
import Records from "@/components/admin/Records";
|
||||
import Details from "@/components/admin/Details";
|
||||
const icons = {
|
||||
overview: PawPrint,
|
||||
overview: LayoutDashboard,
|
||||
users: Users,
|
||||
pets: PawPrint,
|
||||
breeds: Dog,
|
||||
walks: Route,
|
||||
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() {
|
||||
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"),
|
||||
[password, setPassword] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("overview"),
|
||||
[items, setItems] = useState<any[]>([]),
|
||||
[stats, setStats] = useState<Record<string, number>>({});
|
||||
const [revision, refresh] = useState(0),
|
||||
[stats, setStats] = useState<Stats | null>(null);
|
||||
const [error, setError] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
async function load() {
|
||||
[busy, setBusy] = useState(false),
|
||||
[actionBusy, setActionBusy] = useState(false),
|
||||
[notice, setNotice] = useState("");
|
||||
const [detail, setDetail] = useState<{ section: Section; row: Row } | null>(
|
||||
null,
|
||||
),
|
||||
[confirm, setConfirm] = useState<{ section: Section; row: Row } | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
const expired = () => {
|
||||
setAuth("");
|
||||
setDetail(null);
|
||||
setConfirm(null);
|
||||
setError("登录已过期,请重新登录");
|
||||
};
|
||||
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);
|
||||
setError("");
|
||||
try {
|
||||
if (tab === "overview") setStats(await api.stats());
|
||||
else setItems(await api[tab]());
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (token) void load();
|
||||
}, [tab, token]);
|
||||
api
|
||||
.stats(controller.signal)
|
||||
.then((value) => {
|
||||
if (!controller.signal.aborted) setStats(value);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!controller.signal.aborted) setError(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setBusy(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [tab, token, revision]);
|
||||
async function login(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const r = await api.login(username, password);
|
||||
setToken(r.token);
|
||||
auth(r.token);
|
||||
const value = await api.login(username.trim(), password);
|
||||
setToken(value);
|
||||
setAuth(value.token);
|
||||
setPassword("");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
@@ -66,56 +113,125 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
function logout() {
|
||||
void api.logout().catch(() => {});
|
||||
clearToken();
|
||||
auth("");
|
||||
setAuth("");
|
||||
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 {
|
||||
await api.setUserDisabled(row.id, !row.disabled);
|
||||
await load();
|
||||
if (confirm.section === "users")
|
||||
await api.setUserDisabled(confirm.row.id, !confirm.row.disabled);
|
||||
else await api.cancelMeetup(confirm.row.id);
|
||||
setNotice(
|
||||
confirm.section === "users"
|
||||
? confirm.row.disabled
|
||||
? "用户已启用"
|
||||
: "用户已停用,位置分享已关闭"
|
||||
: "活动已取消",
|
||||
);
|
||||
setConfirm(null);
|
||||
refresh((value) => value + 1);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
async function cancel(row: any) {
|
||||
if (!window.confirm(`取消「${row.title}」?`)) return;
|
||||
try {
|
||||
await api.setMeetupStatus(row.id, "cancelled");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
const actionTitle =
|
||||
confirm?.section === "users"
|
||||
? confirm.row.disabled
|
||||
? "启用用户"
|
||||
: "停用用户"
|
||||
: "取消约遛活动";
|
||||
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)
|
||||
return (
|
||||
<main className="login-page">
|
||||
<section className="login-story">
|
||||
<img src="/brand-logo.png" alt="肉垫计划" />
|
||||
<p className="eyebrow">PAWPRINT PLAN</p>
|
||||
<h1>
|
||||
每一段陪伴,
|
||||
<br />
|
||||
都有迹可循。
|
||||
</h1>
|
||||
<p>
|
||||
让宠物的日常更有温度,
|
||||
<br />
|
||||
让每一次出门都值得记录。
|
||||
</p>
|
||||
<div className="story-tags">
|
||||
<span>
|
||||
<PawPrint size={16} />
|
||||
宠物档案
|
||||
</span>
|
||||
<span>
|
||||
<Route size={16} />
|
||||
快乐出门
|
||||
</span>
|
||||
<span>
|
||||
<CheckCheck size={16} />
|
||||
日常照顾
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
<form className="login-card" onSubmit={login}>
|
||||
<div className="login-mark">🐾</div>
|
||||
<p className="eyebrow">肉垫计划 · 管理端</p>
|
||||
<h1>欢迎回来</h1>
|
||||
<p className="muted">照顾每一只小短腿的快乐日常。</p>
|
||||
<label>
|
||||
用户名
|
||||
<span className="login-mark">
|
||||
<PawPrint size={26} />
|
||||
</span>
|
||||
<p className="eyebrow">肉垫计划 · 管理后台</p>
|
||||
<h2>欢迎回来</h2>
|
||||
<p className="muted">今天也一起,照顾好每一个小日常。</p>
|
||||
<label htmlFor="username">管理员账号</label>
|
||||
<Input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
maxLength={64}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
密码
|
||||
<label htmlFor="password">密码</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<Button disabled={busy} className="w-full">
|
||||
登录管理后台
|
||||
{error && (
|
||||
<p className="error-banner" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button disabled={busy} className="w-full login-submit">
|
||||
{busy && <LoaderCircle size={16} className="animate-spin" />}
|
||||
{busy ? "正在登录" : "进入管理后台"}
|
||||
</Button>
|
||||
<small className="login-footnote">
|
||||
肉垫计划 · 给陪伴一个温暖的地方
|
||||
</small>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
@@ -123,219 +239,166 @@ export default function App() {
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<span>🐾</span>
|
||||
<img src="/brand-logo.png" alt="" />
|
||||
<div>
|
||||
<strong>肉垫计划</strong>
|
||||
<small>ADMIN STUDIO</small>
|
||||
<small>PAWPRINT PLAN</small>
|
||||
</div>
|
||||
</div>
|
||||
<p className="side-caption">工作台</p>
|
||||
<nav>
|
||||
<p className="side-caption">陪伴工作台</p>
|
||||
<nav aria-label="管理导航">
|
||||
{(Object.keys(labels) as Tab[]).map((key) => {
|
||||
const Icon = icons[key];
|
||||
return (
|
||||
<button
|
||||
<NavLink
|
||||
to={`/admin/${key}`}
|
||||
key={key}
|
||||
className={tab === key ? "nav-item active" : "nav-item"}
|
||||
onClick={() => setTab(key)}
|
||||
className={`nav-item ${tab === key ? "active" : ""}`}
|
||||
aria-current={tab === key ? "page" : undefined}
|
||||
title={labels[key]}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{labels[key]}
|
||||
</button>
|
||||
<Icon size={19} />
|
||||
<span>{labels[key]}</span>
|
||||
{tab === key && <span className="nav-dot" />}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="side-note">
|
||||
<PawPrint size={22} />
|
||||
<p>
|
||||
陪伴它们的每一天,
|
||||
<br />
|
||||
都值得记录。
|
||||
</p>
|
||||
</div>
|
||||
<div className="side-bottom">
|
||||
<span>让每次出门都值得记录</span>
|
||||
<button onClick={logout}>
|
||||
<LogOut size={16} /> 退出登录
|
||||
</button>
|
||||
<span className="admin-indicator">管理员</span>
|
||||
<Button variant="ghost" onClick={logout} title="退出登录">
|
||||
<LogOut size={16} />
|
||||
<span>退出登录</span>
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="content">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">PAWPRINT DASHBOARD</p>
|
||||
<p className="eyebrow">
|
||||
肉垫计划 / {tab === "overview" ? "今天的日常" : "数据管理"}
|
||||
</p>
|
||||
<h1>{labels[tab]}</h1>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => void load()} disabled={busy}>
|
||||
<RefreshCw size={16} /> 刷新
|
||||
<div className="topbar-actions">
|
||||
<span className="today-date">
|
||||
{new Date().toLocaleDateString("zh-CN", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
weekday: "long",
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || actionBusy}
|
||||
onClick={() => refresh((value) => value + 1)}
|
||||
>
|
||||
<RefreshCw size={16} className={busy ? "animate-spin" : ""} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mobile-logout"
|
||||
size="icon"
|
||||
onClick={logout}
|
||||
aria-label="退出登录"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{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" ? (
|
||||
<>
|
||||
<div className="hero">
|
||||
<div>
|
||||
<p>每一段陪伴,都有迹可循</p>
|
||||
<h2>你好,铲屎官管理者 👋</h2>
|
||||
<span>这里汇总肉垫计划的日常活动。</span>
|
||||
</div>
|
||||
<div className="hero-paw">🐕</div>
|
||||
</div>
|
||||
<div className="stat-grid">
|
||||
{(
|
||||
[
|
||||
["users", "注册用户", "👥"],
|
||||
["pets", "宠物档案", "🐶"],
|
||||
["walks", "遛狗记录", "🐾"],
|
||||
["meetups", "约遛活动", "🌿"],
|
||||
] as const
|
||||
).map(([key, label, icon]) => (
|
||||
<article className="stat-card" key={key}>
|
||||
<div className="stat-icon">{icon}</div>
|
||||
<span>{label}</span>
|
||||
<strong>{stats[key] ?? "—"}</strong>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
<Overview stats={stats} navigate={navigate} />
|
||||
) : tab === "breeds" ? (
|
||||
<Breeds revision={revision} />
|
||||
) : (
|
||||
<section className="table-card">
|
||||
<div className="table-head">
|
||||
<h2>{labels[tab]}</h2>
|
||||
<p>最近 {items.length} 条记录</p>
|
||||
</div>
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{tab === "users" ? (
|
||||
<>
|
||||
<th>用户</th>
|
||||
<th>注册时间</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</>
|
||||
) : tab === "pets" ? (
|
||||
<>
|
||||
<th>宠物</th>
|
||||
<th>品种</th>
|
||||
<th>主人 ID</th>
|
||||
<th>状态</th>
|
||||
</>
|
||||
) : tab === "walks" ? (
|
||||
<>
|
||||
<th>记录 ID</th>
|
||||
<th>用户 ID</th>
|
||||
<th>开始时间</th>
|
||||
<th>距离</th>
|
||||
<th>状态</th>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<th>活动</th>
|
||||
<th>地点</th>
|
||||
<th>时间</th>
|
||||
<th>人数</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => (
|
||||
<tr key={row.id}>
|
||||
{tab === "users" ? (
|
||||
<>
|
||||
<td>
|
||||
<b>{row.nickname}</b>
|
||||
<small>{row.id}</small>
|
||||
</td>
|
||||
<td>{fmt(row.created_at)}</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
"pill " + (row.disabled ? "red" : "green")
|
||||
}
|
||||
>
|
||||
{row.disabled ? "已停用" : "正常"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void toggle(row)}
|
||||
>
|
||||
{row.disabled ? "启用" : "停用"}
|
||||
</Button>
|
||||
</td>
|
||||
</>
|
||||
) : tab === "pets" ? (
|
||||
<>
|
||||
<td>
|
||||
<b>{row.name}</b>
|
||||
<small>{row.id}</small>
|
||||
</td>
|
||||
<td>{row.breed || row.species}</td>
|
||||
<td className="mono">{row.user_id}</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
"pill " + (row.archived ? "red" : "green")
|
||||
}
|
||||
>
|
||||
{row.archived ? "已归档" : "在用"}
|
||||
</span>
|
||||
</td>
|
||||
</>
|
||||
) : tab === "walks" ? (
|
||||
<>
|
||||
<td className="mono">{row.id}</td>
|
||||
<td className="mono">{row.user_id}</td>
|
||||
<td>{fmt(row.started_at)}</td>
|
||||
<td>{(row.distance_meters / 1000).toFixed(2)} km</td>
|
||||
<td>
|
||||
<span className="pill green">{row.status}</span>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td>
|
||||
<b>{row.title}</b>
|
||||
<small>{row.id}</small>
|
||||
</td>
|
||||
<td>{row.place_name}</td>
|
||||
<td>{fmt(row.starts_at)}</td>
|
||||
<td>
|
||||
{row.participants}/{row.max_people}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
"pill " +
|
||||
(row.status === "open" ? "green" : "red")
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{row.status === "open" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void cancel(row)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{items.length === 0 && (
|
||||
<div className="empty">还没有记录,新的足迹会出现在这里 🐾</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<Records
|
||||
key={tab}
|
||||
section={tab}
|
||||
revision={revision}
|
||||
action={(section, row) => {
|
||||
setError("");
|
||||
setConfirm({ section, row });
|
||||
}}
|
||||
detail={(section, row) => setDetail({ section, row })}
|
||||
actionBusy={actionBusy}
|
||||
/>
|
||||
)}
|
||||
<footer className="page-footer">
|
||||
肉垫计划 · 把每一份陪伴,认真记下来
|
||||
</footer>
|
||||
</main>
|
||||
<Details value={detail} close={() => setDetail(null)} />
|
||||
<Dialog
|
||||
open={!!confirm}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !actionBusy) {
|
||||
setConfirm(null);
|
||||
setError("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent aria-describedby="action-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{actionTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p id="action-description" className="confirm-copy">
|
||||
{confirm?.section === "users"
|
||||
? confirm.row.disabled
|
||||
? `确认恢复「${confirm.row.nickname || "该用户"}」的访问权限?`
|
||||
: `停用「${confirm.row.nickname || "该用户"}」后,将无法使用小程序接口,现有位置分享也会关闭。`
|
||||
: `确认取消「${confirm?.row.title}」?取消后活动将停止接受报名。`}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="error-banner" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="dialog-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={actionBusy}
|
||||
onClick={() => {
|
||||
setConfirm(null);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
暂不操作
|
||||
</Button>
|
||||
<Button disabled={actionBusy} onClick={() => void applyAction()}>
|
||||
{actionBusy && (
|
||||
<LoaderCircle size={16} className="animate-spin" />
|
||||
)}
|
||||
确认{actionTitle}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button }
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
@@ -16,7 +16,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg rounded-xl',
|
||||
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg rounded-xl",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -24,18 +24,39 @@ const DialogContent = React.forwardRef<
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 hover:opacity-100">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">关闭</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
))
|
||||
DialogContent.displayName = 'DialogContent'
|
||||
));
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('flex flex-col space-y-1.5', className)} {...props} />
|
||||
function DialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("flex flex-col space-y-1.5", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h2 className={cn('text-lg font-semibold', className)} {...props} />
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Dialog, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogTitle }
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
};
|
||||
|
||||
@@ -1,11 +1,242 @@
|
||||
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 setToken = (value: string) => localStorage.setItem(key, value);
|
||||
export const clearToken = () => localStorage.removeItem(key);
|
||||
const http = axios.create({ baseURL: "/api" });
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = getToken();
|
||||
export const setToken = (value: Session) => {
|
||||
localStorage.setItem(refreshKey, value.refresh_token);
|
||||
localStorage.setItem(expiryKey, String(value.expires_at));
|
||||
localStorage.setItem(key, value.token);
|
||||
};
|
||||
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}`;
|
||||
return config;
|
||||
});
|
||||
@@ -15,21 +246,83 @@ http.interceptors.response.use(
|
||||
if (body?.code === 0) return body.data;
|
||||
throw new Error(body?.message || "请求失败");
|
||||
},
|
||||
(error) => {
|
||||
if (error.response?.status === 401) clearToken();
|
||||
throw new Error(error.response?.data?.message || error.message);
|
||||
async (error) => {
|
||||
if (axios.isCancel(error)) return Promise.reject(error);
|
||||
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 = {
|
||||
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) =>
|
||||
http.post<any, { token: string }>("/admin/login", { username, password }),
|
||||
stats: () => http.get<any, Record<string, number>>("/admin/stats"),
|
||||
users: () => http.get<any, any[]>("/admin/users"),
|
||||
pets: () => http.get<any, any[]>("/admin/pets"),
|
||||
walks: () => http.get<any, any[]>("/admin/walks"),
|
||||
meetups: () => http.get<any, any[]>("/admin/meetups"),
|
||||
http.post<unknown, Session>("/admin/login", {
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
stats: (signal?: AbortSignal) =>
|
||||
http.get<unknown, Stats>("/admin/stats", { signal }),
|
||||
list: (
|
||||
section: Section,
|
||||
params: { page: number; page_size: number; q: string; status: string },
|
||||
signal?: AbortSignal,
|
||||
) => http.get<unknown, PageResult>(`/admin/${section}`, { params, signal }),
|
||||
userPets: (id: string, signal?: AbortSignal) =>
|
||||
http.get<unknown, Row[]>(`/admin/users/${id}/pets`, { signal }),
|
||||
walk: (id: string, signal?: AbortSignal) =>
|
||||
http.get<unknown, { id: string; points: Point[] }>(`/admin/walks/${id}`, {
|
||||
signal,
|
||||
}),
|
||||
setUserDisabled: (id: string, disabled: boolean) =>
|
||||
http.put(`/admin/users/${id}/disabled`, { disabled }),
|
||||
setMeetupStatus: (id: string, status: string) =>
|
||||
http.put(`/admin/meetups/${id}/status`, { status }),
|
||||
cancelMeetup: (id: string) =>
|
||||
http.put(`/admin/meetups/${id}/status`, { status: "cancelled" }),
|
||||
};
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename="/admin">
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<BrowserRouter><Routes>
|
||||
<Route path="/" element={<Navigate to="/admin/overview" replace />} />
|
||||
<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>,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from 'node:path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
base: '/admin/',
|
||||
base: '/',
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -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 均为两小时,到期前自动续期,刷新失效时重新微信登录。
|
||||
@@ -1,34 +1,49 @@
|
||||
App({
|
||||
globalData: { token: "", user: null },
|
||||
globalData: { token: "", user: null, expiresAt: 0 },
|
||||
onLaunch() {
|
||||
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() {
|
||||
if (this.globalData.token) return this.globalData.token;
|
||||
if (!this.loginPromise)
|
||||
if (this.loginPromise) return this.loginPromise;
|
||||
if (this.globalData.token && (!this.globalData.expiresAt || this.globalData.expiresAt > Date.now() / 1000 + 60)) return this.globalData.token;
|
||||
this.loginPromise = (async () => {
|
||||
const { isSimulator } = require("./utils/config");
|
||||
const { request } = require("./utils/api");
|
||||
let data;
|
||||
const refreshToken = wx.getStorageSync("pet_refresh_token");
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const { code } = await wx.login();
|
||||
data = await request("/auth/login", "POST", { code }, false);
|
||||
const pair = await request("/auth/refresh", "POST", { refresh_token: refreshToken }, false);
|
||||
return this.saveSession(pair);
|
||||
} catch (error) {
|
||||
if (!isSimulator) throw error;
|
||||
data = await request(
|
||||
"/auth/dev",
|
||||
"POST",
|
||||
{ key: "wechat-devtools", nickname: "本地铲屎官" },
|
||||
false,
|
||||
);
|
||||
if (error.status !== 401 && error.status !== 403) throw error;
|
||||
wx.removeStorageSync("pet_refresh_token");
|
||||
}
|
||||
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;
|
||||
wx.setStorageSync("pet_token", data.token);
|
||||
return data.token;
|
||||
return this.saveSession(data);
|
||||
})().finally(() => {
|
||||
this.loginPromise = null;
|
||||
});
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
"window": { "navigationStyle": "custom", "backgroundColor": "#f6f9f6", "backgroundTextStyle": "dark" },
|
||||
"permission": { "scope.userLocation": { "desc": "遛狗时记录轨迹,并在你主动开启分享后展示实时位置" } },
|
||||
"requiredPrivateInfos": ["getLocation", "chooseLocation"],
|
||||
"pages": [
|
||||
"pages/home/home",
|
||||
"pages/walk/walk",
|
||||
"pages/tasks/tasks",
|
||||
"pages/pets/pets",
|
||||
"pages/pet-edit/pet-edit",
|
||||
"pages/meetup-edit/meetup-edit",
|
||||
"pages/share/share",
|
||||
"pages/walk-records/walk-records",
|
||||
"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",
|
||||
"style": "v2",
|
||||
"lazyCodeLoading": "requiredComponents"
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
page {
|
||||
background: #f6f9f6;
|
||||
color: #173e36;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #fff9f3;
|
||||
color: #604a40;
|
||||
font-family:
|
||||
ui-rounded, "Hiragino Maru Gothic ProN", "PingFang SC", sans-serif;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
}
|
||||
view,
|
||||
text,
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
textarea,
|
||||
picker {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
button {
|
||||
@@ -18,51 +29,45 @@ button::after {
|
||||
border: 0;
|
||||
}
|
||||
.screen {
|
||||
min-height: 100vh;
|
||||
padding: calc(env(safe-area-inset-top) + 36rpx) 32rpx 170rpx;
|
||||
min-height: 100%;
|
||||
padding: 16rpx 32rpx calc(170rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.eyebrow {
|
||||
color: #8fa39b;
|
||||
color: #886c5f;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
.title {
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
margin: 12rpx 0 26rpx;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
font-weight: 600;
|
||||
margin: 38rpx 0 20rpx;
|
||||
}
|
||||
.muted {
|
||||
color: #8b9f96;
|
||||
color: #886c5f;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 34rpx;
|
||||
box-shadow: 0 15rpx 45rpx rgba(30, 70, 55, 0.05);
|
||||
box-shadow: 0 15rpx 45rpx rgba(123, 84, 58, 0.06);
|
||||
padding: 28rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.primary {
|
||||
background: #194f42;
|
||||
background: #b95738;
|
||||
color: white;
|
||||
border-radius: 23rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
padding: 22rpx 30rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.secondary {
|
||||
background: #e7f0e7;
|
||||
color: #28674e;
|
||||
background: #fff0e2;
|
||||
color: #b95738;
|
||||
border-radius: 23rpx;
|
||||
font-size: 27rpx;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
padding: 20rpx;
|
||||
}
|
||||
.row {
|
||||
@@ -74,14 +79,14 @@ button::after {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 99rpx;
|
||||
background: #e9f3e8;
|
||||
color: #36805c;
|
||||
background: #fff0e2;
|
||||
color: #b95738;
|
||||
padding: 8rpx 18rpx;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #9daf9f;
|
||||
color: #886c5f;
|
||||
padding: 64rpx 24rpx;
|
||||
}
|
||||
.field {
|
||||
@@ -89,12 +94,11 @@ button::after {
|
||||
}
|
||||
.field-label {
|
||||
font-size: 24rpx;
|
||||
color: #718b7e;
|
||||
color: #886c5f;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field picker {
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
@@ -102,6 +106,50 @@ button::after {
|
||||
font-size: 28rpx;
|
||||
min-height: 76rpx;
|
||||
}
|
||||
.field picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.field textarea {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Component({
|
||||
properties: {
|
||||
kind: { type: String, value: "paw" },
|
||||
size: { type: Number, value: 48 },
|
||||
color: { type: String, value: "#b95738" },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "component": true }
|
||||
@@ -0,0 +1,13 @@
|
||||
<view class="app-icon-icon" style="width: {{size}}rpx; height: {{size}}rpx; color: {{color}}">
|
||||
<view wx:if="{{kind==='water'}}" class="app-icon-drop"></view>
|
||||
<view wx:elif="{{kind==='food'}}" class="app-icon-bowl"><view class="app-icon-bowl-rim"></view></view>
|
||||
<view wx:elif="{{kind==='pet'}}" class="app-icon-pet">
|
||||
<view class="app-icon-ear app-icon-ear-left"></view><view class="app-icon-ear app-icon-ear-right"></view>
|
||||
<view class="app-icon-face"><view class="app-icon-eye app-icon-eye-left"></view><view class="app-icon-eye app-icon-eye-right"></view><view class="app-icon-nose"></view></view>
|
||||
</view>
|
||||
<view wx:else class="app-icon-paw">
|
||||
<view class="app-icon-toe app-icon-one"></view><view class="app-icon-toe app-icon-two"></view>
|
||||
<view class="app-icon-toe app-icon-three"></view><view class="app-icon-toe app-icon-four"></view>
|
||||
<view class="app-icon-pad"></view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,21 @@
|
||||
.app-icon-icon { position: relative; display: flex; align-items: center; justify-content: center; }
|
||||
.app-icon-drop { width: 48%; height: 48%; border: 3rpx solid currentColor; border-radius: 50% 50% 50% 0; transform: rotate(135deg); }
|
||||
.app-icon-bowl { position: relative; width: 70%; height: 40%; border: 3rpx solid currentColor; border-radius: 5rpx 5rpx 50% 50%; }
|
||||
.app-icon-bowl-rim { position: absolute; left: -8%; top: -5rpx; width: 116%; height: 3rpx; border-radius: 4rpx; background: currentColor; }
|
||||
.app-icon-paw { position: relative; width: 85%; height: 85%; }
|
||||
.app-icon-toe, .app-icon-pad { position: absolute; background: currentColor; }
|
||||
.app-icon-toe { width: 17%; height: 24%; border-radius: 50%; }
|
||||
.app-icon-one { left: 0; top: 26%; transform: rotate(-28deg); }
|
||||
.app-icon-two { left: 24%; top: 3%; transform: rotate(-12deg); }
|
||||
.app-icon-three { right: 24%; top: 3%; transform: rotate(12deg); }
|
||||
.app-icon-four { right: 0; top: 26%; transform: rotate(28deg); }
|
||||
.app-icon-pad { left: 24%; top: 48%; width: 52%; height: 42%; border-radius: 50% 50% 45% 45%; }
|
||||
.app-icon-pet { position: relative; width: 85%; height: 85%; }
|
||||
.app-icon-ear { position: absolute; top: 22%; width: 27%; height: 52%; border: 3rpx solid currentColor; border-radius: 55% 40% 70% 70%; }
|
||||
.app-icon-ear-left { left: -3%; transform: rotate(25deg); }
|
||||
.app-icon-ear-right { right: -3%; transform: rotate(-25deg); }
|
||||
.app-icon-face { position: absolute; bottom: 0; left: 12%; width: 76%; height: 68%; border: 3rpx solid currentColor; border-radius: 42% 42% 48% 48%; background: #fff; }
|
||||
.app-icon-eye { position: absolute; top: 29%; width: 9%; height: 14%; border-radius: 50%; background: currentColor; }
|
||||
.app-icon-eye-left { left: 23%; }
|
||||
.app-icon-eye-right { right: 23%; }
|
||||
.app-icon-nose { position: absolute; left: 40%; bottom: 22%; width: 20%; height: 14%; border-radius: 40% 40% 60% 60%; background: currentColor; }
|
||||
@@ -0,0 +1,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; }
|
||||
@@ -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 {
|
||||
background: #dfeee2;
|
||||
background: #edf2fb;
|
||||
border-radius: 34rpx;
|
||||
min-height: 245rpx;
|
||||
padding: 36rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 30rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.banner text,
|
||||
.banner strong {
|
||||
.banner-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.banner-copy text,
|
||||
.banner-title {
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.banner text {
|
||||
color: #549375;
|
||||
.banner-copy text {
|
||||
color: #576c88;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.banner strong {
|
||||
.banner-title {
|
||||
font-size: 35rpx;
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.banner-title.first {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.banner image {
|
||||
width: 290rpx;
|
||||
height: 220rpx;
|
||||
position: absolute;
|
||||
right: -30rpx;
|
||||
bottom: 0;
|
||||
width: 52%;
|
||||
height: 190rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.add {
|
||||
font-size: 24rpx;
|
||||
color: #328965;
|
||||
font-weight: 700;
|
||||
color: #b95738;
|
||||
font-weight: 600;
|
||||
}
|
||||
.meetup strong {
|
||||
display: block;
|
||||
@@ -39,30 +44,30 @@
|
||||
margin: 15rpx 0;
|
||||
}
|
||||
.badge {
|
||||
background: #e8f4ea;
|
||||
color: #429369;
|
||||
background: #edf2fb;
|
||||
color: #b95738;
|
||||
border-radius: 99rpx;
|
||||
padding: 8rpx 15rpx;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.arrow {
|
||||
font-size: 39rpx;
|
||||
color: #a1b2a8;
|
||||
color: #9d8779;
|
||||
}
|
||||
.meta {
|
||||
font-size: 24rpx;
|
||||
color: #748c7d;
|
||||
color: #886c5f;
|
||||
margin-top: 9rpx;
|
||||
}
|
||||
.desc {
|
||||
font-size: 24rpx;
|
||||
color: #9aaa9f;
|
||||
color: #886c5f;
|
||||
margin-top: 15rpx;
|
||||
}
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #102a24a0;
|
||||
background: #604a40a0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -81,13 +86,13 @@
|
||||
}
|
||||
.detail {
|
||||
font-size: 25rpx;
|
||||
color: #61816e;
|
||||
color: #886c5f;
|
||||
line-height: 2;
|
||||
margin: 25rpx 0;
|
||||
}
|
||||
.select-title {
|
||||
font-size: 25rpx;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
margin: 30rpx 0 15rpx;
|
||||
}
|
||||
.sheet button {
|
||||
@@ -0,0 +1,38 @@
|
||||
const { navigationGeometry } = require("../../utils/layout");
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
title: String,
|
||||
subtitle: String,
|
||||
location: { type: Boolean, value: false },
|
||||
back: { type: Boolean, value: false },
|
||||
},
|
||||
data: navigationGeometry(),
|
||||
lifetimes: {
|
||||
attached() {
|
||||
this.updateLayout();
|
||||
},
|
||||
},
|
||||
pageLifetimes: {
|
||||
resize() {
|
||||
this.updateLayout();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
locate() {
|
||||
this.triggerEvent("locationtap");
|
||||
},
|
||||
updateLayout() {
|
||||
const info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync();
|
||||
let menu = {};
|
||||
try {
|
||||
menu = wx.getMenuButtonBoundingClientRect();
|
||||
} catch (_) {}
|
||||
this.setData(navigationGeometry(info, menu));
|
||||
},
|
||||
goBack() {
|
||||
if (getCurrentPages().length > 1) wx.navigateBack({ delta: 1 });
|
||||
else wx.reLaunch({ url: "/pages/home/home" });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "component": true }
|
||||
@@ -0,0 +1,15 @@
|
||||
<view class="navigation" style="height: {{headerHeight}}px">
|
||||
<view class="navigation-row" style="top: {{top}}px; height: {{rowHeight}}px; width: {{titleWidth}}px">
|
||||
<view wx:if="{{location}}" class="local-header" bindtap="locate" aria-role="button" aria-label="更新当前位置和天气">
|
||||
<view class="local-title"><view class="location-pin"></view><text>{{title}}</text><view class="location-chevron"></view></view>
|
||||
<view class="local-meta"><text>{{subtitle}}</text></view>
|
||||
</view>
|
||||
<view wx:if="{{back}}" class="back" bindtap="goBack" aria-role="button" aria-label="返回">
|
||||
<view class="back-arrow"></view>
|
||||
</view>
|
||||
<text wx:if="{{!location}}" class="navigation-title">{{title}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view class="content" style="top: {{headerHeight}}px; height: {{scrollHeight}}px" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
|
||||
<slot />
|
||||
</scroll-view>
|
||||
@@ -0,0 +1,108 @@
|
||||
.navigation {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 25;
|
||||
background: #fff9f3;
|
||||
}
|
||||
.navigation-row {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.navigation-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #604a40;
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.local-header {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.local-title {
|
||||
height: 19px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #604a40;
|
||||
font-size: 16px;
|
||||
line-height: 19px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.local-title text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.location-pin {
|
||||
position: relative;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
flex-shrink: 0;
|
||||
border: 1.5px solid #b95738;
|
||||
border-radius: 50% 50% 50% 0;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
.location-pin::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
left: 3.5px;
|
||||
top: 3.5px;
|
||||
border-radius: 50%;
|
||||
background: #b95738;
|
||||
}
|
||||
.location-chevron {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #886c5f;
|
||||
border-bottom: 1px solid #886c5f;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.local-meta {
|
||||
margin-top: 2px;
|
||||
color: #886c5f;
|
||||
font-size: 10px;
|
||||
line-height: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.back {
|
||||
width: 28px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.back-arrow {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-left: 2px solid #604a40;
|
||||
border-bottom: 2px solid #604a40;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.content {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
@@ -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" />
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ Component({
|
||||
properties: {
|
||||
pets: { type: Array, value: [] },
|
||||
selected: { type: Array, value: [] },
|
||||
disabled: { type: Boolean, value: false },
|
||||
},
|
||||
data: { viewPets: [] },
|
||||
observers: {
|
||||
@@ -16,11 +17,18 @@ Component({
|
||||
},
|
||||
methods: {
|
||||
toggle(e) {
|
||||
if (this.data.disabled) return;
|
||||
const id = e.currentTarget.dataset.id;
|
||||
const selected = this.data.selected.includes(id)
|
||||
? this.data.selected.filter((x) => x !== id)
|
||||
: [...this.data.selected, id];
|
||||
this.triggerEvent("change", { selected });
|
||||
},
|
||||
selectAll() {
|
||||
if (this.data.disabled) return;
|
||||
const ids = this.data.pets.map((pet) => pet.id);
|
||||
const allSelected = ids.every((id) => this.data.selected.includes(id));
|
||||
this.triggerEvent("change", { selected: allSelected ? [] : ids });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
{ "component": true }
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {
|
||||
"app-icon": "/components/app-icon/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<view class="picker">
|
||||
<view wx:if="{{pets.length>1&&!disabled}}" class="chip select-all" bindtap="selectAll">
|
||||
<text>✓</text><text>全选 / 清空</text>
|
||||
</view>
|
||||
<view
|
||||
wx:for="{{viewPets}}"
|
||||
wx:key="id"
|
||||
@@ -7,7 +10,7 @@
|
||||
bindtap="toggle"
|
||||
>
|
||||
<image wx:if="{{item.avatar_url}}" src="{{item.avatar_url}}" />
|
||||
<text wx:else>🐕</text>
|
||||
<app-icon wx:else size="32" />
|
||||
<text>{{item.name}}</text>
|
||||
<text class="check">{{item.chosen?'✓':'+'}}</text>
|
||||
</view>
|
||||
|
||||
@@ -8,16 +8,16 @@
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
background: white;
|
||||
border: 2rpx solid #e0eae2;
|
||||
color: #527164;
|
||||
border: 2rpx solid #ece2d8;
|
||||
color: #886c5f;
|
||||
padding: 11rpx 18rpx;
|
||||
border-radius: 99rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.chip.chosen {
|
||||
background: #dff0e1;
|
||||
border-color: #55a273;
|
||||
color: #145441;
|
||||
background: #fff0e2;
|
||||
border-color: #b95738;
|
||||
color: #b95738;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chip image {
|
||||
@@ -29,3 +29,7 @@
|
||||
margin-left: 3rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
.select-all {
|
||||
border-style: dashed;
|
||||
color: #b95738;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||