Feat/dev #5
@@ -11,6 +11,8 @@ MINIO_SECRET_KEY=sundynix
|
|||||||
MINIO_BUCKET=sundynix-pet
|
MINIO_BUCKET=sundynix-pet
|
||||||
MINIO_USE_SSL=true
|
MINIO_USE_SSL=true
|
||||||
|
|
||||||
|
JWT_EXPIRE_HOURS=2
|
||||||
|
JWT_REFRESH_HOURS=2
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
WECHAT_APP_ID=wx52dfc635739a9c19
|
WECHAT_APP_ID=wx52dfc635739a9c19
|
||||||
WECHAT_APP_SECRET=
|
WECHAT_APP_SECRET=
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ services:
|
|||||||
PETS_MINIO_BUCKET: ${MINIO_BUCKET:-sundynix-pet}
|
PETS_MINIO_BUCKET: ${MINIO_BUCKET:-sundynix-pet}
|
||||||
PETS_MINIO_USE_SSL: ${MINIO_USE_SSL:-true}
|
PETS_MINIO_USE_SSL: ${MINIO_USE_SSL:-true}
|
||||||
PETS_JWT_SECRET: ${JWT_SECRET:?}
|
PETS_JWT_SECRET: ${JWT_SECRET:?}
|
||||||
|
PETS_JWT_EXPIRE_HOURS: ${JWT_EXPIRE_HOURS:-2}
|
||||||
|
PETS_JWT_REFRESH_HOURS: ${JWT_REFRESH_HOURS:-2}
|
||||||
PETS_WECHAT_APP_ID: ${WECHAT_APP_ID:?}
|
PETS_WECHAT_APP_ID: ${WECHAT_APP_ID:?}
|
||||||
PETS_WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?}
|
PETS_WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?}
|
||||||
PETS_AUTH_DEV_LOGIN: "false"
|
PETS_AUTH_DEV_LOGIN: "false"
|
||||||
|
|||||||
+17
-1
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
## 本地启动
|
## 本地启动
|
||||||
|
|
||||||
1. `cp configs/config.example.yaml configs/config.yaml`,设置 PostgreSQL、MinIO、JWT 和微信参数。配置文件不进入 Git。
|
1. `cp configs/config.example.yaml configs/config.yaml`,设置 PostgreSQL、MinIO、JWT 和微信参数。
|
||||||
2. `cd web/admin && npm ci && npm run build`。
|
2. `cd web/admin && npm ci && npm run build`。
|
||||||
3. 若数据库还不存在,在 `pets-be` 目录执行一次 `go run ./cmd/dbinit`。
|
3. 若数据库还不存在,在 `pets-be` 目录执行一次 `go run ./cmd/dbinit`。
|
||||||
4. 执行 `go run ./cmd/server`。
|
4. 执行 `go run ./cmd/server`。
|
||||||
@@ -33,3 +33,19 @@ GORM 使用 `sundynix_` 表前缀、单数 snake_case 表名和字段名,例
|
|||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
`../deploy/docker-compose.yml` 使用外部 PostgreSQL 和 MinIO;把 `../deploy/.env.example` 复制为 `.env` 后设置凭据。`pet.sundynix.cn` 用于业务 API;`s3.sundynix.cn` 用于文件上传和图片直链。URL 由 MinIO endpoint、bucket 和 object key 拼接,在上传时写入文件表,格式为 `https://s3.sundynix.cn/sundynix-pet/<object_key>`。`res.sundynix.cn/browser/...` 是 MinIO 控制台页面,不能作为图片直链。
|
`../deploy/docker-compose.yml` 使用外部 PostgreSQL 和 MinIO;把 `../deploy/.env.example` 复制为 `.env` 后设置凭据。`pet.sundynix.cn` 用于业务 API;`s3.sundynix.cn` 用于文件上传和图片直链。URL 由 MinIO endpoint、bucket 和 object key 拼接,在上传时写入文件表,格式为 `https://s3.sundynix.cn/sundynix-pet/<object_key>`。`res.sundynix.cn/browser/...` 是 MinIO 控制台页面,不能作为图片直链。
|
||||||
|
|
||||||
|
## 管理端路由与犬种百科
|
||||||
|
|
||||||
|
菜单使用独立路由:`/admin/overview`、`/admin/users`、`/admin/pets`、`/admin/breeds`、`/admin/walks`、`/admin/meetups`、`/admin/tasks`、`/admin/files`;登录页 `/admin/login`。根路径自动进入工作台或登录页;Go 的 SPA 回退支持刷新和直达链接。业务接口仍在 `/api`,无需额外 API 前缀。
|
||||||
|
|
||||||
|
犬种目录自动导入 350 项(CKU 去重 347 项,另补充三种实用分类),按名称拼音 A–Z 排序。选品种使用 `breed_id`,手动填写保留 `breed` 名称并清空 `breed_id`。后台提供新增、编辑、启用、停用、搜索及每页十条分页。停用品种不影响已有宠物档案。
|
||||||
|
|
||||||
|
CKU 当前返回完整详情的 88 个犬种已整理基本资料、简短改写介绍和来源链接;另三项补充分类提供原创介绍。资料缺失时显示待补充,后台可以编辑百科字段。图片与长篇犬种标准未转载。
|
||||||
|
|
||||||
|
## 登录刷新
|
||||||
|
|
||||||
|
管理端和小程序的访问 token、refresh token 都为 **2 小时**。请求前发现不足一分钟即刷新;401 也会尝试刷新并仅重试一次。刷新凭证每次轮换,数据库只存 SHA-256 摘要;两个角色的凭证不能混用,停用账号、过期及退出会话都不能继续刷新。后台退出会撤销会话和其访问 token。
|
||||||
|
|
||||||
|
刷新成功后,两种凭证重新计时两小时;超过两小时未使用则重新登录。小程序自动重新微信登录,管理端回到登录页并保留原目标路由。并发接口共用刷新请求,支持 Web Locks 的浏览器还会跨标签页串行刷新;网络失败保留凭证以便重试。
|
||||||
|
|
||||||
|
接口:`POST /api/auth/refresh`、`POST /api/admin/refresh`,请求体为 `{ "refresh_token": "..." }`。登录与刷新响应包含 `token`、`refresh_token`、`expires_in: 7200`、`expires_at`、`refresh_expires_at`(Unix 秒)。旧七天 token 会要求重新登录。时长按业务约束固定两小时,旧环境变量不会把期限拉长。
|
||||||
|
|||||||
@@ -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:
|
jwt:
|
||||||
secret: "" # 用 PETS_JWT_SECRET 注入
|
secret: "" # 用 PETS_JWT_SECRET 注入
|
||||||
expire_hours: 168
|
expire_hours: 2
|
||||||
|
refresh_hours: 2
|
||||||
|
|
||||||
wechat:
|
wechat:
|
||||||
app_id: ""
|
app_id: ""
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ minio:
|
|||||||
use_ssl: true
|
use_ssl: true
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
|
expire_hours: 2
|
||||||
|
refresh_hours: 2
|
||||||
secret: kFdKDNCXpyJO2W+htdxobhHNzUIYuwcaZV94aJbIGHQ= # 生产用 PETS_JWT_SECRET 覆盖
|
secret: kFdKDNCXpyJO2W+htdxobhHNzUIYuwcaZV94aJbIGHQ= # 生产用 PETS_JWT_SECRET 覆盖
|
||||||
|
|
||||||
wechat:
|
wechat:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/minio/minio-go/v7 v7.2.1
|
github.com/minio/minio-go/v7 v7.2.1
|
||||||
|
github.com/mozillazg/go-pinyin v0.21.0
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
golang.org/x/crypto v0.53.0
|
golang.org/x/crypto v0.53.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/mozillazg/go-pinyin v0.21.0 h1:Wo8/NT45z7P3er/9YSLHA3/kjZzbLz5hR7i+jGeIGao=
|
||||||
|
github.com/mozillazg/go-pinyin v0.21.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/brand-icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/brand-icon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>肉垫计划 · 管理后台</title>
|
<title>肉垫计划 · 管理后台</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BOnBpy-T.js"></script>
|
<script type="module" crossorigin src="/assets/index-N2FSM0pD.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-7B3h3Yq6.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CkSlqiFn.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ func TestAdminAtRootAndAPIRoutes(t *testing.T) {
|
|||||||
body string
|
body string
|
||||||
}{
|
}{
|
||||||
{path: "/", status: http.StatusOK, body: "<div id=\"root\"></div>"},
|
{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: script, status: http.StatusOK},
|
||||||
{path: "/brand-logo.png", status: http.StatusOK},
|
{path: "/brand-logo.png", status: http.StatusOK},
|
||||||
{path: "/api/ping", status: http.StatusOK, body: "pong"},
|
{path: "/api/ping", status: http.StatusOK, body: "pong"},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
|||||||
|
package breeds
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/sundynix/pets-be/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed enrichment.json
|
||||||
|
var enrichmentData []byte
|
||||||
|
|
||||||
|
type Enrichment struct {
|
||||||
|
SourceKey string `json:"source_key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Fields map[string]string `json:"fields"`
|
||||||
|
FieldSources map[string]string `json:"field_sources"`
|
||||||
|
References []model.BreedReference `json:"references"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Enrichments() ([]Enrichment, error) {
|
||||||
|
var rows []Enrichment
|
||||||
|
err := json.Unmarshal(enrichmentData, &rows)
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference facts remain available even when an editor locks the display content.
|
||||||
|
func enrich(db *gorm.DB) error {
|
||||||
|
rows, err := Enrichments()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load breed sources: %w", err)
|
||||||
|
}
|
||||||
|
defaults, err := Defaults()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
baseline := map[string]model.Breed{}
|
||||||
|
for _, breed := range defaults {
|
||||||
|
baseline[*breed.SourceKey] = breed
|
||||||
|
}
|
||||||
|
for _, item := range rows {
|
||||||
|
var breed model.Breed
|
||||||
|
if err := db.Where("source_key = ?", item.SourceKey).First(&breed).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, source := range item.References {
|
||||||
|
source.BreedID = breed.ID
|
||||||
|
// Source snapshots are read-only in admin; update only when factual metadata changes.
|
||||||
|
if err := db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "breed_id"}, {Name: "provider"}}, DoUpdates: clause.AssignmentColumns([]string{"external_id", "url", "retrieved_at", "facts"}), Where: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: "sundynix_breed_reference.facts IS DISTINCT FROM EXCLUDED.facts OR sundynix_breed_reference.url IS DISTINCT FROM EXCLUDED.url OR sundynix_breed_reference.retrieved_at IS DISTINCT FROM EXCLUDED.retrieved_at"}}}}).Create(&source).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if breed.EditorialLocked || breed.Name != item.Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
original := baseline[item.SourceKey]
|
||||||
|
updates := map[string]any{"english_name": original.EnglishName, "origin": original.Origin, "breed_group": original.BreedGroup, "fci_number": original.FCINumber, "height": original.Height, "weight": original.Weight, "summary": original.Summary, "temperament": original.Temperament, "traits": original.Traits, "care_notes": original.CareNotes, "life_span": "", "exercise": "", "grooming": ""}
|
||||||
|
for field, value := range item.Fields {
|
||||||
|
switch field {
|
||||||
|
case "english_name", "origin", "breed_group", "fci_number", "height", "weight", "life_span", "exercise", "grooming", "temperament", "traits", "summary", "care_notes":
|
||||||
|
updates[field] = value
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported breed field: %s", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
origins, err := json.Marshal(item.FieldSources)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updates["field_sources"] = string(origins)
|
||||||
|
latest := original.EncyclopediaUpdatedAt
|
||||||
|
for _, ref := range item.References {
|
||||||
|
if ref.RetrievedAt > latest {
|
||||||
|
latest = ref.RetrievedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updates["encyclopedia_updated_at"] = latest
|
||||||
|
// Skip unchanged columns so ordinary starts do not rewrite every breed.
|
||||||
|
current := map[string]string{"english_name": breed.EnglishName, "origin": breed.Origin, "breed_group": breed.BreedGroup, "fci_number": breed.FCINumber, "height": breed.Height, "weight": breed.Weight, "life_span": breed.LifeSpan, "exercise": breed.Exercise, "grooming": breed.Grooming, "temperament": breed.Temperament, "traits": breed.Traits, "summary": breed.Summary, "care_notes": breed.CareNotes, "encyclopedia_updated_at": breed.EncyclopediaUpdatedAt}
|
||||||
|
for key, value := range updates {
|
||||||
|
if key != "field_sources" && current[key] == value {
|
||||||
|
delete(updates, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reflect.DeepEqual(breed.FieldSources, item.FieldSources) {
|
||||||
|
delete(updates, "field_sources")
|
||||||
|
}
|
||||||
|
if len(updates) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// UpdateColumns avoids advancing UpdatedAt on each application start.
|
||||||
|
if err := db.Model(&breed).Where("editorial_locked = false AND name = ?", item.Name).UpdateColumns(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -52,8 +52,9 @@ type MinIOConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type JWTConfig struct {
|
type JWTConfig struct {
|
||||||
Secret string `mapstructure:"secret"`
|
Secret string `mapstructure:"secret"`
|
||||||
ExpireHours int `mapstructure:"expire_hours"`
|
ExpireHours int `mapstructure:"expire_hours"`
|
||||||
|
RefreshHours int `mapstructure:"refresh_hours"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WeChatConfig struct {
|
type WeChatConfig struct {
|
||||||
@@ -87,7 +88,8 @@ func Load() (*Config, error) {
|
|||||||
v.SetDefault("minio.bucket", "sundynix-pet")
|
v.SetDefault("minio.bucket", "sundynix-pet")
|
||||||
v.SetDefault("minio.use_ssl", true)
|
v.SetDefault("minio.use_ssl", true)
|
||||||
v.SetDefault("jwt.secret", "")
|
v.SetDefault("jwt.secret", "")
|
||||||
v.SetDefault("jwt.expire_hours", 168)
|
v.SetDefault("jwt.expire_hours", 2)
|
||||||
|
v.SetDefault("jwt.refresh_hours", 2)
|
||||||
v.SetDefault("wechat.app_id", "")
|
v.SetDefault("wechat.app_id", "")
|
||||||
v.SetDefault("wechat.app_secret", "")
|
v.SetDefault("wechat.app_secret", "")
|
||||||
v.SetDefault("auth.dev_login", false)
|
v.SetDefault("auth.dev_login", false)
|
||||||
@@ -120,5 +122,8 @@ func Load() (*Config, error) {
|
|||||||
if cfg.Server.Mode == "release" && cfg.Auth.DevLogin {
|
if cfg.Server.Mode == "release" && cfg.Auth.DevLogin {
|
||||||
return nil, errors.New("dev login must be disabled in release mode")
|
return nil, errors.New("dev login must be disabled in release mode")
|
||||||
}
|
}
|
||||||
|
// Access tokens have the same fixed two-hour lifetime for both clients.
|
||||||
|
cfg.JWT.ExpireHours = 2
|
||||||
|
cfg.JWT.RefreshHours = 2
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
"gorm.io/gorm/schema"
|
"gorm.io/gorm/schema"
|
||||||
|
|
||||||
|
"github.com/sundynix/pets-be/internal/breeds"
|
||||||
"github.com/sundynix/pets-be/internal/config"
|
"github.com/sundynix/pets-be/internal/config"
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
"github.com/sundynix/pets-be/internal/model"
|
||||||
)
|
)
|
||||||
@@ -39,5 +40,8 @@ func Migrate(db *gorm.DB) error {
|
|||||||
if err := db.AutoMigrate(model.All()...); err != nil {
|
if err := db.AutoMigrate(model.All()...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if _, err := breeds.Seed(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_sundynix_walk_one_active_per_user ON sundynix_walk (user_id) WHERE status IN ('active', 'paused')").Error
|
return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_sundynix_walk_one_active_per_user ON sundynix_walk (user_id) WHERE status IN ('active', 'paused')").Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,12 +59,13 @@ func (a *API) adminLogin(c *gin.Context) {
|
|||||||
fail(c, http.StatusUnauthorized, "用户名或密码错误")
|
fail(c, http.StatusUnauthorized, "用户名或密码错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, err := a.token(admin.ID, "admin")
|
pair, err := a.newSession(admin.ID, "admin")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, http.StatusInternalServerError, "签发令牌失败")
|
fail(c, http.StatusInternalServerError, "签发令牌失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ok(c, gin.H{"token": token, "admin": gin.H{"id": admin.ID, "username": admin.Username}})
|
pair["admin"] = gin.H{"id": admin.ID, "username": admin.Username}
|
||||||
|
ok(c, pair)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *API) devLogin(c *gin.Context) {
|
func (a *API) devLogin(c *gin.Context) {
|
||||||
@@ -203,12 +204,13 @@ func (a *API) loginUser(c *gin.Context, user model.User) {
|
|||||||
fail(c, http.StatusForbidden, "账号已停用")
|
fail(c, http.StatusForbidden, "账号已停用")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, err := a.token(user.ID, "user")
|
pair, err := a.newSession(user.ID, "user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, http.StatusInternalServerError, "签发令牌失败")
|
fail(c, http.StatusInternalServerError, "签发令牌失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ok(c, gin.H{"token": token, "user": a.userView(user)})
|
pair["user"] = a.userView(user)
|
||||||
|
ok(c, pair)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *API) me(c *gin.Context) {
|
func (a *API) me(c *gin.Context) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,7 +132,7 @@ func (a *API) petView(pet model.Pet) gin.H {
|
|||||||
if pet.BirthDate != nil {
|
if pet.BirthDate != nil {
|
||||||
birthDate = pet.BirthDate.Format("2006-01-02")
|
birthDate = pet.BirthDate.Format("2006-01-02")
|
||||||
}
|
}
|
||||||
return gin.H{"id": pet.ID, "name": pet.Name, "species": pet.Species, "breed": pet.Breed,
|
return gin.H{"id": pet.ID, "name": pet.Name, "species": pet.Species, "breed": pet.Breed, "breed_id": pet.BreedID,
|
||||||
"gender": pet.Gender, "birth_date": birthDate, "weight_kg": pet.WeightKg,
|
"gender": pet.Gender, "birth_date": birthDate, "weight_kg": pet.WeightKg,
|
||||||
"avatar_file_id": pet.AvatarFileID, "avatar_url": a.fileURL(pet.AvatarFileID),
|
"avatar_file_id": pet.AvatarFileID, "avatar_url": a.fileURL(pet.AvatarFileID),
|
||||||
"coat": pet.Coat, "allergies": pet.Allergies, "care_notes": pet.CareNotes,
|
"coat": pet.Coat, "allergies": pet.Allergies, "care_notes": pet.CareNotes,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type petInput struct {
|
|||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Species string `json:"species"`
|
Species string `json:"species"`
|
||||||
Breed string `json:"breed"`
|
Breed string `json:"breed"`
|
||||||
|
BreedID *string `json:"breed_id"`
|
||||||
Gender string `json:"gender"`
|
Gender string `json:"gender"`
|
||||||
BirthDate string `json:"birth_date"`
|
BirthDate string `json:"birth_date"`
|
||||||
WeightKg float64 `json:"weight_kg"`
|
WeightKg float64 `json:"weight_kg"`
|
||||||
@@ -81,6 +82,7 @@ func (a *API) updatePet(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
pet.Name, pet.Species, pet.Breed, pet.Gender = updated.Name, updated.Species, updated.Breed, updated.Gender
|
pet.Name, pet.Species, pet.Breed, pet.Gender = updated.Name, updated.Species, updated.Breed, updated.Gender
|
||||||
pet.BirthDate, pet.WeightKg, pet.AvatarFileID = updated.BirthDate, updated.WeightKg, updated.AvatarFileID
|
pet.BirthDate, pet.WeightKg, pet.AvatarFileID = updated.BirthDate, updated.WeightKg, updated.AvatarFileID
|
||||||
|
pet.BreedID = updated.BreedID
|
||||||
pet.Coat, pet.Allergies, pet.CareNotes = updated.Coat, updated.Allergies, updated.CareNotes
|
pet.Coat, pet.Allergies, pet.CareNotes = updated.Coat, updated.Allergies, updated.CareNotes
|
||||||
if err := a.db.Save(&pet).Error; err != nil {
|
if err := a.db.Save(&pet).Error; err != nil {
|
||||||
fail(c, http.StatusInternalServerError, "更新宠物失败")
|
fail(c, http.StatusInternalServerError, "更新宠物失败")
|
||||||
@@ -110,6 +112,27 @@ func (a *API) buildPet(c *gin.Context, in petInput) (model.Pet, bool) {
|
|||||||
return pet, false
|
return pet, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
in.Breed = strings.TrimSpace(in.Breed)
|
||||||
|
if len([]rune(in.Breed)) > 128 {
|
||||||
|
fail(c, http.StatusBadRequest, "品种名称过长")
|
||||||
|
return pet, false
|
||||||
|
}
|
||||||
|
if in.BreedID != nil {
|
||||||
|
var breed model.Breed
|
||||||
|
if !validID(*in.BreedID) || a.db.First(&breed, "id = ?", *in.BreedID).Error != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "品种不存在,请重新选择或手动填写")
|
||||||
|
return pet, false
|
||||||
|
}
|
||||||
|
// A disabled breed can be retained on an existing pet, but not newly selected.
|
||||||
|
if !breed.Enabled {
|
||||||
|
var current model.Pet
|
||||||
|
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ? AND breed_id = ?", c.Param("id"), actor(c), *in.BreedID).First(¤t).Error != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "该品种已停用,请手动填写或重新选择")
|
||||||
|
return pet, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in.Breed = breed.Name
|
||||||
|
}
|
||||||
var birthday *time.Time
|
var birthday *time.Time
|
||||||
if in.BirthDate != "" {
|
if in.BirthDate != "" {
|
||||||
d, err := parseDay(in.BirthDate)
|
d, err := parseDay(in.BirthDate)
|
||||||
@@ -120,7 +143,7 @@ func (a *API) buildPet(c *gin.Context, in petInput) (model.Pet, bool) {
|
|||||||
birthday = &d
|
birthday = &d
|
||||||
}
|
}
|
||||||
pet = model.Pet{Name: in.Name, Species: in.Species, Breed: strings.TrimSpace(in.Breed),
|
pet = model.Pet{Name: in.Name, Species: in.Species, Breed: strings.TrimSpace(in.Breed),
|
||||||
Gender: in.Gender, BirthDate: birthday, WeightKg: in.WeightKg,
|
BreedID: in.BreedID, Gender: in.Gender, BirthDate: birthday, WeightKg: in.WeightKg,
|
||||||
AvatarFileID: in.AvatarFileID, Coat: in.Coat, Allergies: in.Allergies, CareNotes: in.CareNotes}
|
AvatarFileID: in.AvatarFileID, Coat: in.Coat, Allergies: in.Allergies, CareNotes: in.CareNotes}
|
||||||
return pet, true
|
return pet, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,11 +37,17 @@ func (a *API) Register(r *gin.Engine) {
|
|||||||
api.POST("/auth/login", a.login)
|
api.POST("/auth/login", a.login)
|
||||||
api.POST("/auth/dev", a.devLogin)
|
api.POST("/auth/dev", a.devLogin)
|
||||||
api.POST("/admin/login", a.adminLogin)
|
api.POST("/admin/login", a.adminLogin)
|
||||||
|
api.POST("/auth/refresh", a.refreshToken("user"))
|
||||||
|
api.POST("/admin/refresh", a.refreshToken("admin"))
|
||||||
|
api.POST("/auth/logout", a.revokeRefresh("user"))
|
||||||
|
api.POST("/admin/logout", a.revokeRefresh("admin"))
|
||||||
|
|
||||||
user := api.Group("")
|
user := api.Group("")
|
||||||
user.Use(a.requireRole("user"))
|
user.Use(a.requireRole("user"))
|
||||||
user.GET("/environment", a.environment)
|
user.GET("/environment", a.environment)
|
||||||
user.GET("/me", a.me)
|
user.GET("/me", a.me)
|
||||||
|
user.GET("/breeds", a.listBreeds)
|
||||||
|
user.GET("/breeds/:id", a.breedDetail)
|
||||||
user.GET("/pets", a.listPets)
|
user.GET("/pets", a.listPets)
|
||||||
user.POST("/pets", a.createPet)
|
user.POST("/pets", a.createPet)
|
||||||
user.GET("/pets/:id", a.getPet)
|
user.GET("/pets/:id", a.getPet)
|
||||||
@@ -72,6 +78,10 @@ func (a *API) Register(r *gin.Engine) {
|
|||||||
|
|
||||||
admin := api.Group("/admin")
|
admin := api.Group("/admin")
|
||||||
admin.Use(a.requireRole("admin"))
|
admin.Use(a.requireRole("admin"))
|
||||||
|
admin.GET("/breeds", a.adminBreeds)
|
||||||
|
admin.POST("/breeds", a.createBreed)
|
||||||
|
admin.PUT("/breeds/:id", a.updateBreed)
|
||||||
|
admin.PUT("/breeds/:id/enabled", a.setBreedEnabled)
|
||||||
admin.GET("/stats", a.adminStats)
|
admin.GET("/stats", a.adminStats)
|
||||||
admin.GET("/users", a.adminUsers)
|
admin.GET("/users", a.adminUsers)
|
||||||
admin.GET("/users/:id/pets", a.adminUserPets)
|
admin.GET("/users/:id/pets", a.adminUserPets)
|
||||||
@@ -86,18 +96,16 @@ func (a *API) Register(r *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type claims struct {
|
type claims struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
SessionID string `json:"sid,omitempty"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *API) token(id, role string) (string, error) {
|
func (a *API) token(id, role string) (string, error) { return a.sessionToken(id, role, "") }
|
||||||
expire := a.cfg.JWT.ExpireHours
|
func (a *API) sessionToken(id, role, sessionID string) (string, error) {
|
||||||
if expire <= 0 {
|
|
||||||
expire = 168
|
|
||||||
}
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{Role: role, RegisteredClaims: jwt.RegisteredClaims{
|
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{Role: role, SessionID: sessionID, RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Subject: id, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(expire) * time.Hour)),
|
ID: uuid.NewString(), Subject: id, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(2 * time.Hour)),
|
||||||
}})
|
}})
|
||||||
return t.SignedString([]byte(a.cfg.JWT.Secret))
|
return t.SignedString([]byte(a.cfg.JWT.Secret))
|
||||||
}
|
}
|
||||||
@@ -127,6 +135,20 @@ func (a *API) requireRole(role string) gin.HandlerFunc {
|
|||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Reject credentials issued with the old seven-day lifetime.
|
||||||
|
if cl.IssuedAt == nil || cl.ExpiresAt == nil || cl.ExpiresAt.Sub(cl.IssuedAt.Time) > 2*time.Hour {
|
||||||
|
fail(c, http.StatusUnauthorized, "登录已过期")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cl.SessionID != "" {
|
||||||
|
var count int64
|
||||||
|
if !validID(cl.SessionID) || a.db.Model(&model.RefreshSession{}).Where("id = ? AND actor_id = ? AND role = ? AND revoked = false AND expires_at > ?", cl.SessionID, cl.Subject, role, time.Now()).Count(&count).Error != nil || count != 1 {
|
||||||
|
fail(c, http.StatusUnauthorized, "登录已过期")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if role == "user" {
|
if role == "user" {
|
||||||
var user model.User
|
var user model.User
|
||||||
if err := a.db.Select("id", "disabled").First(&user, "id = ?", cl.Subject).Error; err != nil || user.Disabled {
|
if err := a.db.Select("id", "disabled").First(&user, "id = ?", cl.Subject).Error; err != nil || user.Disabled {
|
||||||
|
|||||||
@@ -47,12 +47,64 @@ type Admin struct {
|
|||||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefreshSession stores only a hash of the rotating refresh credential.
|
||||||
|
type RefreshSession struct {
|
||||||
|
Base
|
||||||
|
ActorID string `gorm:"type:uuid;index;not null" json:"-"`
|
||||||
|
Role string `gorm:"size:16;index;not null" json:"-"`
|
||||||
|
TokenHash string `gorm:"size:64;uniqueIndex;not null" json:"-"`
|
||||||
|
ExpiresAt time.Time `gorm:"index;not null" json:"-"`
|
||||||
|
Revoked bool `gorm:"not null;default:false" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Breed struct {
|
||||||
|
Base
|
||||||
|
LifeSpan string `gorm:"size:128" json:"life_span"`
|
||||||
|
Exercise string `gorm:"size:255" json:"exercise"`
|
||||||
|
Grooming string `gorm:"size:255" json:"grooming"`
|
||||||
|
EditorialLocked bool `gorm:"not null;default:false" json:"editorial_locked"`
|
||||||
|
FieldSources map[string]string `gorm:"serializer:json;type:jsonb" json:"field_sources"`
|
||||||
|
References []BreedReference `gorm:"foreignKey:BreedID" json:"references,omitempty"`
|
||||||
|
Origin string `gorm:"size:128" json:"origin"`
|
||||||
|
BreedGroup string `gorm:"size:255" json:"breed_group"`
|
||||||
|
FCINumber string `gorm:"size:16" json:"fci_number"`
|
||||||
|
Height string `gorm:"size:128" json:"height"`
|
||||||
|
Weight string `gorm:"size:128" json:"weight"`
|
||||||
|
Summary string `gorm:"type:text" json:"summary"`
|
||||||
|
Temperament string `gorm:"size:255" json:"temperament"`
|
||||||
|
Traits string `gorm:"type:text" json:"traits"`
|
||||||
|
CareNotes string `gorm:"type:text" json:"care_notes"`
|
||||||
|
EncyclopediaUpdatedAt string `gorm:"size:10" json:"encyclopedia_updated_at"`
|
||||||
|
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
|
||||||
|
EnglishName string `gorm:"size:128" json:"english_name"`
|
||||||
|
Aliases string `gorm:"size:512" json:"aliases"`
|
||||||
|
Pinyin string `gorm:"size:1024;index;not null" json:"pinyin"`
|
||||||
|
PinyinInitials string `gorm:"size:128" json:"pinyin_initials"`
|
||||||
|
Initial string `gorm:"size:1;index;not null" json:"initial"`
|
||||||
|
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||||
|
Source string `gorm:"size:128" json:"source"`
|
||||||
|
SourceKey *string `gorm:"size:128;uniqueIndex" json:"source_key"`
|
||||||
|
SourceURL string `gorm:"size:512" json:"source_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Facts contain short structured facts, never copied articles or remote images.
|
||||||
|
type BreedReference struct {
|
||||||
|
Base
|
||||||
|
BreedID string `gorm:"type:uuid;uniqueIndex:idx_breed_provider;not null" json:"breed_id"`
|
||||||
|
Provider string `gorm:"size:32;uniqueIndex:idx_breed_provider;not null" json:"provider"`
|
||||||
|
ExternalID string `gorm:"size:128;not null" json:"external_id"`
|
||||||
|
URL string `gorm:"size:512;not null" json:"url"`
|
||||||
|
RetrievedAt string `gorm:"size:10;not null" json:"retrieved_at"`
|
||||||
|
Facts map[string]string `gorm:"serializer:json;type:jsonb;not null" json:"facts"`
|
||||||
|
}
|
||||||
|
|
||||||
type Pet struct {
|
type Pet struct {
|
||||||
Base
|
Base
|
||||||
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
|
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
|
||||||
Name string `gorm:"size:64;not null" json:"name"`
|
Name string `gorm:"size:64;not null" json:"name"`
|
||||||
Species string `gorm:"size:16;not null;default:dog" json:"species"`
|
Species string `gorm:"size:16;not null;default:dog" json:"species"`
|
||||||
Breed string `gorm:"size:64" json:"breed"`
|
Breed string `gorm:"size:128" json:"breed"`
|
||||||
|
BreedID *string `gorm:"type:uuid;index" json:"breed_id"`
|
||||||
Gender string `gorm:"size:16" json:"gender"`
|
Gender string `gorm:"size:16" json:"gender"`
|
||||||
BirthDate *time.Time `gorm:"type:date" json:"birth_date"`
|
BirthDate *time.Time `gorm:"type:date" json:"birth_date"`
|
||||||
WeightKg float64 `gorm:"type:numeric(6,2)" json:"weight_kg"`
|
WeightKg float64 `gorm:"type:numeric(6,2)" json:"weight_kg"`
|
||||||
@@ -140,7 +192,7 @@ type MeetupPet struct {
|
|||||||
|
|
||||||
func All() []any {
|
func All() []any {
|
||||||
return []any{
|
return []any{
|
||||||
&File{}, &User{}, &Admin{}, &Pet{}, &CareTask{}, &TaskCompletion{},
|
&File{}, &User{}, &Admin{}, &RefreshSession{}, &Breed{}, &BreedReference{}, &Pet{}, &CareTask{}, &TaskCompletion{},
|
||||||
&Walk{}, &WalkPet{}, &WalkPoint{}, &Meetup{}, &MeetupParticipant{}, &MeetupPet{},
|
&Walk{}, &WalkPet{}, &WalkPoint{}, &Meetup{}, &MeetupParticipant{}, &MeetupPet{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
Generated
+58
@@ -17,6 +17,7 @@
|
|||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
"react-router-dom": "^7.18.4",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
"tailwind-merge": "^3.6.0"
|
"tailwind-merge": "^3.6.0"
|
||||||
},
|
},
|
||||||
@@ -1600,6 +1601,19 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -3076,6 +3090,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"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",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"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.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-style-singleton": {
|
"node_modules/react-style-singleton": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
@@ -3282,6 +3334,12 @@
|
|||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
"react-router-dom": "^7.18.4",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
"tailwind-merge": "^3.6.0"
|
"tailwind-merge": "^3.6.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { NavLink, Navigate, useNavigate, useLocation, useParams } from "react-router-dom";
|
||||||
|
import Breeds from "@/components/admin/Breeds";
|
||||||
import { useEffect, useState, type FormEvent } from "react";
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
import {
|
import {
|
||||||
|
Dog,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
Files,
|
Files,
|
||||||
@@ -37,17 +40,21 @@ const icons = {
|
|||||||
overview: LayoutDashboard,
|
overview: LayoutDashboard,
|
||||||
users: Users,
|
users: Users,
|
||||||
pets: PawPrint,
|
pets: PawPrint,
|
||||||
|
breeds: Dog,
|
||||||
walks: Route,
|
walks: Route,
|
||||||
meetups: CalendarDays,
|
meetups: CalendarDays,
|
||||||
tasks: CheckCheck,
|
tasks: CheckCheck,
|
||||||
files: Files,
|
files: Files,
|
||||||
};
|
};
|
||||||
|
type Tab = Section | "overview" | "breeds";
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
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()),
|
const [token, setAuth] = useState(getToken()),
|
||||||
[username, setUsername] = useState("sundynix"),
|
[username, setUsername] = useState("sundynix"),
|
||||||
[password, setPassword] = useState("");
|
[password, setPassword] = useState("");
|
||||||
const [tab, setTab] = useState<Section | "overview">("overview"),
|
const [revision, refresh] = useState(0),
|
||||||
[revision, refresh] = useState(0),
|
|
||||||
[stats, setStats] = useState<Stats | null>(null);
|
[stats, setStats] = useState<Stats | null>(null);
|
||||||
const [error, setError] = useState(""),
|
const [error, setError] = useState(""),
|
||||||
[busy, setBusy] = useState(false),
|
[busy, setBusy] = useState(false),
|
||||||
@@ -66,8 +73,10 @@ export default function App() {
|
|||||||
setConfirm(null);
|
setConfirm(null);
|
||||||
setError("登录已过期,请重新登录");
|
setError("登录已过期,请重新登录");
|
||||||
};
|
};
|
||||||
|
const sync = () => setAuth(getToken());
|
||||||
|
window.addEventListener("storage", sync);
|
||||||
window.addEventListener("admin-auth-expired", expired);
|
window.addEventListener("admin-auth-expired", expired);
|
||||||
return () => window.removeEventListener("admin-auth-expired", expired);
|
return () => { window.removeEventListener("admin-auth-expired", expired); window.removeEventListener("storage", sync); };
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || tab !== "overview") return;
|
if (!token || tab !== "overview") return;
|
||||||
@@ -94,7 +103,7 @@ export default function App() {
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const value = await api.login(username.trim(), password);
|
const value = await api.login(username.trim(), password);
|
||||||
setToken(value.token);
|
setToken(value);
|
||||||
setAuth(value.token);
|
setAuth(value.token);
|
||||||
setPassword("");
|
setPassword("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -104,6 +113,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function logout() {
|
function logout() {
|
||||||
|
void api.logout().catch(() => {});
|
||||||
clearToken();
|
clearToken();
|
||||||
setAuth("");
|
setAuth("");
|
||||||
setPassword("");
|
setPassword("");
|
||||||
@@ -113,9 +123,9 @@ export default function App() {
|
|||||||
setError("");
|
setError("");
|
||||||
setNotice("");
|
setNotice("");
|
||||||
}
|
}
|
||||||
function navigate(section: Section | "overview") {
|
function navigate(section: Tab) {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
setTab(section);
|
routerNavigate(`/admin/${section}`);
|
||||||
setError("");
|
setError("");
|
||||||
setNotice("");
|
setNotice("");
|
||||||
}
|
}
|
||||||
@@ -148,6 +158,12 @@ export default function App() {
|
|||||||
? "启用用户"
|
? "启用用户"
|
||||||
: "停用用户"
|
: "停用用户"
|
||||||
: "取消约遛活动";
|
: "取消约遛活动";
|
||||||
|
if (section !== "login" && !(section && Object.hasOwn(labels, section))) return <Navigate to="/admin/overview" replace />;
|
||||||
|
if (!token && section !== "login") return <Navigate to="/admin/login" replace state={{ from: location.pathname }} />;
|
||||||
|
if (token && section === "login") {
|
||||||
|
const from = location.state?.from;
|
||||||
|
return <Navigate to={typeof from === "string" && /^\/admin\/(overview|users|pets|breeds|walks|meetups|tasks|files)$/.test(from) ? from : "/admin/overview"} replace />;
|
||||||
|
}
|
||||||
if (!token)
|
if (!token)
|
||||||
return (
|
return (
|
||||||
<main className="login-page">
|
<main className="login-page">
|
||||||
@@ -231,20 +247,20 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="side-caption">陪伴工作台</p>
|
<p className="side-caption">陪伴工作台</p>
|
||||||
<nav aria-label="管理导航">
|
<nav aria-label="管理导航">
|
||||||
{(Object.keys(labels) as (Section | "overview")[]).map((key) => {
|
{(Object.keys(labels) as Tab[]).map((key) => {
|
||||||
const Icon = icons[key];
|
const Icon = icons[key];
|
||||||
return (
|
return (
|
||||||
<button
|
<NavLink
|
||||||
|
to={`/admin/${key}`}
|
||||||
key={key}
|
key={key}
|
||||||
className={`nav-item ${tab === key ? "active" : ""}`}
|
className={`nav-item ${tab === key ? "active" : ""}`}
|
||||||
aria-current={tab === key ? "page" : undefined}
|
aria-current={tab === key ? "page" : undefined}
|
||||||
title={labels[key]}
|
title={labels[key]}
|
||||||
onClick={() => navigate(key)}
|
|
||||||
>
|
>
|
||||||
<Icon size={19} />
|
<Icon size={19} />
|
||||||
<span>{labels[key]}</span>
|
<span>{labels[key]}</span>
|
||||||
{tab === key && <span className="nav-dot" />}
|
{tab === key && <span className="nav-dot" />}
|
||||||
</button>
|
</NavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -318,6 +334,8 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
{tab === "overview" ? (
|
{tab === "overview" ? (
|
||||||
<Overview stats={stats} navigate={navigate} />
|
<Overview stats={stats} navigate={navigate} />
|
||||||
|
) : tab === "breeds" ? (
|
||||||
|
<Breeds revision={revision} />
|
||||||
) : (
|
) : (
|
||||||
<Records
|
<Records
|
||||||
key={tab}
|
key={tab}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { Section } from "@/lib/api";
|
import type { Section } from "@/lib/api";
|
||||||
export const labels: Record<Section | "overview", string> = {
|
export const labels: Record<Section | "overview" | "breeds", string> = {
|
||||||
overview: "工作台",
|
overview: "工作台",
|
||||||
users: "用户管理",
|
users: "用户管理",
|
||||||
pets: "宠物档案",
|
pets: "宠物档案",
|
||||||
|
breeds: "狗狗品种",
|
||||||
walks: "遛狗记录",
|
walks: "遛狗记录",
|
||||||
meetups: "约遛活动",
|
meetups: "约遛活动",
|
||||||
tasks: "日常任务",
|
tasks: "日常任务",
|
||||||
|
|||||||
@@ -1090,3 +1090,28 @@ td strong {
|
|||||||
padding-right: 20px;
|
padding-right: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-item { text-decoration: none; }
|
||||||
|
.breed-alphabet { display:flex; flex-wrap:wrap; gap:6px; padding:0 24px 24px; }
|
||||||
|
.breed-alphabet button { min-width:30px; height:30px; border-radius:9px; border:1px solid #eeded1; padding:0 8px; color:#927766; }
|
||||||
|
.breed-alphabet button.active { background:#ffeadc; border-color:#dfa080; color:#b85837; }
|
||||||
|
.breed-aliases { max-width:240px; white-space:normal; }
|
||||||
|
.breed-form { display:grid; gap:18px; }
|
||||||
|
.breed-form label { display:grid; gap:8px; }
|
||||||
|
.breed-form .breed-enabled { display:flex; align-items:center; gap:10px; }
|
||||||
|
|
||||||
|
.breed-dialog {max-height:85vh;overflow-y:auto;}
|
||||||
|
.breed-fact-fields {display:grid;grid-template-columns:1fr 1fr;gap:16px;}
|
||||||
|
.breed-form textarea {width:100%;border:1px solid #eadacb;border-radius:12px;padding:12px;resize:vertical;}
|
||||||
|
.breed-source-badges{display:flex;flex-wrap:wrap;gap:6px;max-width:280px}
|
||||||
|
.breed-source-badges a{text-decoration:none}
|
||||||
|
.breed-editor-tabs{display:flex;gap:10px;margin-bottom:8px}
|
||||||
|
.breed-source-list{display:grid;gap:16px}
|
||||||
|
.breed-source-card{border:1px solid #ead9cc;background:#fffbf7;padding:18px;border-radius:16px}
|
||||||
|
.breed-source-card header{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||||
|
.breed-source-card small{display:block;margin-top:8px;color:#a28a78}
|
||||||
|
.breed-source-card dl{display:grid;gap:12px;margin-top:18px}
|
||||||
|
.breed-source-card dl>div{display:grid;grid-template-columns:90px 1fr;gap:12px}
|
||||||
|
.breed-source-card dt{color:#a28a78;font-size:13px}
|
||||||
|
.breed-source-card dd{margin:0;font-size:14px;overflow-wrap:anywhere}
|
||||||
|
.breed-form select{width:100%;min-height:40px;border:1px solid #ead9cc;border-radius:10px;padding:8px;background:white}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
export type Section =
|
export type Section =
|
||||||
"users" | "pets" | "walks" | "meetups" | "tasks" | "files";
|
| "users"
|
||||||
|
| "pets"
|
||||||
|
| "walks"
|
||||||
|
| "meetups"
|
||||||
|
| "tasks"
|
||||||
|
| "files";
|
||||||
export interface Row {
|
export interface Row {
|
||||||
id: string;
|
id: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
@@ -52,6 +57,64 @@ export interface Row {
|
|||||||
bucket?: string;
|
bucket?: string;
|
||||||
object_key?: 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 {
|
export interface Point {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
@@ -79,13 +142,101 @@ export interface Stats {
|
|||||||
storage_bytes: number;
|
storage_bytes: number;
|
||||||
daily_walks: { date: string; count: number }[];
|
daily_walks: { date: string; count: number }[];
|
||||||
}
|
}
|
||||||
const key = "pets_admin_token";
|
export interface Session {
|
||||||
|
token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
expires_at: number;
|
||||||
|
refresh_expires_at: number;
|
||||||
|
}
|
||||||
|
const key = "pets_admin_token",
|
||||||
|
refreshKey = "pets_admin_refresh_token",
|
||||||
|
expiryKey = "pets_admin_expires_at";
|
||||||
export const getToken = () => localStorage.getItem(key) || "";
|
export const getToken = () => localStorage.getItem(key) || "";
|
||||||
export const setToken = (value: string) => localStorage.setItem(key, value);
|
export const setToken = (value: Session) => {
|
||||||
export const clearToken = () => localStorage.removeItem(key);
|
localStorage.setItem(refreshKey, value.refresh_token);
|
||||||
|
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 });
|
const http = axios.create({ baseURL: "/api", timeout: 20000 });
|
||||||
http.interceptors.request.use((config) => {
|
let refreshing: Promise<string> | null = null;
|
||||||
const token = getToken();
|
function expireSession(usedRefresh: string) {
|
||||||
|
if (localStorage.getItem(refreshKey) !== usedRefresh) return;
|
||||||
|
clearToken();
|
||||||
|
window.dispatchEvent(new Event("admin-auth-expired"));
|
||||||
|
}
|
||||||
|
async function performRefresh(): Promise<string> {
|
||||||
|
const used = localStorage.getItem(refreshKey) || "";
|
||||||
|
if (!used) {
|
||||||
|
clearToken();
|
||||||
|
window.dispatchEvent(new Event("admin-auth-expired"));
|
||||||
|
throw new Error("登录已过期,请重新登录");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await raw.post("/admin/refresh", { refresh_token: used });
|
||||||
|
if (response.data?.code !== 0 || !response.data.data?.refresh_token)
|
||||||
|
throw new Error(response.data?.message || "刷新登录失败");
|
||||||
|
// Logout while the request is in flight must not resurrect the session.
|
||||||
|
if (localStorage.getItem(refreshKey) !== used) {
|
||||||
|
if (getToken()) return getToken();
|
||||||
|
throw new Error("已退出登录");
|
||||||
|
}
|
||||||
|
setToken(response.data.data);
|
||||||
|
return response.data.data.token;
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
axios.isAxiosError(error) &&
|
||||||
|
[401, 403].includes(error.response?.status || 0)
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
localStorage.getItem(refreshKey) &&
|
||||||
|
localStorage.getItem(refreshKey) !== used
|
||||||
|
)
|
||||||
|
return getToken();
|
||||||
|
expireSession(used);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
axios.isAxiosError(error)
|
||||||
|
? error.response?.data?.message || "暂时无法刷新登录,请重试"
|
||||||
|
: (error as Error).message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function refreshSession(usedAccess?: string): Promise<string> {
|
||||||
|
if (refreshing) return refreshing;
|
||||||
|
const work = () => {
|
||||||
|
if (usedAccess && getToken() !== usedAccess && getToken())
|
||||||
|
return Promise.resolve(getToken());
|
||||||
|
const expires = Number(localStorage.getItem(expiryKey));
|
||||||
|
if (!usedAccess && expires > Date.now() / 1000 + 60 && getToken())
|
||||||
|
return Promise.resolve(getToken());
|
||||||
|
return performRefresh();
|
||||||
|
};
|
||||||
|
// Serialize browser tabs as well as requests in this tab when Web Locks are available.
|
||||||
|
refreshing = (
|
||||||
|
navigator.locks
|
||||||
|
? navigator.locks.request("pets-admin-refresh", work)
|
||||||
|
: work()
|
||||||
|
).finally(() => {
|
||||||
|
refreshing = null;
|
||||||
|
});
|
||||||
|
return refreshing;
|
||||||
|
}
|
||||||
|
http.interceptors.request.use(async (config) => {
|
||||||
|
let token = getToken();
|
||||||
|
const expires = Number(localStorage.getItem(expiryKey));
|
||||||
|
if (
|
||||||
|
token &&
|
||||||
|
config.url !== "/admin/login" &&
|
||||||
|
expires &&
|
||||||
|
expires <= Date.now() / 1000 + 60
|
||||||
|
)
|
||||||
|
token = await refreshSession();
|
||||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
@@ -95,9 +246,27 @@ http.interceptors.response.use(
|
|||||||
if (body?.code === 0) return body.data;
|
if (body?.code === 0) return body.data;
|
||||||
throw new Error(body?.message || "请求失败");
|
throw new Error(body?.message || "请求失败");
|
||||||
},
|
},
|
||||||
(error) => {
|
async (error) => {
|
||||||
if (axios.isCancel(error)) return Promise.reject(error);
|
if (axios.isCancel(error)) return Promise.reject(error);
|
||||||
if (error.response?.status === 401) {
|
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();
|
clearToken();
|
||||||
window.dispatchEvent(new Event("admin-auth-expired"));
|
window.dispatchEvent(new Event("admin-auth-expired"));
|
||||||
}
|
}
|
||||||
@@ -106,14 +275,36 @@ http.interceptors.response.use(
|
|||||||
error.response?.data?.message ||
|
error.response?.data?.message ||
|
||||||
(error.code === "ECONNABORTED"
|
(error.code === "ECONNABORTED"
|
||||||
? "请求超时,请重试"
|
? "请求超时,请重试"
|
||||||
: "无法连接服务器,请稍后重试"),
|
: error.message || "无法连接服务器,请稍后重试"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
export const api = {
|
export const api = {
|
||||||
|
logout: () => {
|
||||||
|
const refresh_token = localStorage.getItem(refreshKey);
|
||||||
|
return refresh_token
|
||||||
|
? raw.post("/admin/logout", { refresh_token })
|
||||||
|
: Promise.resolve();
|
||||||
|
},
|
||||||
|
breeds: (
|
||||||
|
params: {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
q: string;
|
||||||
|
status: string;
|
||||||
|
initial: string;
|
||||||
|
},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => http.get<unknown, BreedPage>("/admin/breeds", { params, signal }),
|
||||||
|
saveBreed: (id: string, data: BreedInput) =>
|
||||||
|
id
|
||||||
|
? http.put(`/admin/breeds/${id}`, data)
|
||||||
|
: http.post("/admin/breeds", data),
|
||||||
|
setBreedEnabled: (id: string, enabled: boolean) =>
|
||||||
|
http.put(`/admin/breeds/${id}/enabled`, { enabled }),
|
||||||
login: (username: string, password: string) =>
|
login: (username: string, password: string) =>
|
||||||
http.post<unknown, { token: string }>("/admin/login", {
|
http.post<unknown, Session>("/admin/login", {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<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>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,3 +18,5 @@
|
|||||||
4. 用另一个微信账号报名约遛,确认已报名和退出状态。
|
4. 用另一个微信账号报名约遛,确认已报名和退出状态。
|
||||||
|
|
||||||
小程序源码约 600 KB,低于微信主包 2 MB 限制。真机调试前请确认小程序后台已配置 `pet.sundynix.cn` 为合法请求域名。
|
小程序源码约 600 KB,低于微信主包 2 MB 限制。真机调试前请确认小程序后台已配置 `pet.sundynix.cn` 为合法请求域名。
|
||||||
|
|
||||||
|
底部导航顺序:日常、任务、遛狗、宠物、知识。遛狗页面内切换轨迹记录和约遛;知识提供犬种 A–Z 搜索与百科详情,宠物表单支持选品种或手动填写。访问与刷新 token 均为两小时,到期前自动续期,刷新失效时重新微信登录。
|
||||||
|
|||||||
+23
-8
@@ -1,15 +1,26 @@
|
|||||||
App({
|
App({
|
||||||
globalData: { token: "", user: null },
|
globalData: { token: "", user: null, expiresAt: 0 },
|
||||||
onLaunch() {
|
onLaunch() {
|
||||||
this.globalData.token = wx.getStorageSync("pet_token") || "";
|
this.globalData.token = wx.getStorageSync("pet_token") || "";
|
||||||
|
this.globalData.expiresAt = Number(wx.getStorageSync("pet_token_expires_at")) || 0;
|
||||||
// Start immediately; pages await the same promise and display failures themselves.
|
// Start immediately; pages await the same promise and display failures themselves.
|
||||||
this.login().catch(() => {});
|
this.login().catch(() => {});
|
||||||
},
|
},
|
||||||
async login() {
|
async login() {
|
||||||
if (this.loginPromise) return this.loginPromise;
|
if (this.loginPromise) return this.loginPromise;
|
||||||
if (this.globalData.token) return this.globalData.token;
|
if (this.globalData.token && (!this.globalData.expiresAt || this.globalData.expiresAt > Date.now() / 1000 + 60)) return this.globalData.token;
|
||||||
this.loginPromise = (async () => {
|
this.loginPromise = (async () => {
|
||||||
const { request } = require("./utils/api");
|
const { request } = require("./utils/api");
|
||||||
|
const refreshToken = wx.getStorageSync("pet_refresh_token");
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const pair = await request("/auth/refresh", "POST", { refresh_token: refreshToken }, false);
|
||||||
|
return this.saveSession(pair);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status !== 401 && error.status !== 403) throw error;
|
||||||
|
wx.removeStorageSync("pet_refresh_token");
|
||||||
|
}
|
||||||
|
}
|
||||||
const { code } = await new Promise((resolve, reject) =>
|
const { code } = await new Promise((resolve, reject) =>
|
||||||
wx.login({
|
wx.login({
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
@@ -19,16 +30,20 @@ App({
|
|||||||
);
|
);
|
||||||
if (!code) throw new Error("微信登录未取得凭证,请重新连接");
|
if (!code) throw new Error("微信登录未取得凭证,请重新连接");
|
||||||
const data = await request("/auth/login", "POST", { code }, false);
|
const data = await request("/auth/login", "POST", { code }, false);
|
||||||
if (!data || typeof data.token !== "string" || !data.token) {
|
|
||||||
throw new Error("登录未完成,请重新连接");
|
|
||||||
}
|
|
||||||
this.globalData.token = data.token;
|
|
||||||
this.globalData.user = data.user;
|
this.globalData.user = data.user;
|
||||||
wx.setStorageSync("pet_token", data.token);
|
return this.saveSession(data);
|
||||||
return data.token;
|
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
this.loginPromise = null;
|
this.loginPromise = null;
|
||||||
});
|
});
|
||||||
return this.loginPromise;
|
return this.loginPromise;
|
||||||
},
|
},
|
||||||
|
saveSession(data) {
|
||||||
|
if (!data || typeof data.token !== "string" || !data.token) throw new Error("登录未完成,请重新连接");
|
||||||
|
this.globalData.token = data.token;
|
||||||
|
this.globalData.expiresAt = Number(data.expires_at) || 0;
|
||||||
|
wx.setStorageSync("pet_token", data.token);
|
||||||
|
wx.setStorageSync("pet_token_expires_at", this.globalData.expiresAt);
|
||||||
|
if (data.refresh_token) wx.setStorageSync("pet_refresh_token", data.refresh_token);
|
||||||
|
return data.token;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-2
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
"pages/home/home",
|
"pages/home/home",
|
||||||
"pages/meetups/meetups",
|
|
||||||
"pages/walk/walk",
|
"pages/walk/walk",
|
||||||
"pages/tasks/tasks",
|
"pages/tasks/tasks",
|
||||||
"pages/pets/pets",
|
"pages/pets/pets",
|
||||||
"pages/pet-edit/pet-edit",
|
"pages/pet-edit/pet-edit",
|
||||||
"pages/meetup-edit/meetup-edit",
|
"pages/meetup-edit/meetup-edit",
|
||||||
"pages/share/share",
|
"pages/share/share",
|
||||||
"pages/walk-records/walk-records"
|
"pages/walk-records/walk-records",
|
||||||
|
"pages/knowledge/knowledge",
|
||||||
|
"pages/breed-detail/breed-detail"
|
||||||
],
|
],
|
||||||
"window": {
|
"window": {
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"disableScroll": true,
|
"component": true,
|
||||||
"usingComponents": {
|
"usingComponents": {
|
||||||
"tab-bar": "/components/tab-bar/index",
|
|
||||||
"pet-picker": "/components/pet-picker/index"
|
"pet-picker": "/components/pet-picker/index"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
<page-layout id="page-layout" title="约个伴一起遛">
|
|
||||||
<view class="screen">
|
<view class="screen">
|
||||||
<view class="banner">
|
<view class="banner">
|
||||||
<view class="banner-copy">
|
<view class="banner-copy">
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<button class="primary" bindtap="create">+ 发起一场约遛</button>
|
<button class="primary" bindtap="create">+ 发起一场约遛</button>
|
||||||
</view>
|
</view>
|
||||||
</page-layout>
|
<root-portal wx:if="{{active}}"><view class="overlay">
|
||||||
<view wx:if="{{active}}" class="overlay">
|
|
||||||
<view class="sheet">
|
<view class="sheet">
|
||||||
<view class="row">
|
<view class="row">
|
||||||
<strong>{{active.title}}</strong>
|
<strong>{{active.title}}</strong>
|
||||||
@@ -57,4 +55,6 @@
|
|||||||
<button wx:else class="secondary" bindtap="cancel" disabled="{{busy}}">取消这场约遛</button>
|
<button wx:else class="secondary" bindtap="cancel" disabled="{{busy}}">取消这场约遛</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<tab-bar current="/pages/meetups/meetups" />
|
|
||||||
|
|
||||||
|
</root-portal>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ path: "/pages/home/home", label: "日常", key: "daily" },
|
{ path: "/pages/home/home", label: "日常", key: "daily" },
|
||||||
{ path: "/pages/meetups/meetups", label: "约遛", key: "meetups" },
|
|
||||||
{ path: "/pages/walk/walk", label: "遛狗", key: "walk" },
|
|
||||||
{ path: "/pages/tasks/tasks", label: "任务", key: "tasks" },
|
{ path: "/pages/tasks/tasks", label: "任务", key: "tasks" },
|
||||||
|
{ path: "/pages/walk/walk", label: "遛狗", key: "walk" },
|
||||||
{ path: "/pages/pets/pets", label: "宠物", key: "pets" },
|
{ path: "/pages/pets/pets", label: "宠物", key: "pets" },
|
||||||
|
{ path: "/pages/knowledge/knowledge", label: "知识", key: "knowledge" },
|
||||||
];
|
];
|
||||||
Component({
|
Component({
|
||||||
properties: { current: String },
|
properties: { current: String },
|
||||||
|
|||||||
@@ -25,10 +25,7 @@
|
|||||||
<view class="daily-rays"></view>
|
<view class="daily-rays"></view>
|
||||||
<view class="daily-sun"></view>
|
<view class="daily-sun"></view>
|
||||||
</view>
|
</view>
|
||||||
<view wx:elif="{{item.key==='meetups'}}" class="friends-icon">
|
<view wx:elif="{{item.key==='knowledge'}}" class="book-icon"><view class="book-spine"></view><view class="book-line"></view></view>
|
||||||
<view class="friend friend-left"></view>
|
|
||||||
<view class="friend friend-right"></view>
|
|
||||||
</view>
|
|
||||||
<view wx:elif="{{item.key==='tasks'}}" class="task-icon"><view class="task-tick"></view></view>
|
<view wx:elif="{{item.key==='tasks'}}" class="task-icon"><view class="task-tick"></view></view>
|
||||||
<view wx:else class="pet-icon"><app-icon kind="pet" size="44" color="{{current===item.path?'#b95738':'#9d8779'}}" /></view>
|
<view wx:else class="pet-icon"><app-icon kind="pet" size="44" color="{{current===item.path?'#b95738':'#9d8779'}}" /></view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -101,32 +101,9 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
.active .daily-sun { background: #fff0e2; }
|
.active .daily-sun { background: #fff0e2; }
|
||||||
.friends-icon {
|
.book-icon { position:relative;width:42rpx;height:34rpx;border:3rpx solid currentColor;border-radius:8rpx 8rpx 5rpx 5rpx; }
|
||||||
position: relative;
|
.book-spine { position:absolute;left:18rpx;top:0;height:100%;border-left:3rpx solid currentColor; }
|
||||||
width: 48rpx;
|
.book-line { position:absolute;left:5rpx;right:5rpx;top:9rpx;border-top:2rpx solid currentColor; }
|
||||||
height: 45rpx;
|
|
||||||
}
|
|
||||||
.friend {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 2rpx;
|
|
||||||
width: 25rpx;
|
|
||||||
height: 16rpx;
|
|
||||||
border: 4rpx solid currentColor;
|
|
||||||
border-radius: 18rpx 18rpx 5rpx 5rpx;
|
|
||||||
}
|
|
||||||
.friend::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: -25rpx;
|
|
||||||
left: 3rpx;
|
|
||||||
width: 13rpx;
|
|
||||||
height: 13rpx;
|
|
||||||
border: 4rpx solid currentColor;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
.friend-left { left: 1rpx; }
|
|
||||||
.friend-right { right: 1rpx; background: #fff; }
|
|
||||||
.active .friend-right { background: #fff0e2; }
|
|
||||||
.task-icon {
|
.task-icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 35rpx;
|
width: 35rpx;
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
const { secure } = require("../../utils/api");
|
||||||
|
const providers = {
|
||||||
|
cku: "CKU",
|
||||||
|
fci: "FCI",
|
||||||
|
akc: "AKC",
|
||||||
|
rkc: "英国皇家犬业俱乐部",
|
||||||
|
editorial: "编辑整理",
|
||||||
|
};
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
breed: null,
|
||||||
|
loading: true,
|
||||||
|
error: "",
|
||||||
|
traits: [],
|
||||||
|
facts: [],
|
||||||
|
references: [],
|
||||||
|
traitSource: "",
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
this.id = options.id;
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
if (this.pending) return;
|
||||||
|
this.pending = true;
|
||||||
|
this.setData({ loading: true, error: "" });
|
||||||
|
try {
|
||||||
|
const breed = await secure("/breeds/" + this.id);
|
||||||
|
let scores = {};
|
||||||
|
try {
|
||||||
|
scores = JSON.parse(breed.traits || "{}") || {};
|
||||||
|
} catch (_) {}
|
||||||
|
const fieldSources = breed.field_sources || {};
|
||||||
|
const facts = [
|
||||||
|
["origin", "起源地"],
|
||||||
|
["fci_number", "FCI 编号"],
|
||||||
|
["height", "参考肩高"],
|
||||||
|
["weight", "参考体重"],
|
||||||
|
["life_span", "寿命参考"],
|
||||||
|
["breed_group", "犬种分组"],
|
||||||
|
].map(([key, label]) => ({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
value: breed[key] || "待补充",
|
||||||
|
source: breed[key] ? providers[fieldSources[key]] || "" : "",
|
||||||
|
}));
|
||||||
|
const references = (breed.references || []).map((ref) => ({
|
||||||
|
...ref,
|
||||||
|
name: providers[ref.provider] || ref.provider,
|
||||||
|
}));
|
||||||
|
this.setData({
|
||||||
|
breed,
|
||||||
|
facts,
|
||||||
|
references,
|
||||||
|
traits: Object.keys(scores)
|
||||||
|
.filter(
|
||||||
|
(name) =>
|
||||||
|
Number.isInteger(scores[name]) &&
|
||||||
|
scores[name] >= 1 &&
|
||||||
|
scores[name] <= 5,
|
||||||
|
)
|
||||||
|
.map((name) => ({ name, value: scores[name] })),
|
||||||
|
traitSource: providers[fieldSources.traits] || "来源资料",
|
||||||
|
exerciseSource: providers[fieldSources.exercise] || "",
|
||||||
|
groomingSource: providers[fieldSources.grooming] || "",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.setData({ error: error.message });
|
||||||
|
} finally {
|
||||||
|
this.pending = false;
|
||||||
|
this.setData({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
source(e) {
|
||||||
|
const ref = this.data.references.find(
|
||||||
|
(item) => item.id === e.currentTarget.dataset.id,
|
||||||
|
);
|
||||||
|
const url = ref ? ref.url : this.data.breed?.source_url;
|
||||||
|
if (!url) return;
|
||||||
|
wx.setClipboardData({
|
||||||
|
data: url,
|
||||||
|
success: () => wx.showToast({ title: "资料链接已复制", icon: "none" }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"disableScroll":true}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<page-layout id="page-layout" title="犬种小百科" back="{{true}}">
|
||||||
|
<view class="screen detail-screen">
|
||||||
|
<view wx:if="{{loading}}" class="empty">正在认识这位小伙伴…</view>
|
||||||
|
<view wx:elif="{{error}}" class="empty"><text>{{error}}</text><button class="secondary" bindtap="load">重新加载</button></view>
|
||||||
|
<block wx:elif="{{breed}}">
|
||||||
|
<view class="breed-cover"><app-icon kind="pet" size="100" color="#b95738"/><text class="breed-name">{{breed.name}}</text><text class="english">{{breed.english_name}}</text><text wx:if="{{breed.aliases}}" class="aliases">也叫:{{breed.aliases}}</text></view>
|
||||||
|
<view class="card"><view class="section-heading">认识它</view><text class="paragraph">{{breed.summary||'这个犬种的详细资料还在整理中,可以通过下方来源链接查看官方资料。'}}</text><view class="facts"><view wx:for="{{facts}}" wx:key="key"><text>{{item.label}}</text><text class="fact-value">{{item.value}}</text><text wx:if="{{item.source}}" class="fact-source">{{item.source}}</text></view></view></view>
|
||||||
|
<view wx:if="{{breed.temperament||traits.length}}" class="card"><view class="section-heading">它的小性格</view><text class="paragraph">{{breed.temperament}}</text><view wx:for="{{traits}}" wx:key="name" class="trait"><text>{{item.name}}</text><view class="trait-meter"><view style="width:{{item.value*20}}%"></view></view><text>{{item.value}}/5</text></view><text wx:if="{{traits.length}}" class="footnote">{{traitSource}} 指数 · 仅作为犬种倾向参考</text></view>
|
||||||
|
<view wx:if="{{breed.exercise||breed.grooming||breed.care_notes}}" class="card"><view class="section-heading">一起过好每一天</view><view wx:if="{{breed.exercise}}" class="care-item"><app-icon kind="paw" size="30" color="#bd7955"/><view><text class="care-title">散步与互动</text><text class="paragraph">{{breed.exercise}}</text><text wx:if="{{exerciseSource}}" class="footnote">{{exerciseSource}}</text></view></view><view wx:if="{{breed.grooming}}" class="care-item"><view class="care-circle"></view><view><text class="care-title">毛发小护理</text><text class="paragraph">{{breed.grooming}}</text><text wx:if="{{groomingSource}}" class="footnote">{{groomingSource}}</text></view></view><text wx:if="{{breed.care_notes}}" class="paragraph care-note">{{breed.care_notes}}</text><text class="footnote">需求会随年龄、身体状态和个体而变化。</text></view>
|
||||||
|
<view class="source"><view class="section-heading">这份资料从哪里来</view><view wx:for="{{references}}" wx:key="id" class="source-row" data-id="{{item.id}}" bindtap="source"><view><text class="source-name">{{item.name}}</text><text class="source-date">核对于 {{item.retrieved_at}}</text></view><text class="source-link">复制链接 ›</text></view><block wx:if="{{!references.length}}"><text>资料来源 · {{breed.source||'编辑整理'}}</text><text class="source-link" wx:if="{{breed.source_url}}" bindtap="source">复制来源链接 ›</text></block><text wx:if="{{breed.encyclopedia_updated_at}}" class="source-date">中文资料整理于 {{breed.encyclopedia_updated_at}}</text></view>
|
||||||
|
<view class="individual-note">品种是认识它的起点,每只狗狗也有自己的个性。</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
</page-layout>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
.detail-screen {padding-bottom:calc(50rpx + env(safe-area-inset-bottom));}
|
||||||
|
.breed-cover {display:flex;flex-direction:column;align-items:center;text-align:center;padding:46rpx 30rpx;background:linear-gradient(130deg,#fff0df,#eee8f7);border-radius:34rpx;margin-bottom:24rpx;}
|
||||||
|
.breed-name {font-size:37rpx;font-weight:600;margin:20rpx 0 10rpx;}
|
||||||
|
.english,.aliases {font-size:23rpx;color:#9d8779;line-height:1.7;}
|
||||||
|
.section-heading {font-size:29rpx;font-weight:600;margin-bottom:20rpx;}
|
||||||
|
.paragraph {font-size:26rpx;line-height:1.9;white-space:pre-wrap;}
|
||||||
|
.facts {display:grid;grid-template-columns:1fr 1fr;gap:26rpx;margin:30rpx 0;}
|
||||||
|
.facts view {padding:22rpx;background:#fff9f3;border-radius:20rpx;}
|
||||||
|
.facts text {display:block;font-size:22rpx;color:#9d8779;margin-bottom:8rpx;}
|
||||||
|
.facts strong {font-size:25rpx;font-weight:500;}
|
||||||
|
.group,.footnote {display:block;font-size:22rpx;color:#9d8779;}
|
||||||
|
.trait {display:flex;align-items:center;gap:18rpx;font-size:23rpx;margin:22rpx 0;}
|
||||||
|
.trait>text:first-child {width:140rpx;flex:none;}
|
||||||
|
.trait-meter {flex:1;height:12rpx;background:#f4e8de;border-radius:99rpx;overflow:hidden;}
|
||||||
|
.trait-meter view {height:100%;background:#e5a47b;border-radius:99rpx;}
|
||||||
|
.source {display:flex;flex-direction:column;gap:10rpx;padding:24rpx;color:#9d8779;font-size:22rpx;}
|
||||||
|
.source-link {color:#b95738;}
|
||||||
|
.individual-note {text-align:center;color:#a48d7e;font-size:22rpx;margin-top:20rpx;}
|
||||||
|
.facts text.fact-value{font-size:25rpx;color:#675146;font-weight:500;line-height:1.5;margin-bottom:6rpx}
|
||||||
|
.facts text.fact-source{font-size:19rpx;color:#ba9f8d;margin:0}
|
||||||
|
.care-item{display:flex;gap:18rpx;padding:22rpx 0;border-bottom:1rpx solid #f3e9df}
|
||||||
|
.care-item>view:last-child{flex:1;min-width:0}
|
||||||
|
.care-title{display:block;font-size:25rpx;color:#aa6e4e;margin-bottom:8rpx}
|
||||||
|
.care-circle{height:28rpx;width:28rpx;border:4rpx solid #d8af92;border-radius:50%;flex:none;box-sizing:border-box;margin-top:4rpx}
|
||||||
|
.care-note{display:block;margin:24rpx 0}
|
||||||
|
.source{background:#fff4e8;border-radius:28rpx;margin-top:24rpx}
|
||||||
|
.source-row{display:flex;align-items:center;justify-content:space-between;gap:20rpx;padding:18rpx 0;border-bottom:1rpx solid #efdfcf}
|
||||||
|
.source-name{display:block;font-size:25rpx;color:#735e51}
|
||||||
|
.source-date{display:block;font-size:20rpx;line-height:1.8;color:#b49a88}
|
||||||
|
.source-link{flex:none;font-size:23rpx}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
const { loadBreeds, groupBreeds } = require("../../utils/breeds");
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
groups: [],
|
||||||
|
query: "",
|
||||||
|
loading: true,
|
||||||
|
error: "",
|
||||||
|
total: 0,
|
||||||
|
initial: "",
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
if (this.pending) return;
|
||||||
|
this.pending = true;
|
||||||
|
this.setData({ loading: true, error: "" });
|
||||||
|
try {
|
||||||
|
this.rows = await loadBreeds();
|
||||||
|
this.filter();
|
||||||
|
} catch (error) {
|
||||||
|
this.setData({ error: error.message });
|
||||||
|
} finally {
|
||||||
|
this.pending = false;
|
||||||
|
this.setData({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filter() {
|
||||||
|
let groups = groupBreeds(this.rows || [], this.data.query);
|
||||||
|
if (this.data.initial)
|
||||||
|
groups = groups.filter((g) => g.letter === this.data.initial);
|
||||||
|
this.setData({
|
||||||
|
groups,
|
||||||
|
total: groups.reduce((n, g) => n + g.items.length, 0),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
search(e) {
|
||||||
|
this.setData({ query: e.detail.value, initial: "" });
|
||||||
|
this.filter();
|
||||||
|
},
|
||||||
|
letter(e) {
|
||||||
|
this.setData({ initial: e.currentTarget.dataset.letter });
|
||||||
|
this.filter();
|
||||||
|
},
|
||||||
|
open(e) {
|
||||||
|
wx.navigateTo({
|
||||||
|
url: "/pages/breed-detail/breed-detail?id=" + e.currentTarget.dataset.id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"disableScroll":true,"usingComponents":{"tab-bar":"/components/tab-bar/index"}}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<page-layout id="page-layout" title="一起懂它更多">
|
||||||
|
<view class="screen">
|
||||||
|
<view class="knowledge-hero"><text class="eyebrow">肉垫小百科</text><view class="hero-title"><text>每个小伙伴</text><text>都有自己的模样</text></view><text class="hero-copy">从认识品种开始,慢慢读懂它。</text><view class="hero-book"><view class="book-middle"></view><view class="book-stripe"></view></view></view>
|
||||||
|
<input class="knowledge-search" value="{{query}}" bindinput="search" placeholder="搜索犬种、常用名或拼音" maxlength="128" />
|
||||||
|
<scroll-view class="alphabet-scroll" scroll-x enhanced show-scrollbar="{{false}}"><view class="alphabet"><text class="{{initial===''?'selected':''}}" data-letter="" bindtap="letter">全部</text><text wx:for="{{['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z']}}" wx:key="*this" class="{{initial===item?'selected':''}}" data-letter="{{item}}" bindtap="letter">{{item}}</text></view></scroll-view>
|
||||||
|
<view class="catalog-heading"><text>犬种百科</text><text class="catalog-count">{{total}} 个小伙伴</text></view>
|
||||||
|
<view wx:if="{{loading}}" class="empty">正在打开小百科…</view>
|
||||||
|
<view wx:elif="{{error}}" class="empty"><text>{{error}}</text><button class="secondary" bindtap="load">重新加载</button></view>
|
||||||
|
<block wx:else><view wx:for="{{groups}}" wx:for-item="group" wx:key="letter"><view class="group-letter">{{group.letter}}</view><view wx:for="{{group.items}}" wx:key="id" class="breed-entry" data-id="{{item.id}}" bindtap="open"><view class="entry-letter">{{item.initial}}</view><view class="entry-copy"><text class="entry-name">{{item.name}}</text><text class="entry-alias">{{item.origin||item.aliases||item.english_name||'点开认识这位小伙伴'}}</text><text class="entry-status">{{item.has_details?'认识它的小性格':'资料整理中'}}</text></view><text class="entry-arrow">›</text></view></view><view wx:if="{{!total}}" class="empty">暂时没找到这个品种,换个名字试试吧。</view></block>
|
||||||
|
</view>
|
||||||
|
</page-layout>
|
||||||
|
<tab-bar current="/pages/knowledge/knowledge" />
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
.knowledge-hero { position:relative;overflow:hidden;padding:34rpx;background:#eee8f7;border-radius:34rpx;color:#79658c; }
|
||||||
|
.knowledge-hero .eyebrow {color:#9782aa;letter-spacing:1rpx;}
|
||||||
|
.hero-title {font-size:37rpx;font-weight:600;line-height:1.5;margin:16rpx 0 12rpx;}
|
||||||
|
.hero-copy {font-size:23rpx;}
|
||||||
|
.hero-book {position:absolute;right:45rpx;top:70rpx;width:106rpx;height:94rpx;border:5rpx solid #b4a3c5;border-radius:20rpx 20rpx 8rpx 8rpx;transform:rotate(9deg);background:#fff9f380;}
|
||||||
|
.book-middle {position:absolute;left:48rpx;height:100%;border-left:4rpx solid #b4a3c5;}
|
||||||
|
.book-stripe {position:absolute;left:14rpx;right:14rpx;top:26rpx;height:24rpx;border-top:3rpx solid #b4a3c5;border-bottom:3rpx solid #b4a3c5;}
|
||||||
|
.knowledge-search {height:92rpx;padding:0 28rpx;background:#fff;border-radius:24rpx;margin:28rpx 0 16rpx;font-size:26rpx;}
|
||||||
|
.alphabet-scroll {width:100%;white-space:nowrap;}
|
||||||
|
.alphabet {display:inline-flex;gap:10rpx;padding:4rpx 0 12rpx;}
|
||||||
|
.alphabet text {display:inline-block;padding:10rpx 20rpx;border-radius:16rpx;color:#9d8779;background:#fff;}
|
||||||
|
.alphabet .selected {color:#b95738;background:#ffeadc;}
|
||||||
|
.catalog-heading {display:flex;align-items:center;justify-content:space-between;margin:30rpx 0 8rpx;font-size:32rpx;font-weight:600;}
|
||||||
|
.catalog-count {font-size:23rpx;color:#9d8779;font-weight:400;}
|
||||||
|
.group-letter {color:#b95738;font-size:29rpx;font-weight:600;padding:20rpx 8rpx 12rpx;}
|
||||||
|
.breed-entry {display:flex;align-items:center;gap:20rpx;padding:26rpx;background:#fff;border-radius:26rpx;margin-bottom:14rpx;}
|
||||||
|
.entry-letter {width:66rpx;height:66rpx;display:flex;align-items:center;justify-content:center;flex:none;background:#fff0e2;border-radius:22rpx;color:#ba7355;font-size:30rpx;}
|
||||||
|
.entry-copy {flex:1;min-width:0;}
|
||||||
|
.entry-name {display:block;font-size:27rpx;font-weight:600;}
|
||||||
|
.entry-alias {display:block;color:#9d8779;font-size:21rpx;margin-top:6rpx;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;}
|
||||||
|
.entry-arrow {color:#bfa697;font-size:36rpx;}
|
||||||
|
|
||||||
|
.entry-status{display:block;font-size:19rpx;color:#bc9c85;margin-top:6rpx}
|
||||||
|
|
||||||
|
.hero-title>text{display:block}
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
const { secure, toast } = require("../../utils/api");
|
|
||||||
Page({
|
|
||||||
data: { meetups: [], pets: [], selected: [], active: null, userId: "", joined: false, busy: false },
|
|
||||||
onShow() {
|
|
||||||
this.load();
|
|
||||||
},
|
|
||||||
async load() {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -4,6 +4,7 @@ Page({
|
|||||||
id: "",
|
id: "",
|
||||||
name: "",
|
name: "",
|
||||||
breed: "",
|
breed: "",
|
||||||
|
breed_id: "",
|
||||||
gender: "male",
|
gender: "male",
|
||||||
birth_date: "",
|
birth_date: "",
|
||||||
weight_kg: "",
|
weight_kg: "",
|
||||||
@@ -30,6 +31,9 @@ Page({
|
|||||||
field(e) {
|
field(e) {
|
||||||
this.setData({ [e.currentTarget.dataset.key]: e.detail.value });
|
this.setData({ [e.currentTarget.dataset.key]: e.detail.value });
|
||||||
},
|
},
|
||||||
|
breedChange(e) {
|
||||||
|
this.setData({ breed: e.detail.name, breed_id: e.detail.id || "" });
|
||||||
|
},
|
||||||
date(e) {
|
date(e) {
|
||||||
this.setData({ birth_date: e.detail.value });
|
this.setData({ birth_date: e.detail.value });
|
||||||
},
|
},
|
||||||
@@ -55,6 +59,7 @@ Page({
|
|||||||
name: this.data.name,
|
name: this.data.name,
|
||||||
species: "dog",
|
species: "dog",
|
||||||
breed: this.data.breed,
|
breed: this.data.breed,
|
||||||
|
breed_id: this.data.breed_id || null,
|
||||||
gender: this.data.gender,
|
gender: this.data.gender,
|
||||||
birth_date: this.data.birth_date || "",
|
birth_date: this.data.birth_date || "",
|
||||||
weight_kg: Number(this.data.weight_kg) || 0,
|
weight_kg: Number(this.data.weight_kg) || 0,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
"disableScroll": true,
|
"disableScroll": true,
|
||||||
"usingComponents": {}
|
"usingComponents": {
|
||||||
|
"breed-picker": "/components/breed-picker/index"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="field">
|
<view class="field">
|
||||||
<view class="field-label">品种</view>
|
<view class="field-label">品种</view>
|
||||||
<input placeholder="填写它的品种" value="{{breed}}" data-key="breed" bindinput="field" />
|
<breed-picker value="{{breed}}" breed-id="{{breed_id||''}}" bind:change="breedChange" />
|
||||||
</view>
|
</view>
|
||||||
<view class="field">
|
<view class="field">
|
||||||
<view class="field-label">性别</view>
|
<view class="field-label">性别</view>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const { secure, toast } = require("../../utils/api");
|
const { secure, toast } = require("../../utils/api");
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
|
mode: "walk",
|
||||||
pets: [],
|
pets: [],
|
||||||
selected: [],
|
selected: [],
|
||||||
walk: null,
|
walk: null,
|
||||||
@@ -14,6 +15,10 @@ Page({
|
|||||||
busy: false,
|
busy: false,
|
||||||
locationError: "",
|
locationError: "",
|
||||||
},
|
},
|
||||||
|
changeMode(e) {
|
||||||
|
const mode = e.currentTarget.dataset.mode;
|
||||||
|
if (mode !== this.data.mode) this.setData({ mode });
|
||||||
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
this.load();
|
this.load();
|
||||||
clearInterval(this.clock);
|
clearInterval(this.clock);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"disableScroll": true,
|
"disableScroll": true,
|
||||||
"usingComponents": {
|
"usingComponents": {
|
||||||
"tab-bar": "/components/tab-bar/index",
|
"tab-bar": "/components/tab-bar/index",
|
||||||
"pet-picker": "/components/pet-picker/index"
|
"pet-picker": "/components/pet-picker/index",
|
||||||
|
"meetup-panel": "/components/meetup-panel/index"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<page-layout id="page-layout" title="一起去兜风">
|
<page-layout id="page-layout" title="一起去兜风">
|
||||||
<view class="screen walk-screen">
|
<view class="walk-modes"><view class="mode {{mode==='walk'?'chosen':''}}" data-mode="walk" bindtap="changeMode">记录遛狗<text wx:if="{{walk&&walk.status==='active'}}" class="recording-dot"></text></view><view class="mode {{mode==='meetups'?'chosen':''}}" data-mode="meetups" bindtap="changeMode">约个伴</view></view>
|
||||||
|
<meetup-panel wx:if="{{mode==='meetups'}}" />
|
||||||
|
<view wx:else class="screen walk-screen">
|
||||||
<view class="map-box">
|
<view class="map-box">
|
||||||
<map
|
<map
|
||||||
id="walk-map"
|
id="walk-map"
|
||||||
|
|||||||
@@ -111,3 +111,8 @@
|
|||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.walk-modes { margin:12rpx 32rpx 22rpx; padding:7rpx; display:flex; border-radius:99rpx; background:#f5e9de; }
|
||||||
|
.mode { flex:1;padding:16rpx;text-align:center;color:#9d8779;font-size:26rpx;border-radius:99rpx; }
|
||||||
|
.mode.chosen { background:#fff;color:#b95738;box-shadow:0 5rpx 15rpx #604a4010; }
|
||||||
|
.recording-dot { display:inline-block;width:10rpx;height:10rpx;background:#b95738;border-radius:50%;margin-left:10rpx; }
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
const { secure } = require("./api");
|
||||||
|
let cached;
|
||||||
|
let pending;
|
||||||
|
function loadBreeds(force = false) {
|
||||||
|
if (pending) return pending;
|
||||||
|
if (cached && !force) return Promise.resolve(cached);
|
||||||
|
pending = secure("/breeds")
|
||||||
|
.then((rows) => {
|
||||||
|
cached = rows;
|
||||||
|
return rows;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pending = null;
|
||||||
|
});
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
function groupBreeds(rows, query = "") {
|
||||||
|
const q = query.trim().toLowerCase().replace(/\s/g, "");
|
||||||
|
const groups = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (
|
||||||
|
q &&
|
||||||
|
![
|
||||||
|
row.name,
|
||||||
|
row.aliases,
|
||||||
|
row.english_name,
|
||||||
|
row.pinyin,
|
||||||
|
row.pinyin_initials,
|
||||||
|
].some((value) =>
|
||||||
|
(value || "").toLowerCase().replace(/\s/g, "").includes(q),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const letter = row.initial || "#";
|
||||||
|
if (!groups.has(letter)) groups.set(letter, []);
|
||||||
|
groups.get(letter).push(row);
|
||||||
|
}
|
||||||
|
return [...groups]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([letter, items]) => ({
|
||||||
|
letter,
|
||||||
|
anchor: letter === "#" ? "letter-other" : "letter-" + letter,
|
||||||
|
items,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
module.exports = { loadBreeds, groupBreeds };
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const test=require('node:test'), assert=require('node:assert/strict'), fs=require('node:fs'),vm=require('node:vm');
|
||||||
|
const {createRequire}=require('node:module');
|
||||||
|
const adminRequire=createRequire(require('node:path').resolve('pets-be/web/admin/package.json'));
|
||||||
|
const ts=adminRequire('typescript'),axios=adminRequire('axios').default;
|
||||||
|
const tick=()=>new Promise(resolve=>setImmediate(resolve));
|
||||||
|
function runtime(adapter) {
|
||||||
|
const storage=new Map(),events=[];
|
||||||
|
const localStorage={getItem:k=>storage.get(k)||null,setItem:(k,v)=>storage.set(k,v),removeItem:k=>storage.delete(k)};
|
||||||
|
axios.defaults.adapter=adapter;
|
||||||
|
const module={exports:{}};
|
||||||
|
const code=ts.transpileModule(fs.readFileSync('pets-be/web/admin/src/lib/api.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,esModuleInterop:true,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||||
|
vm.runInNewContext(code,{module,exports:module.exports,require:adminRequire,localStorage,navigator:{},window:{dispatchEvent:e=>events.push(e.type)},Event:class{constructor(type){this.type=type;}}});
|
||||||
|
return {...module.exports,storage,events};
|
||||||
|
}
|
||||||
|
const response=(config,data)=>({status:200,statusText:'OK',config,headers:{},data:{code:0,data}});
|
||||||
|
const unauthorized=config=>Promise.reject(new axios.AxiosError('expired','ERR_BAD_REQUEST',config,null,{status:401,data:{message:'expired'},config}));
|
||||||
|
const pair=token=>({token,refresh_token:'refresh-'+token,expires_at:Date.now()/1000+7200,refresh_expires_at:Date.now()/1000+7200});
|
||||||
|
test('管理端并发请求只刷新一次,再携带新 token 请求',async()=>{
|
||||||
|
let releases,refreshes=0,seen=[];
|
||||||
|
const r=runtime(config=>{if(config.url==='/admin/refresh'){refreshes++;return new Promise(resolve=>releases=()=>resolve(response(config,pair('new'))));}seen.push(config.headers.Authorization);return Promise.resolve(response(config,{users:1}));});
|
||||||
|
r.setToken({...pair('old'),expires_at:Date.now()/1000+30});const calls=[r.api.stats(),r.api.stats(),r.api.stats()];await tick();assert.equal(refreshes,1);releases();await Promise.all(calls);assert.deepEqual(seen,['Bearer new','Bearer new','Bearer new']);assert.equal(r.getToken(),'new');
|
||||||
|
});
|
||||||
|
test('刷新网络失败保留登录,过期拒绝则清空会话',async()=>{
|
||||||
|
let rejectAuth=false;
|
||||||
|
const r=runtime(config=>config.url==='/admin/refresh' ? rejectAuth?unauthorized(config):Promise.reject(new Error('offline')):unauthorized(config));
|
||||||
|
r.setToken({...pair('old'),expires_at:Date.now()/1000+30});await assert.rejects(r.api.stats());assert.equal(r.getToken(),'old');assert.equal(r.events.length,0);
|
||||||
|
rejectAuth=true;await assert.rejects(r.api.stats());assert.equal(r.getToken(),'');assert.ok(r.events.includes('admin-auth-expired'));
|
||||||
|
});
|
||||||
|
test('退出期间的刷新响应不会恢复会话,登录密码错误也不会递归刷新',async()=>{
|
||||||
|
let release,refreshes=0;
|
||||||
|
const r=runtime(config=>{if(config.url==='/admin/login')return unauthorized(config);if(config.url==='/admin/refresh'){refreshes++;return new Promise(resolve=>release=()=>resolve(response(config,pair('new'))));}return Promise.resolve(response(config,{}));});
|
||||||
|
r.setToken({...pair('old'),expires_at:Date.now()/1000+30});const request=r.api.stats();await tick();r.clearToken();release();await assert.rejects(request);assert.equal(r.getToken(),'');
|
||||||
|
await assert.rejects(r.api.login('sundynix','bad'));assert.equal(refreshes,1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
function authRuntime() {
|
||||||
|
let app;
|
||||||
|
const storage=new Map(), requests=[], logins=[];
|
||||||
|
const wx={ getStorageSync:k=>storage.get(k), setStorageSync:(k,v)=>storage.set(k,v), removeStorageSync:k=>storage.delete(k), login:o=>logins.push(o), request:o=>requests.push(o) };
|
||||||
|
const module={exports:{}};
|
||||||
|
vm.runInNewContext(fs.readFileSync('pets-fe/utils/api.js','utf8'), {module,wx,getApp:()=>app,require:()=>({baseURL:'https://test/api'})});
|
||||||
|
vm.runInNewContext(fs.readFileSync('pets-fe/app.js','utf8'), {wx,App:value=>app=value,require:()=>module.exports});
|
||||||
|
const reply=(req,data,statusCode=200)=>req.success({statusCode,data:statusCode===200?{code:0,data}:{message:'expired'}});
|
||||||
|
return {app,storage,requests,logins,api:module.exports,reply};
|
||||||
|
}
|
||||||
|
test('临近到期先刷新,启动与多个业务请求共用一次刷新',async()=>{
|
||||||
|
const r=authRuntime();r.storage.set('pet_token','old');r.storage.set('pet_token_expires_at',Date.now()/1000+30);r.storage.set('pet_refresh_token','refresh-old');
|
||||||
|
r.app.onLaunch();const pending=[r.api.secure('/pets'),r.api.secure('/tasks')];await tick();
|
||||||
|
assert.equal(r.requests.length,1);assert.equal(r.requests[0].url,'https://test/api/auth/refresh');assert.equal(r.logins.length,0);
|
||||||
|
r.reply(r.requests[0],{token:'new',refresh_token:'refresh-new',expires_at:Date.now()/1000+7200});await tick();
|
||||||
|
for(const req of r.requests.slice(1)){assert.equal(req.header.Authorization,'Bearer new');r.reply(req,'ok');}
|
||||||
|
assert.deepEqual(await Promise.all(pending),['ok','ok']);assert.equal(r.storage.get('pet_refresh_token'),'refresh-new');
|
||||||
|
});
|
||||||
|
test('刷新过期才回到微信登录,网络错误保留刷新凭证以供重试',async()=>{
|
||||||
|
const r=authRuntime();r.storage.set('pet_refresh_token','expired');const first=r.app.login();r.reply(r.requests[0],null,401);await tick();
|
||||||
|
assert.equal(r.logins.length,1);r.logins[0].success({code:'new-code'});await tick();r.reply(r.requests[1],{token:'new',refresh_token:'new-refresh',expires_at:Date.now()/1000+7200});await first;
|
||||||
|
r.app.globalData.token='';const retry=r.app.login();r.requests[2].fail();await assert.rejects(retry,/网络/);assert.equal(r.storage.get('pet_refresh_token'),'new-refresh');assert.equal(r.logins.length,1);
|
||||||
|
const last=r.app.login();r.reply(r.requests[3],{token:'last',refresh_token:'last-refresh',expires_at:Date.now()/1000+7200});await last;
|
||||||
|
assert.equal(r.app.globalData.token,'last');
|
||||||
|
});
|
||||||
|
test('品种按 A–Z 分组,支持别名、拼音和首字母搜索',()=>{
|
||||||
|
const module={exports:{}};vm.runInNewContext(fs.readFileSync('pets-fe/utils/breeds.js','utf8'),{module,require:()=>({secure:()=>{}})});
|
||||||
|
const rows=[{id:'k',name:'柯基',aliases:'小短腿',pinyin:'keji',pinyin_initials:'kj',initial:'K'},{id:'b',name:'边境牧羊犬',aliases:'边牧',pinyin:'bianjingmuyangquan',pinyin_initials:'bjm yq'.replace(/ /g,''),initial:'B'}];
|
||||||
|
const group=module.exports.groupBreeds;assert.equal(group(rows)[0].letter,'B');for(const q of ['柯基','小短腿','keji','KJ']) assert.equal(group(rows,q)[0].items[0].id,'k');assert.equal(group(rows,'没有这种犬').length,0);
|
||||||
|
});
|
||||||
|
test('手动填写清除品种 ID,保存宠物发送自定义品种',async()=>{
|
||||||
|
let definition;vm.runInNewContext(fs.readFileSync('pets-fe/components/breed-picker/index.js','utf8'),{require:()=>({}),Component:c=>definition=c,wx:{showToast:()=>{}}});
|
||||||
|
let change;const component={...definition.methods,data:{custom:' 地方犬 ',visible:true},setData:v=>Object.assign(component.data,v),triggerEvent:(_,v)=>change=v};component.useCustom();assert.equal(change.id,null);assert.equal(change.name,'地方犬');assert.equal(component.data.visible,false);
|
||||||
|
let pageDef,body;vm.runInNewContext(fs.readFileSync('pets-fe/pages/pet-edit/pet-edit.js','utf8'),{Page:p=>pageDef=p,require:()=>({secure:async(_p,_m,b)=>body=b}),wx:{navigateBack:()=>{}}});
|
||||||
|
const page={...pageDef,data:{...pageDef.data,name:'小狗',breed_id:'old-id'},setData:v=>Object.assign(page.data,v)};page.breedChange({detail:change});await page.save();assert.equal(body.breed_id,null);assert.equal(body.breed,'地方犬');
|
||||||
|
page.breedChange({detail:{id:'selected-id',name:'柯基'}});await page.save();assert.equal(body.breed_id,'selected-id');assert.equal(body.breed,'柯基');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('百科详情展示各字段出处,只复制用户点击的来源链接',async()=>{
|
||||||
|
let definition, copied;
|
||||||
|
const breed={name:'柯基',origin:'英国',traits:'{"运动活力":4,"无效":9}',exercise:'每天走走',field_sources:{origin:'fci',traits:'akc',exercise:'rkc'},references:[{id:'r1',provider:'fci',url:'https://www.fci.be/en/nomenclature/',retrieved_at:'2026-10-05'}]};
|
||||||
|
vm.runInNewContext(fs.readFileSync('pets-fe/pages/breed-detail/breed-detail.js','utf8'),{Page:p=>definition=p,require:()=>({secure:async()=>breed}),wx:{setClipboardData:options=>copied=options.data,showToast:()=>{}}});
|
||||||
|
const page={...definition,data:{...definition.data},id:'breed-id',setData:patch=>Object.assign(page.data,patch)};
|
||||||
|
await page.load();assert.equal(page.data.facts.find(f=>f.key==='origin').source,'FCI');assert.equal(page.data.traits.length,1);assert.equal(page.data.traitSource,'AKC');assert.equal(page.data.exerciseSource,'英国皇家犬业俱乐部');
|
||||||
|
page.source({currentTarget:{dataset:{id:'r1'}}});assert.equal(copied,breed.references[0].url);
|
||||||
|
});
|
||||||
@@ -25,7 +25,8 @@ function page(name, secure, wx = {}) {
|
|||||||
};
|
};
|
||||||
let definition;
|
let definition;
|
||||||
global.Page = (value) => { definition = value; };
|
global.Page = (value) => { definition = value; };
|
||||||
const filename = path.resolve(__dirname, `../pets-fe/pages/${name}/${name}.js`);
|
global.Component = (value) => { definition = {data:value.data,...value.methods}; };
|
||||||
|
const filename = path.resolve(__dirname, name === "meetups" ? "../pets-fe/components/meetup-panel/index.js" : `../pets-fe/pages/${name}/${name}.js`);
|
||||||
delete require.cache[filename];
|
delete require.cache[filename];
|
||||||
require(filename);
|
require(filename);
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user