Feat/dev #5
@@ -11,6 +11,8 @@ MINIO_SECRET_KEY=sundynix
|
||||
MINIO_BUCKET=sundynix-pet
|
||||
MINIO_USE_SSL=true
|
||||
|
||||
JWT_EXPIRE_HOURS=2
|
||||
JWT_REFRESH_HOURS=2
|
||||
JWT_SECRET=
|
||||
WECHAT_APP_ID=wx52dfc635739a9c19
|
||||
WECHAT_APP_SECRET=
|
||||
|
||||
@@ -20,6 +20,8 @@ services:
|
||||
PETS_MINIO_BUCKET: ${MINIO_BUCKET:-sundynix-pet}
|
||||
PETS_MINIO_USE_SSL: ${MINIO_USE_SSL:-true}
|
||||
PETS_JWT_SECRET: ${JWT_SECRET:?}
|
||||
PETS_JWT_EXPIRE_HOURS: ${JWT_EXPIRE_HOURS:-2}
|
||||
PETS_JWT_REFRESH_HOURS: ${JWT_REFRESH_HOURS:-2}
|
||||
PETS_WECHAT_APP_ID: ${WECHAT_APP_ID:?}
|
||||
PETS_WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?}
|
||||
PETS_AUTH_DEV_LOGIN: "false"
|
||||
|
||||
+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`。
|
||||
3. 若数据库还不存在,在 `pets-be` 目录执行一次 `go run ./cmd/dbinit`。
|
||||
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 控制台页面,不能作为图片直链。
|
||||
|
||||
## 管理端路由与犬种百科
|
||||
|
||||
菜单使用独立路由:`/admin/overview`、`/admin/users`、`/admin/pets`、`/admin/breeds`、`/admin/walks`、`/admin/meetups`、`/admin/tasks`、`/admin/files`;登录页 `/admin/login`。根路径自动进入工作台或登录页;Go 的 SPA 回退支持刷新和直达链接。业务接口仍在 `/api`,无需额外 API 前缀。
|
||||
|
||||
犬种目录自动导入 350 项(CKU 去重 347 项,另补充三种实用分类),按名称拼音 A–Z 排序。选品种使用 `breed_id`,手动填写保留 `breed` 名称并清空 `breed_id`。后台提供新增、编辑、启用、停用、搜索及每页十条分页。停用品种不影响已有宠物档案。
|
||||
|
||||
CKU 当前返回完整详情的 88 个犬种已整理基本资料、简短改写介绍和来源链接;另三项补充分类提供原创介绍。资料缺失时显示待补充,后台可以编辑百科字段。图片与长篇犬种标准未转载。
|
||||
|
||||
## 登录刷新
|
||||
|
||||
管理端和小程序的访问 token、refresh token 都为 **2 小时**。请求前发现不足一分钟即刷新;401 也会尝试刷新并仅重试一次。刷新凭证每次轮换,数据库只存 SHA-256 摘要;两个角色的凭证不能混用,停用账号、过期及退出会话都不能继续刷新。后台退出会撤销会话和其访问 token。
|
||||
|
||||
刷新成功后,两种凭证重新计时两小时;超过两小时未使用则重新登录。小程序自动重新微信登录,管理端回到登录页并保留原目标路由。并发接口共用刷新请求,支持 Web Locks 的浏览器还会跨标签页串行刷新;网络失败保留凭证以便重试。
|
||||
|
||||
接口:`POST /api/auth/refresh`、`POST /api/admin/refresh`,请求体为 `{ "refresh_token": "..." }`。登录与刷新响应包含 `token`、`refresh_token`、`expires_in: 7200`、`expires_at`、`refresh_expires_at`(Unix 秒)。旧七天 token 会要求重新登录。时长按业务约束固定两小时,旧环境变量不会把期限拉长。
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// seedbreeds imports the embedded CKU breed catalog without overwriting administrator edits.
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/sundynix/pets-be/internal/breeds"
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/database"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
db, err := database.Open(cfg.Postgres, true)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// Only the schema needed by these features, leaving unrelated application records intact.
|
||||
if err := db.AutoMigrate(&model.Breed{}, &model.BreedReference{}, &model.Pet{}, &model.RefreshSession{}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
added, err := breeds.Seed(db)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
var total int64
|
||||
if err := db.Model(&model.Breed{}).Count(&total).Error; err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
var sources int64
|
||||
db.Model(&model.BreedReference{}).Count(&sources)
|
||||
log.Printf("breed catalog imported: added=%d total=%d references=%d", added, total, sources)
|
||||
}
|
||||
@@ -19,7 +19,8 @@ minio:
|
||||
|
||||
jwt:
|
||||
secret: "" # 用 PETS_JWT_SECRET 注入
|
||||
expire_hours: 168
|
||||
expire_hours: 2
|
||||
refresh_hours: 2
|
||||
|
||||
wechat:
|
||||
app_id: ""
|
||||
|
||||
@@ -18,6 +18,8 @@ minio:
|
||||
use_ssl: true
|
||||
|
||||
jwt:
|
||||
expire_hours: 2
|
||||
refresh_hours: 2
|
||||
secret: kFdKDNCXpyJO2W+htdxobhHNzUIYuwcaZV94aJbIGHQ= # 生产用 PETS_JWT_SECRET 覆盖
|
||||
|
||||
wechat:
|
||||
|
||||
@@ -7,6 +7,7 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/minio/minio-go/v7 v7.2.1
|
||||
github.com/mozillazg/go-pinyin v0.21.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
|
||||
@@ -86,6 +86,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mozillazg/go-pinyin v0.21.0 h1:Wo8/NT45z7P3er/9YSLHA3/kjZzbLz5hR7i+jGeIGao=
|
||||
github.com/mozillazg/go-pinyin v0.21.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
|
||||
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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>肉垫计划 · 管理后台</title>
|
||||
<script type="module" crossorigin src="/assets/index-BOnBpy-T.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-7B3h3Yq6.css">
|
||||
<script type="module" crossorigin src="/assets/index-N2FSM0pD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CkSlqiFn.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -38,6 +38,9 @@ func TestAdminAtRootAndAPIRoutes(t *testing.T) {
|
||||
body string
|
||||
}{
|
||||
{path: "/", status: http.StatusOK, body: "<div id=\"root\"></div>"},
|
||||
{path: "/admin/breeds", status: http.StatusOK, body: "<div id=\"root\"></div>"},
|
||||
{path: "/admin/walks", status: http.StatusOK, body: "<div id=\"root\"></div>"},
|
||||
{path: "/admin/files", status: http.StatusOK, body: "<div id=\"root\"></div>"},
|
||||
{path: script, status: http.StatusOK},
|
||||
{path: "/brand-logo.png", status: http.StatusOK},
|
||||
{path: "/api/ping", status: http.StatusOK, body: "pong"},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -54,6 +54,7 @@ type MinIOConfig struct {
|
||||
type JWTConfig struct {
|
||||
Secret string `mapstructure:"secret"`
|
||||
ExpireHours int `mapstructure:"expire_hours"`
|
||||
RefreshHours int `mapstructure:"refresh_hours"`
|
||||
}
|
||||
|
||||
type WeChatConfig struct {
|
||||
@@ -87,7 +88,8 @@ func Load() (*Config, error) {
|
||||
v.SetDefault("minio.bucket", "sundynix-pet")
|
||||
v.SetDefault("minio.use_ssl", true)
|
||||
v.SetDefault("jwt.secret", "")
|
||||
v.SetDefault("jwt.expire_hours", 168)
|
||||
v.SetDefault("jwt.expire_hours", 2)
|
||||
v.SetDefault("jwt.refresh_hours", 2)
|
||||
v.SetDefault("wechat.app_id", "")
|
||||
v.SetDefault("wechat.app_secret", "")
|
||||
v.SetDefault("auth.dev_login", false)
|
||||
@@ -120,5 +122,8 @@ func Load() (*Config, error) {
|
||||
if cfg.Server.Mode == "release" && cfg.Auth.DevLogin {
|
||||
return nil, errors.New("dev login must be disabled in release mode")
|
||||
}
|
||||
// Access tokens have the same fixed two-hour lifetime for both clients.
|
||||
cfg.JWT.ExpireHours = 2
|
||||
cfg.JWT.RefreshHours = 2
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/breeds"
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
@@ -39,5 +40,8 @@ func Migrate(db *gorm.DB) error {
|
||||
if err := db.AutoMigrate(model.All()...); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := breeds.Seed(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_sundynix_walk_one_active_per_user ON sundynix_walk (user_id) WHERE status IN ('active', 'paused')").Error
|
||||
}
|
||||
|
||||
@@ -59,12 +59,13 @@ func (a *API) adminLogin(c *gin.Context) {
|
||||
fail(c, http.StatusUnauthorized, "用户名或密码错误")
|
||||
return
|
||||
}
|
||||
token, err := a.token(admin.ID, "admin")
|
||||
pair, err := a.newSession(admin.ID, "admin")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "签发令牌失败")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"token": token, "admin": gin.H{"id": admin.ID, "username": admin.Username}})
|
||||
pair["admin"] = gin.H{"id": admin.ID, "username": admin.Username}
|
||||
ok(c, pair)
|
||||
}
|
||||
|
||||
func (a *API) devLogin(c *gin.Context) {
|
||||
@@ -203,12 +204,13 @@ func (a *API) loginUser(c *gin.Context, user model.User) {
|
||||
fail(c, http.StatusForbidden, "账号已停用")
|
||||
return
|
||||
}
|
||||
token, err := a.token(user.ID, "user")
|
||||
pair, err := a.newSession(user.ID, "user")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "签发令牌失败")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"token": token, "user": a.userView(user)})
|
||||
pair["user"] = a.userView(user)
|
||||
ok(c, pair)
|
||||
}
|
||||
|
||||
func (a *API) me(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sundynix/pets-be/internal/breeds"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (a *API) breedQuery(c *gin.Context) *gorm.DB {
|
||||
return search(a.db.Model(&model.Breed{}), c, "name", "english_name", "aliases", "pinyin", "pinyin_initials")
|
||||
}
|
||||
func (a *API) listBreeds(c *gin.Context) {
|
||||
if len([]rune(c.Query("q"))) > 128 {
|
||||
fail(c, 400, "搜索内容过长")
|
||||
return
|
||||
}
|
||||
query := a.breedQuery(c).Where("enabled = true")
|
||||
var rows []model.Breed
|
||||
if err := query.Order("initial ASC, pinyin ASC, name ASC").Find(&rows).Error; err != nil {
|
||||
fail(c, 500, "读取品种失败")
|
||||
return
|
||||
}
|
||||
// Users only need the selection fields; no internal source metadata.
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, gin.H{"id": row.ID, "name": row.Name, "english_name": row.EnglishName, "aliases": row.Aliases, "pinyin": row.Pinyin, "pinyin_initials": row.PinyinInitials, "initial": row.Initial, "has_details": row.Summary != "" || row.Origin != "", "origin": row.Origin})
|
||||
}
|
||||
ok(c, items)
|
||||
}
|
||||
func (a *API) adminBreeds(c *gin.Context) {
|
||||
query := a.breedQuery(c).Preload("References")
|
||||
if status := c.Query("status"); status != "" {
|
||||
if status != "enabled" && status != "disabled" {
|
||||
fail(c, 400, "状态无效")
|
||||
return
|
||||
}
|
||||
query = query.Where("enabled = ?", status == "enabled")
|
||||
}
|
||||
if initial := c.Query("initial"); initial != "" {
|
||||
if len(initial) != 1 || (initial != "#" && (initial < "A" || initial > "Z")) {
|
||||
fail(c, 400, "字母无效")
|
||||
return
|
||||
}
|
||||
query = query.Where("initial = ?", initial)
|
||||
}
|
||||
rows, page, valid := adminRows[model.Breed](c, query, "initial ASC, pinyin ASC, name ASC")
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
page.Items = rows
|
||||
ok(c, page)
|
||||
}
|
||||
|
||||
type breedInput struct {
|
||||
LifeSpan string `json:"life_span"`
|
||||
Exercise string `json:"exercise"`
|
||||
Grooming string `json:"grooming"`
|
||||
Traits map[string]int `json:"traits"`
|
||||
FCINumber string `json:"fci_number"`
|
||||
Origin string `json:"origin"`
|
||||
BreedGroup string `json:"breed_group"`
|
||||
Height string `json:"height"`
|
||||
Weight string `json:"weight"`
|
||||
Summary string `json:"summary"`
|
||||
Temperament string `json:"temperament"`
|
||||
CareNotes string `json:"care_notes"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
EnglishName string `json:"english_name"`
|
||||
Aliases string `json:"aliases"`
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
|
||||
func bindBreed(c *gin.Context) (model.Breed, bool) {
|
||||
var in breedInput
|
||||
if c.ShouldBindJSON(&in) != nil {
|
||||
fail(c, 400, "请填写品种名称和状态")
|
||||
return model.Breed{}, false
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
in.EnglishName = strings.TrimSpace(in.EnglishName)
|
||||
in.Aliases = strings.TrimSpace(in.Aliases)
|
||||
if in.Name == "" || len([]rune(in.Name)) > 128 || len([]rune(in.EnglishName)) > 128 || len([]rune(in.Aliases)) > 512 {
|
||||
fail(c, 400, "品种资料长度无效")
|
||||
return model.Breed{}, false
|
||||
}
|
||||
if len([]rune(in.Origin)) > 128 || len([]rune(in.BreedGroup)) > 255 || len([]rune(in.Height)) > 128 || len([]rune(in.Weight)) > 128 || len([]rune(in.Summary)) > 2000 || len([]rune(in.Temperament)) > 255 || len([]rune(in.CareNotes)) > 2000 {
|
||||
fail(c, 400, "百科资料长度无效")
|
||||
return model.Breed{}, false
|
||||
}
|
||||
if len([]rune(in.LifeSpan)) > 128 || len([]rune(in.Exercise)) > 255 || len([]rune(in.Grooming)) > 255 || len(in.FCINumber) > 16 {
|
||||
fail(c, 400, "照顾资料长度无效")
|
||||
return model.Breed{}, false
|
||||
}
|
||||
validTraits := map[string]bool{"运动需求": true, "运动活力": true, "训练接受度": true, "掉毛程度": true, "吠叫倾向": true, "梳毛需求": true, "亲近家人": true}
|
||||
for key, value := range in.Traits {
|
||||
if !validTraits[key] || value < 1 || value > 5 {
|
||||
fail(c, 400, "指数需要为 1–5")
|
||||
return model.Breed{}, false
|
||||
}
|
||||
}
|
||||
scores, _ := json.Marshal(in.Traits)
|
||||
full, short, initial := breeds.SortFields(in.Name)
|
||||
return model.Breed{LifeSpan: strings.TrimSpace(in.LifeSpan), Exercise: strings.TrimSpace(in.Exercise), Grooming: strings.TrimSpace(in.Grooming), FCINumber: strings.TrimSpace(in.FCINumber), Traits: string(scores), Origin: strings.TrimSpace(in.Origin), BreedGroup: strings.TrimSpace(in.BreedGroup), Height: strings.TrimSpace(in.Height), Weight: strings.TrimSpace(in.Weight), Summary: strings.TrimSpace(in.Summary), Temperament: strings.TrimSpace(in.Temperament), CareNotes: strings.TrimSpace(in.CareNotes), Name: in.Name, EnglishName: in.EnglishName, Aliases: in.Aliases, Pinyin: full, PinyinInitials: short, Initial: initial, Enabled: *in.Enabled}, true
|
||||
}
|
||||
func breedWriteError(c *gin.Context, err error) {
|
||||
// pgx PostgreSQL unique_violation, without disclosing DB details.
|
||||
type sqlState interface{ SQLState() string }
|
||||
var state sqlState
|
||||
if errors.As(err, &state) && state.SQLState() == "23505" {
|
||||
fail(c, http.StatusConflict, "该品种名称已存在")
|
||||
return
|
||||
}
|
||||
fail(c, 500, "保存品种失败")
|
||||
}
|
||||
func (a *API) createBreed(c *gin.Context) {
|
||||
row, valid := bindBreed(c)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
row.Source = "管理端录入"
|
||||
row.EditorialLocked = true
|
||||
row.EncyclopediaUpdatedAt = time.Now().Format("2006-01-02")
|
||||
// GORM defaults true for zero values on Create; explicitly select false after insertion.
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
enabled := row.Enabled
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled {
|
||||
row.Enabled = false
|
||||
return tx.Model(&row).Update("enabled", false).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
breedWriteError(c, err)
|
||||
return
|
||||
}
|
||||
created(c, row)
|
||||
}
|
||||
func (a *API) updateBreed(c *gin.Context) {
|
||||
if !validID(c.Param("id")) {
|
||||
fail(c, 404, "品种不存在")
|
||||
return
|
||||
}
|
||||
update, valid := bindBreed(c)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
var row model.Breed
|
||||
if a.db.First(&row, "id = ?", c.Param("id")).Error != nil {
|
||||
fail(c, 404, "品种不存在")
|
||||
return
|
||||
}
|
||||
if row.FieldSources == nil {
|
||||
row.FieldSources = map[string]string{}
|
||||
}
|
||||
before := map[string]string{"name": row.Name, "english_name": row.EnglishName, "aliases": row.Aliases, "origin": row.Origin, "breed_group": row.BreedGroup, "height": row.Height, "weight": row.Weight, "summary": row.Summary, "temperament": row.Temperament, "care_notes": row.CareNotes, "life_span": row.LifeSpan, "exercise": row.Exercise, "grooming": row.Grooming, "fci_number": row.FCINumber, "traits": row.Traits}
|
||||
after := map[string]string{"name": update.Name, "english_name": update.EnglishName, "aliases": update.Aliases, "origin": update.Origin, "breed_group": update.BreedGroup, "height": update.Height, "weight": update.Weight, "summary": update.Summary, "temperament": update.Temperament, "care_notes": update.CareNotes, "life_span": update.LifeSpan, "exercise": update.Exercise, "grooming": update.Grooming, "fci_number": update.FCINumber, "traits": update.Traits}
|
||||
var previousTraits map[string]int
|
||||
if json.Unmarshal([]byte(row.Traits), &previousTraits) == nil {
|
||||
normalized, _ := json.Marshal(previousTraits)
|
||||
before["traits"] = string(normalized)
|
||||
}
|
||||
for key, value := range after {
|
||||
if value != before[key] && !(key == "traits" && value == "null") {
|
||||
row.FieldSources[key] = "editorial"
|
||||
}
|
||||
}
|
||||
row.Origin, row.BreedGroup, row.Height, row.Weight, row.Summary, row.Temperament, row.CareNotes = update.Origin, update.BreedGroup, update.Height, update.Weight, update.Summary, update.Temperament, update.CareNotes
|
||||
row.LifeSpan, row.Exercise, row.Grooming, row.FCINumber = update.LifeSpan, update.Exercise, update.Grooming, update.FCINumber
|
||||
if update.Traits != "null" {
|
||||
row.Traits = update.Traits
|
||||
}
|
||||
row.EditorialLocked = true
|
||||
row.EncyclopediaUpdatedAt = time.Now().Format("2006-01-02")
|
||||
row.Name, row.EnglishName, row.Aliases = update.Name, update.EnglishName, update.Aliases
|
||||
row.Pinyin, row.PinyinInitials, row.Initial, row.Enabled = update.Pinyin, update.PinyinInitials, update.Initial, update.Enabled
|
||||
if err := a.db.Save(&row).Error; err != nil {
|
||||
breedWriteError(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, row)
|
||||
}
|
||||
func (a *API) setBreedEnabled(c *gin.Context) {
|
||||
var in struct {
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil {
|
||||
fail(c, 400, "状态参数无效")
|
||||
return
|
||||
}
|
||||
result := a.db.Model(&model.Breed{}).Where("id = ?", c.Param("id")).Update("enabled", *in.Enabled)
|
||||
if result.Error != nil {
|
||||
fail(c, 500, "更新状态失败")
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
fail(c, 404, "品种不存在")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"id": c.Param("id"), "enabled": *in.Enabled})
|
||||
}
|
||||
|
||||
func (a *API) breedDetail(c *gin.Context) {
|
||||
var row model.Breed
|
||||
if !validID(c.Param("id")) || a.db.Preload("References").Where("id = ? AND enabled = true", c.Param("id")).First(&row).Error != nil {
|
||||
fail(c, 404, "品种不存在或已停用")
|
||||
return
|
||||
}
|
||||
ok(c, row)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sundynix/pets-be/internal/breeds"
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/database"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"gorm.io/gorm"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func featureDB(t *testing.T) (*gorm.DB, *config.Config) {
|
||||
t.Helper()
|
||||
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
|
||||
t.Skip("PostgreSQL integration disabled")
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := database.Open(cfg.Postgres, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
t.Cleanup(func() { sqlDB.Close() })
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatal(tx.Error)
|
||||
}
|
||||
t.Cleanup(func() { tx.Rollback() })
|
||||
return tx, cfg
|
||||
}
|
||||
func featureCall(handler gin.HandlerFunc, method, path string, body any, actorID, id string) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(body)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(method, path, bytes.NewReader(raw))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set("actor_id", actorID)
|
||||
c.Params = gin.Params{{Key: "id", Value: id}}
|
||||
handler(c)
|
||||
return rec
|
||||
}
|
||||
func pairFrom(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
if rec.Code != 200 && rec.Code != 201 {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var result struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result.Data
|
||||
}
|
||||
func TestAccessTokenTwoHours(t *testing.T) {
|
||||
api := New(nil, nil, &config.Config{JWT: config.JWTConfig{Secret: "test", ExpireHours: 168}})
|
||||
raw, err := api.token(uuid.NewString(), "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &claims{}
|
||||
_, err = jwt.ParseWithClaims(raw, c, func(*jwt.Token) (any, error) { return []byte("test"), nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.ExpiresAt.Sub(c.IssuedAt.Time) != 2*time.Hour {
|
||||
t.Fatal("access token lifetime is not two hours")
|
||||
}
|
||||
}
|
||||
func TestRefreshRotationPostgres(t *testing.T) {
|
||||
tx, cfg := featureDB(t)
|
||||
a := New(tx, nil, cfg)
|
||||
user := model.User{OpenID: "refresh-test:" + uuid.NewString(), Nickname: "测试家长"}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pair, err := a.newSession(user.ID, "user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := pair["refresh_token"].(string)
|
||||
var session model.RefreshSession
|
||||
if err := tx.Where("token_hash = ?", refreshHash(old)).First(&session).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.TokenHash == old {
|
||||
t.Fatal("raw refresh stored")
|
||||
}
|
||||
call := func(raw, role string) *httptest.ResponseRecorder {
|
||||
return featureCall(a.refreshToken(role), "POST", "/refresh", gin.H{"refresh_token": raw}, "", "")
|
||||
}
|
||||
if rec := call(old, "admin"); rec.Code != 401 {
|
||||
t.Fatal("user credential accepted for admin")
|
||||
}
|
||||
next := pairFrom(t, call(old, "user"))
|
||||
fresh := next["refresh_token"].(string)
|
||||
if fresh == old || next["refresh_expires_at"].(float64) < float64(time.Now().Add(2*time.Hour-time.Second).Unix()) {
|
||||
t.Fatal("rotation or two-hour expiry wrong")
|
||||
}
|
||||
if rec := call(old, "user"); rec.Code != 401 {
|
||||
t.Fatal("old credential reusable")
|
||||
}
|
||||
revoked := featureCall(a.revokeRefresh("user"), "POST", "/logout", gin.H{"refresh_token": fresh}, "", "")
|
||||
if revoked.Code != 200 {
|
||||
t.Fatal(revoked.Body.String())
|
||||
}
|
||||
if rec := call(fresh, "user"); rec.Code != 401 {
|
||||
t.Fatal("logout failed to revoke")
|
||||
}
|
||||
// Logout invalidates the access token tied to the same session too.
|
||||
r := gin.New()
|
||||
r.GET("/protected", a.requireRole("user"), func(c *gin.Context) { ok(c, true) })
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+next["token"].(string))
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != 401 {
|
||||
t.Fatal("revoked session access accepted")
|
||||
}
|
||||
pair, err = a.newSession(user.ID, "user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh = pair["refresh_token"].(string)
|
||||
if err := tx.Model(&user).Update("disabled", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := call(fresh, "user"); rec.Code != 401 {
|
||||
t.Fatal("disabled user refreshed")
|
||||
}
|
||||
if err := tx.Model(&user).Update("disabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx.Model(&model.RefreshSession{}).Where("token_hash = ?", refreshHash(fresh)).Update("expires_at", time.Now().Add(-time.Minute))
|
||||
if rec := call(fresh, "user"); rec.Code != 401 {
|
||||
t.Fatal("expired credential refreshed")
|
||||
}
|
||||
admin := model.Admin{Username: "refresh-test:" + uuid.NewString(), PasswordHash: "fixture"}
|
||||
if err := tx.Create(&admin).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pair, err = a.newSession(admin.ID, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pairFrom(t, call(pair["refresh_token"].(string), "admin"))
|
||||
}
|
||||
func TestBreedCatalogPostgres(t *testing.T) {
|
||||
tx, cfg := featureDB(t)
|
||||
a := New(tx, nil, cfg)
|
||||
if added, err := breeds.Seed(tx); err != nil || added != 0 {
|
||||
t.Fatalf("idempotent seed: %d %v", added, err)
|
||||
}
|
||||
var source model.Breed
|
||||
if err := tx.Where("name = ?", "彭布罗克威尔士柯基").First(&source).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx.Model(&source).Updates(map[string]any{"enabled": false, "name": "改名柯基测试"})
|
||||
if added, err := breeds.Seed(tx); err != nil || added != 0 {
|
||||
t.Fatalf("seed restored renamed record: %d %v", added, err)
|
||||
}
|
||||
tx.First(&source, "id = ?", source.ID)
|
||||
if source.Enabled || source.Name != "改名柯基测试" {
|
||||
t.Fatal("seed overwrote edits")
|
||||
}
|
||||
rec := featureCall(a.listBreeds, "GET", "/breeds?q=bianmu", nil, "", "")
|
||||
if rec.Code != 200 {
|
||||
t.Fatal(rec.Body.String())
|
||||
}
|
||||
// Short initials and Chinese aliases must both work.
|
||||
for _, q := range []string{"边牧", "bjm", "bianjing"} {
|
||||
rec = featureCall(a.listBreeds, "GET", "/breeds?q="+q, nil, "", "")
|
||||
if rec.Code != 200 || !bytes.Contains(rec.Body.Bytes(), []byte("边境牧羊犬")) {
|
||||
t.Fatalf("search %s: %s", q, rec.Body)
|
||||
}
|
||||
}
|
||||
name := "测试品种" + uuid.NewString()
|
||||
body := gin.H{"name": name, "aliases": "testbreed", "enabled": false}
|
||||
created := pairFrom(t, featureCall(a.createBreed, "POST", "/breeds", body, "", ""))
|
||||
id := created["id"].(string)
|
||||
if created["enabled"].(bool) {
|
||||
t.Fatal("disabled creation ignored")
|
||||
}
|
||||
pairFrom(t, featureCall(a.setBreedEnabled, "PUT", "/breeds", gin.H{"enabled": true}, "", id))
|
||||
body["enabled"] = true
|
||||
body["name"] = name + "更新"
|
||||
pairFrom(t, featureCall(a.updateBreed, "PUT", "/breeds", body, "", id))
|
||||
// Duplicate request runs in a savepoint because PostgreSQL aborts a transaction on unique violations.
|
||||
tx.SavePoint("duplicate_breed")
|
||||
rec = featureCall(a.createBreed, "POST", "/breeds", body, "", "")
|
||||
if rec.Code != 409 {
|
||||
t.Fatalf("duplicate status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
tx.RollbackTo("duplicate_breed")
|
||||
user := model.User{OpenID: "breed-test:" + uuid.NewString(), Nickname: "测试家长"}
|
||||
tx.Create(&user)
|
||||
selected, valid := a.buildPet(testPetContext(user.ID, ""), petInput{Name: "测试毛孩", Breed: "forged", BreedID: &id})
|
||||
if !valid || selected.Breed != body["name"] {
|
||||
t.Fatal("selected name not resolved by ID")
|
||||
}
|
||||
custom, valid := a.buildPet(testPetContext(user.ID, ""), petInput{Name: "测试毛孩", Breed: "未收录地方犬"})
|
||||
if !valid || custom.BreedID != nil || custom.Breed != "未收录地方犬" {
|
||||
t.Fatal("manual breed unavailable")
|
||||
}
|
||||
selected.UserID = user.ID
|
||||
tx.Create(&selected)
|
||||
tx.Model(&model.Breed{}).Where("id = ?", id).Update("enabled", false)
|
||||
_, valid = a.buildPet(testPetContext(user.ID, ""), petInput{Name: "新宠物", BreedID: &id})
|
||||
if valid {
|
||||
t.Fatal("disabled breed newly selected")
|
||||
}
|
||||
_, valid = a.buildPet(testPetContext(user.ID, selected.ID), petInput{Name: "旧宠物", BreedID: &id})
|
||||
if !valid {
|
||||
t.Fatal("existing disabled breed cannot be retained")
|
||||
}
|
||||
}
|
||||
func testPetContext(actorID, id string) *gin.Context {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Set("actor_id", actorID)
|
||||
c.Params = gin.Params{{Key: "id", Value: id}}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestBreedSourcesAndEditorialProtection(t *testing.T) {
|
||||
tx, cfg := featureDB(t)
|
||||
a := New(tx, nil, cfg)
|
||||
var breed model.Breed
|
||||
if err := tx.Where("name = ?", "彭布罗克威尔士柯基").First(&breed).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail := pairFrom(t, featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID))
|
||||
if len(detail["references"].([]any)) != 4 {
|
||||
t.Fatal("Corgi references missing")
|
||||
}
|
||||
if detail["field_sources"].(map[string]any)["exercise"] != "rkc" {
|
||||
t.Fatal("exercise attribution wrong")
|
||||
}
|
||||
body := gin.H{"name": breed.Name, "english_name": breed.EnglishName, "enabled": true, "exercise": "编辑安排的散步活动", "summary": "新的中文介绍", "traits": gin.H{"运动活力": 2}}
|
||||
pairFrom(t, featureCall(a.updateBreed, "PUT", "/breeds/"+breed.ID, body, "", breed.ID))
|
||||
if _, err := breeds.Seed(tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail = pairFrom(t, featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID))
|
||||
if detail["exercise"] != "编辑安排的散步活动" || detail["summary"] != "新的中文介绍" || detail["height"] != "" {
|
||||
t.Fatal("import overwrote manual edits or cleared fields")
|
||||
}
|
||||
if detail["field_sources"].(map[string]any)["exercise"] != "editorial" || len(detail["references"].([]any)) != 4 {
|
||||
t.Fatal("edited provenance or reference uniqueness lost")
|
||||
}
|
||||
body["traits"] = gin.H{"运动活力": 6}
|
||||
if rec := featureCall(a.updateBreed, "PUT", "/breeds/"+breed.ID, body, "", breed.ID); rec.Code != 400 {
|
||||
t.Fatalf("invalid rating accepted: %d", rec.Code)
|
||||
}
|
||||
tx.Model(&model.Breed{}).Where("id = ?", breed.ID).Update("enabled", false)
|
||||
if rec := featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID); rec.Code != 404 {
|
||||
t.Fatal("disabled encyclopedia visible")
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (a *API) petView(pet model.Pet) gin.H {
|
||||
if pet.BirthDate != nil {
|
||||
birthDate = pet.BirthDate.Format("2006-01-02")
|
||||
}
|
||||
return gin.H{"id": pet.ID, "name": pet.Name, "species": pet.Species, "breed": pet.Breed,
|
||||
return gin.H{"id": pet.ID, "name": pet.Name, "species": pet.Species, "breed": pet.Breed, "breed_id": pet.BreedID,
|
||||
"gender": pet.Gender, "birth_date": birthDate, "weight_kg": pet.WeightKg,
|
||||
"avatar_file_id": pet.AvatarFileID, "avatar_url": a.fileURL(pet.AvatarFileID),
|
||||
"coat": pet.Coat, "allergies": pet.Allergies, "care_notes": pet.CareNotes,
|
||||
|
||||
@@ -15,6 +15,7 @@ type petInput struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Species string `json:"species"`
|
||||
Breed string `json:"breed"`
|
||||
BreedID *string `json:"breed_id"`
|
||||
Gender string `json:"gender"`
|
||||
BirthDate string `json:"birth_date"`
|
||||
WeightKg float64 `json:"weight_kg"`
|
||||
@@ -81,6 +82,7 @@ func (a *API) updatePet(c *gin.Context) {
|
||||
}
|
||||
pet.Name, pet.Species, pet.Breed, pet.Gender = updated.Name, updated.Species, updated.Breed, updated.Gender
|
||||
pet.BirthDate, pet.WeightKg, pet.AvatarFileID = updated.BirthDate, updated.WeightKg, updated.AvatarFileID
|
||||
pet.BreedID = updated.BreedID
|
||||
pet.Coat, pet.Allergies, pet.CareNotes = updated.Coat, updated.Allergies, updated.CareNotes
|
||||
if err := a.db.Save(&pet).Error; err != nil {
|
||||
fail(c, http.StatusInternalServerError, "更新宠物失败")
|
||||
@@ -110,6 +112,27 @@ func (a *API) buildPet(c *gin.Context, in petInput) (model.Pet, bool) {
|
||||
return pet, false
|
||||
}
|
||||
}
|
||||
in.Breed = strings.TrimSpace(in.Breed)
|
||||
if len([]rune(in.Breed)) > 128 {
|
||||
fail(c, http.StatusBadRequest, "品种名称过长")
|
||||
return pet, false
|
||||
}
|
||||
if in.BreedID != nil {
|
||||
var breed model.Breed
|
||||
if !validID(*in.BreedID) || a.db.First(&breed, "id = ?", *in.BreedID).Error != nil {
|
||||
fail(c, http.StatusBadRequest, "品种不存在,请重新选择或手动填写")
|
||||
return pet, false
|
||||
}
|
||||
// A disabled breed can be retained on an existing pet, but not newly selected.
|
||||
if !breed.Enabled {
|
||||
var current model.Pet
|
||||
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ? AND breed_id = ?", c.Param("id"), actor(c), *in.BreedID).First(¤t).Error != nil {
|
||||
fail(c, http.StatusBadRequest, "该品种已停用,请手动填写或重新选择")
|
||||
return pet, false
|
||||
}
|
||||
}
|
||||
in.Breed = breed.Name
|
||||
}
|
||||
var birthday *time.Time
|
||||
if in.BirthDate != "" {
|
||||
d, err := parseDay(in.BirthDate)
|
||||
@@ -120,7 +143,7 @@ func (a *API) buildPet(c *gin.Context, in petInput) (model.Pet, bool) {
|
||||
birthday = &d
|
||||
}
|
||||
pet = model.Pet{Name: in.Name, Species: in.Species, Breed: strings.TrimSpace(in.Breed),
|
||||
Gender: in.Gender, BirthDate: birthday, WeightKg: in.WeightKg,
|
||||
BreedID: in.BreedID, Gender: in.Gender, BirthDate: birthday, WeightKg: in.WeightKg,
|
||||
AvatarFileID: in.AvatarFileID, Coat: in.Coat, Allergies: in.Allergies, CareNotes: in.CareNotes}
|
||||
return pet, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var errRefreshInvalid = errors.New("refresh credential invalid")
|
||||
|
||||
func refreshCredential() (string, error) {
|
||||
data := make([]byte, 32)
|
||||
if _, err := rand.Read(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(data), nil
|
||||
}
|
||||
func refreshHash(value string) string {
|
||||
hash := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
func (a *API) sessionPair(id, role, sessionID, refresh string, expires time.Time) (gin.H, error) {
|
||||
token, err := a.sessionToken(id, role, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"token": token, "refresh_token": refresh, "expires_in": 7200, "expires_at": time.Now().Add(2 * time.Hour).Unix(), "refresh_expires_at": expires.Unix()}, nil
|
||||
}
|
||||
func (a *API) newSession(id, role string) (gin.H, error) {
|
||||
raw, err := refreshCredential()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expires := time.Now().Add(2 * time.Hour)
|
||||
sessionID := uuid.NewString()
|
||||
pair, err := a.sessionPair(id, role, sessionID, raw, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Opportunistic cleanup bounded by indexed expiry, without a background scheduler.
|
||||
if err := a.db.Where("expires_at <= ?", time.Now()).Delete(&model.RefreshSession{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = a.db.Create(&model.RefreshSession{Base: model.Base{ID: sessionID}, ActorID: id, Role: role, TokenHash: refreshHash(raw), ExpiresAt: expires}).Error
|
||||
return pair, err
|
||||
}
|
||||
func readRefresh(c *gin.Context) (string, bool) {
|
||||
var in struct {
|
||||
Token string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
if c.ShouldBindJSON(&in) != nil || len(in.Token) != 43 {
|
||||
fail(c, http.StatusUnauthorized, "登录已过期,请重新登录")
|
||||
return "", false
|
||||
}
|
||||
return in.Token, true
|
||||
}
|
||||
func (a *API) refreshToken(role string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw, valid := readRefresh(c)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
var pair gin.H
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
var session model.RefreshSession
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("token_hash = ? AND role = ?", refreshHash(raw), role).First(&session).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errRefreshInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if session.Revoked || !session.ExpiresAt.After(time.Now()) {
|
||||
return errRefreshInvalid
|
||||
}
|
||||
if role == "user" {
|
||||
var user model.User
|
||||
err = tx.Select("id", "disabled").First(&user, "id = ?", session.ActorID).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || (err == nil && user.Disabled) {
|
||||
return errRefreshInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
var count int64
|
||||
if err := tx.Model(&model.Admin{}).Where("id = ?", session.ActorID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return errRefreshInvalid
|
||||
}
|
||||
}
|
||||
next, err := refreshCredential()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nextExpiry := time.Now().Add(2 * time.Hour)
|
||||
pair, err = a.sessionPair(session.ActorID, role, session.ID, next, nextExpiry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Rotate inside the lock. The old credential can only succeed once.
|
||||
return tx.Model(&session).Updates(map[string]any{"token_hash": refreshHash(next), "expires_at": nextExpiry}).Error
|
||||
})
|
||||
if errors.Is(err, errRefreshInvalid) {
|
||||
fail(c, 401, "登录已过期,请重新登录")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
fail(c, 500, "暂时无法刷新登录,请重试")
|
||||
return
|
||||
}
|
||||
ok(c, pair)
|
||||
}
|
||||
}
|
||||
func (a *API) revokeRefresh(role string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw, valid := readRefresh(c)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
if err := a.db.Model(&model.RefreshSession{}).Where("token_hash = ? AND role = ?", refreshHash(raw), role).Update("revoked", true).Error; err != nil {
|
||||
fail(c, 500, "退出登录失败")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"logged_out": true})
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,17 @@ func (a *API) Register(r *gin.Engine) {
|
||||
api.POST("/auth/login", a.login)
|
||||
api.POST("/auth/dev", a.devLogin)
|
||||
api.POST("/admin/login", a.adminLogin)
|
||||
api.POST("/auth/refresh", a.refreshToken("user"))
|
||||
api.POST("/admin/refresh", a.refreshToken("admin"))
|
||||
api.POST("/auth/logout", a.revokeRefresh("user"))
|
||||
api.POST("/admin/logout", a.revokeRefresh("admin"))
|
||||
|
||||
user := api.Group("")
|
||||
user.Use(a.requireRole("user"))
|
||||
user.GET("/environment", a.environment)
|
||||
user.GET("/me", a.me)
|
||||
user.GET("/breeds", a.listBreeds)
|
||||
user.GET("/breeds/:id", a.breedDetail)
|
||||
user.GET("/pets", a.listPets)
|
||||
user.POST("/pets", a.createPet)
|
||||
user.GET("/pets/:id", a.getPet)
|
||||
@@ -72,6 +78,10 @@ func (a *API) Register(r *gin.Engine) {
|
||||
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(a.requireRole("admin"))
|
||||
admin.GET("/breeds", a.adminBreeds)
|
||||
admin.POST("/breeds", a.createBreed)
|
||||
admin.PUT("/breeds/:id", a.updateBreed)
|
||||
admin.PUT("/breeds/:id/enabled", a.setBreedEnabled)
|
||||
admin.GET("/stats", a.adminStats)
|
||||
admin.GET("/users", a.adminUsers)
|
||||
admin.GET("/users/:id/pets", a.adminUserPets)
|
||||
@@ -87,17 +97,15 @@ func (a *API) Register(r *gin.Engine) {
|
||||
|
||||
type claims struct {
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"sid,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func (a *API) token(id, role string) (string, error) {
|
||||
expire := a.cfg.JWT.ExpireHours
|
||||
if expire <= 0 {
|
||||
expire = 168
|
||||
}
|
||||
func (a *API) token(id, role string) (string, error) { return a.sessionToken(id, role, "") }
|
||||
func (a *API) sessionToken(id, role, sessionID string) (string, error) {
|
||||
now := time.Now()
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{Role: role, RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: id, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(expire) * time.Hour)),
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{Role: role, SessionID: sessionID, RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: uuid.NewString(), Subject: id, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(2 * time.Hour)),
|
||||
}})
|
||||
return t.SignedString([]byte(a.cfg.JWT.Secret))
|
||||
}
|
||||
@@ -127,6 +135,20 @@ func (a *API) requireRole(role string) gin.HandlerFunc {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// Reject credentials issued with the old seven-day lifetime.
|
||||
if cl.IssuedAt == nil || cl.ExpiresAt == nil || cl.ExpiresAt.Sub(cl.IssuedAt.Time) > 2*time.Hour {
|
||||
fail(c, http.StatusUnauthorized, "登录已过期")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if cl.SessionID != "" {
|
||||
var count int64
|
||||
if !validID(cl.SessionID) || a.db.Model(&model.RefreshSession{}).Where("id = ? AND actor_id = ? AND role = ? AND revoked = false AND expires_at > ?", cl.SessionID, cl.Subject, role, time.Now()).Count(&count).Error != nil || count != 1 {
|
||||
fail(c, http.StatusUnauthorized, "登录已过期")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
if role == "user" {
|
||||
var user model.User
|
||||
if err := a.db.Select("id", "disabled").First(&user, "id = ?", cl.Subject).Error; err != nil || user.Disabled {
|
||||
|
||||
@@ -47,12 +47,64 @@ type Admin struct {
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
}
|
||||
|
||||
// RefreshSession stores only a hash of the rotating refresh credential.
|
||||
type RefreshSession struct {
|
||||
Base
|
||||
ActorID string `gorm:"type:uuid;index;not null" json:"-"`
|
||||
Role string `gorm:"size:16;index;not null" json:"-"`
|
||||
TokenHash string `gorm:"size:64;uniqueIndex;not null" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"index;not null" json:"-"`
|
||||
Revoked bool `gorm:"not null;default:false" json:"-"`
|
||||
}
|
||||
|
||||
type Breed struct {
|
||||
Base
|
||||
LifeSpan string `gorm:"size:128" json:"life_span"`
|
||||
Exercise string `gorm:"size:255" json:"exercise"`
|
||||
Grooming string `gorm:"size:255" json:"grooming"`
|
||||
EditorialLocked bool `gorm:"not null;default:false" json:"editorial_locked"`
|
||||
FieldSources map[string]string `gorm:"serializer:json;type:jsonb" json:"field_sources"`
|
||||
References []BreedReference `gorm:"foreignKey:BreedID" json:"references,omitempty"`
|
||||
Origin string `gorm:"size:128" json:"origin"`
|
||||
BreedGroup string `gorm:"size:255" json:"breed_group"`
|
||||
FCINumber string `gorm:"size:16" json:"fci_number"`
|
||||
Height string `gorm:"size:128" json:"height"`
|
||||
Weight string `gorm:"size:128" json:"weight"`
|
||||
Summary string `gorm:"type:text" json:"summary"`
|
||||
Temperament string `gorm:"size:255" json:"temperament"`
|
||||
Traits string `gorm:"type:text" json:"traits"`
|
||||
CareNotes string `gorm:"type:text" json:"care_notes"`
|
||||
EncyclopediaUpdatedAt string `gorm:"size:10" json:"encyclopedia_updated_at"`
|
||||
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
|
||||
EnglishName string `gorm:"size:128" json:"english_name"`
|
||||
Aliases string `gorm:"size:512" json:"aliases"`
|
||||
Pinyin string `gorm:"size:1024;index;not null" json:"pinyin"`
|
||||
PinyinInitials string `gorm:"size:128" json:"pinyin_initials"`
|
||||
Initial string `gorm:"size:1;index;not null" json:"initial"`
|
||||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||
Source string `gorm:"size:128" json:"source"`
|
||||
SourceKey *string `gorm:"size:128;uniqueIndex" json:"source_key"`
|
||||
SourceURL string `gorm:"size:512" json:"source_url"`
|
||||
}
|
||||
|
||||
// Facts contain short structured facts, never copied articles or remote images.
|
||||
type BreedReference struct {
|
||||
Base
|
||||
BreedID string `gorm:"type:uuid;uniqueIndex:idx_breed_provider;not null" json:"breed_id"`
|
||||
Provider string `gorm:"size:32;uniqueIndex:idx_breed_provider;not null" json:"provider"`
|
||||
ExternalID string `gorm:"size:128;not null" json:"external_id"`
|
||||
URL string `gorm:"size:512;not null" json:"url"`
|
||||
RetrievedAt string `gorm:"size:10;not null" json:"retrieved_at"`
|
||||
Facts map[string]string `gorm:"serializer:json;type:jsonb;not null" json:"facts"`
|
||||
}
|
||||
|
||||
type Pet struct {
|
||||
Base
|
||||
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
Species string `gorm:"size:16;not null;default:dog" json:"species"`
|
||||
Breed string `gorm:"size:64" json:"breed"`
|
||||
Breed string `gorm:"size:128" json:"breed"`
|
||||
BreedID *string `gorm:"type:uuid;index" json:"breed_id"`
|
||||
Gender string `gorm:"size:16" json:"gender"`
|
||||
BirthDate *time.Time `gorm:"type:date" json:"birth_date"`
|
||||
WeightKg float64 `gorm:"type:numeric(6,2)" json:"weight_kg"`
|
||||
@@ -140,7 +192,7 @@ type MeetupPet struct {
|
||||
|
||||
func All() []any {
|
||||
return []any{
|
||||
&File{}, &User{}, &Admin{}, &Pet{}, &CareTask{}, &TaskCompletion{},
|
||||
&File{}, &User{}, &Admin{}, &RefreshSession{}, &Breed{}, &BreedReference{}, &Pet{}, &CareTask{}, &TaskCompletion{},
|
||||
&Walk{}, &WalkPet{}, &WalkPoint{}, &Meetup{}, &MeetupParticipant{}, &MeetupPet{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Collect official factual metadata. Run from repo root; no third-party packages.
|
||||
HTML is cached outside the repository. No source prose or photos are persisted.
|
||||
Mappings use FCI numbers and exact English names, never fuzzy breed matching.
|
||||
"""
|
||||
import argparse
|
||||
from datetime import date
|
||||
import concurrent.futures
|
||||
import hashlib
|
||||
import html
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unicodedata
|
||||
import urllib.request
|
||||
from urllib.parse import urljoin
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1] / 'internal/breeds'
|
||||
CACHE = Path('/private/tmp/pawprint-breed-sources')
|
||||
CACHE.mkdir(exist_ok=True)
|
||||
DATE = date.today().isoformat()
|
||||
|
||||
class Node:
|
||||
def __init__(self, tag='', attrs=None):
|
||||
self.tag, self.attrs, self.children = tag, dict(attrs or []), []
|
||||
def text(self):
|
||||
return ''.join(c if isinstance(c, str) else c.text() for c in self.children)
|
||||
def all(self):
|
||||
yield self
|
||||
for c in self.children:
|
||||
if isinstance(c, Node):
|
||||
yield from c.all()
|
||||
|
||||
class DOM(HTMLParser):
|
||||
def __init__(self, text):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.root = Node(); self.stack = [self.root]; self.feed(text)
|
||||
def handle_starttag(self, tag, attrs):
|
||||
n = Node(tag, attrs); self.stack[-1].children.append(n)
|
||||
if tag not in ('br','img','hr','meta','link','input','source','wbr','area','embed'):
|
||||
self.stack.append(n)
|
||||
def handle_startendtag(self, tag, attrs):
|
||||
self.stack[-1].children.append(Node(tag,attrs))
|
||||
def handle_endtag(self, tag):
|
||||
for i in range(len(self.stack)-1,0,-1):
|
||||
if self.stack[i].tag == tag:
|
||||
self.stack = self.stack[:i]; return
|
||||
def handle_data(self, data):
|
||||
self.stack[-1].children.append(data)
|
||||
|
||||
def clean(value):
|
||||
return re.sub(r'\s+', ' ', html.unescape(re.sub('<[^>]*>',' ',str(value or '')))).strip()
|
||||
def norm(value):
|
||||
return re.sub('[^a-z0-9]', '', unicodedata.normalize('NFKD', value).encode('ascii','ignore').decode().lower())
|
||||
def fetch(url):
|
||||
file = CACHE / (hashlib.sha256(url.encode()).hexdigest()+'.html')
|
||||
if file.exists() and date.fromtimestamp(file.stat().st_mtime).isoformat()==DATE: return file.read_text()
|
||||
req = urllib.request.Request(url, headers={'User-Agent':'PawprintPlan/1.0 (breed facts)'})
|
||||
with urllib.request.urlopen(req, timeout=20) as r: text = r.read().decode('utf-8','replace')
|
||||
file.write_text(text); return text
|
||||
|
||||
def references(provider, key, url, facts):
|
||||
return {'provider':provider,'external_id':str(key),'url':url,'retrieved_at':DATE,
|
||||
'facts':{k:clean(v) for k,v in facts.items() if v is not None and clean(v)}}
|
||||
|
||||
GROUPS=['牧羊犬与牧牛犬组','宾莎犬、雪纳瑞、獒犬与瑞士山地牧牛犬组','梗犬组','腊肠犬组','尖嘴犬与原始犬种组','嗅觉猎犬与相关犬种组','指示猎犬组','寻回犬、搜寻犬与水猎犬组','伴侣犬与玩具犬组','灵缇(视觉猎犬)组']
|
||||
COUNTRIES={'AUSTRALIA':'澳大利亚','BELGIUM':'比利时','CROATIA':'克罗地亚','CZECHOSLOVAKIA':'捷克斯洛伐克','FRANCE':'法国','GERMANY':'德国','GREAT BRITAIN':'英国','HUNGARY':'匈牙利','ITALY':'意大利','POLAND':'波兰','PORTUGAL':'葡萄牙','ROMANIA':'罗马尼亚','RUSSIA':'俄罗斯','SLOVAKIA':'斯洛伐克','SPAIN':'西班牙','SWITZERLAND':'瑞士','THE NETHERLANDS':'荷兰','NETHERLANDS':'荷兰','AUSTRIA':'奥地利','DENMARK':'丹麦','FINLAND':'芬兰','IRELAND':'爱尔兰','NORWAY':'挪威','SWEDEN':'瑞典','USA':'美国','UNITED STATES OF AMERICA':'美国','CANADA':'加拿大','JAPAN':'日本','CHINA':'中国','KOREA':'韩国','ISRAEL':'以色列','MEXICO':'墨西哥','BRAZIL':'巴西','ARGENTINA':'阿根廷','AFGHANISTAN':'阿富汗','MIDDLE EAST':'中东','TURKEY':'土耳其','THAILAND':'泰国','SOUTH AFRICA':'南非','MOROCCO':'摩洛哥','MALI':'马里','ICELAND':'冰岛','GREENLAND':'格陵兰','MALTA':'马耳他','BOSNIA AND HERZEGOVINA':'波斯尼亚和黑塞哥维那','SERBIA':'塞尔维亚','MONTENEGRO':'黑山','GREECE':'希腊','URUGUAY':'乌拉圭','PERU':'秘鲁','INDONESIA':'印度尼西亚','LITHUANIA':'立陶宛','ESTONIA':'爱沙尼亚','BELGIUM, FRANCE':'比利时、法国','CENTRAL AFRICA':'非洲中部','CUBA':'古巴','CZECH REPUBLIC':'捷克','GERMANY, SWITZERLAND':'德国、瑞士','KOREA (REPUBLIC OF)':'韩国','NORTH MACEDONIA, SERBIA':'北马其顿、塞尔维亚','RUSSIAN FEDERATION':'俄罗斯','SLOVENIA':'斯洛文尼亚','TAIWAN':'中国台湾','TIBET (CHINA)':'中国西藏'}
|
||||
|
||||
def fci_group(link):
|
||||
number, url=link
|
||||
nodes=list(DOM(fetch(url)).root.all()); country=''; records={}
|
||||
for n in nodes:
|
||||
node_id=n.attrs.get('id','')
|
||||
if re.search(r'_PaysLabel_\d+$',node_id): country=re.sub(r'^\d+\.\s*','',clean(n.text()))
|
||||
if n.tag=='a' and 'RaceHyperLink' in node_id:
|
||||
match=re.search(r'\((\d+)\)',n.text())
|
||||
if not match: continue
|
||||
key=match.group(1); title=clean(n.text()); en=re.findall(r'\(([^\d][^)]*)\)',title)
|
||||
name = re.sub(r'-\d+\.html$', '', n.attrs['href'].split('/')[-1]).replace('-', ' ')
|
||||
facts={'english_name':name.title(),'origin':COUNTRIES.get(country,country.title()),'breed_group':GROUPS[number-1],'fci_number':key}
|
||||
records[key]=references('fci',key,urljoin(url,n.attrs['href']),facts)
|
||||
return records
|
||||
|
||||
def akc_page(item):
|
||||
name,url=item
|
||||
nodes=list(DOM(fetch(url)).root.all()); props=None
|
||||
for n in nodes:
|
||||
if n.attrs.get('data-js-component')=='breedPage': props=json.loads(n.attrs['data-js-props']);break
|
||||
if not props: return None
|
||||
data=props['settings']['breed_data']; slug=props['settings']['current_breed']
|
||||
basic=data.get('basics',{}).get(slug,{})
|
||||
standard=data.get('standards',{}).get(slug,{})
|
||||
traits=data.get('traits',{}).get(slug,{})
|
||||
facts={'english_name':name,'life_span':basic.get('life_expectancy'),'height':standard.get('height_display'),'weight':standard.get('weight_display')}
|
||||
scores={}
|
||||
labels={'energy_level':'运动活力','trainability_level':'训练接受度','shedding_level':'掉毛程度','barking_level':'吠叫倾向','coat_grooming_frequency':'梳毛需求','affectionate_with_family':'亲近家人'}
|
||||
for key, label in labels.items():
|
||||
value=traits.get('traits',{}).get(key,{}).get('score')
|
||||
if str(value) in ['1','2','3','4','5']:scores[label]=int(value)
|
||||
if scores:facts['traits']=json.dumps(scores,ensure_ascii=False,separators=(',',':'))
|
||||
words={'alert':'对环境敏锐','affectionate':'喜欢亲近家人','smart':'学习反应快','intelligent':'学习反应快','friendly':'亲人友善','active':'喜欢活动','energetic':'精力较充沛','playful':'喜欢玩耍','loyal':'重视与家人的联系','independent':'有自主性','gentle':'性情较温和','confident':'比较自信','courageous':'胆量较足','curious':'好奇心较强','calm':'较为沉着','outgoing':'乐于互动','devoted':'重视与家人的联系','lively':'喜欢活动','watchful':'对环境敏锐'}
|
||||
tags=list(dict.fromkeys(words[w.strip().lower()] for w in traits.get('temperament','').split('/') if w.strip().lower() in words))
|
||||
if tags:facts['temperament']='、'.join(tags)
|
||||
return references('akc',slug,url,facts)
|
||||
|
||||
def localized(key, value):
|
||||
value=clean(value).replace('(male)','(公犬)').replace('(female)','(母犬)').replace('up to','不超过').replace('years','年').replace('year','年')
|
||||
unit, factor, label = ('inches',2.54,'cm') if key=='height' else ('pounds',0.45359237,'kg')
|
||||
def convert(match):
|
||||
low=f"{float(match.group(1))*factor:.1f}".rstrip('0').rstrip('.')
|
||||
high=("–"+f"{float(match.group(2))*factor:.1f}".rstrip('0').rstrip('.')) if match.group(2) else ''
|
||||
return low+high+' '+label
|
||||
return re.sub(r'(\d+(?:\.\d+)?)(?:\s*[-–]\s*(\d+(?:\.\d+)?))?\s*'+unit,convert,value)
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser();parser.parse_args()
|
||||
rows=json.loads((ROOT/'data.json').read_text())
|
||||
home=list(DOM(fetch('https://www.fci.be/en/nomenclature/')).root.all())
|
||||
links=[]
|
||||
for n in home:
|
||||
if n.tag=='a' and 'GroupesRepeater_GroupeHyperLink' in n.attrs.get('id',''):
|
||||
links.append((int(clean(n.text()).split()[-1]),urljoin('https://www.fci.be',n.attrs['href'])))
|
||||
fci={}
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
|
||||
for result in pool.map(fci_group,links): fci.update(result)
|
||||
print('FCI catalog:',len(fci),flush=True)
|
||||
akc={}
|
||||
for n in DOM(fetch('https://www.akc.org/dog-breeds/')).root.all():
|
||||
if n.tag=='option' and re.fullmatch(r'https://www.akc.org/dog-breeds/[a-z0-9-]+/',n.attrs.get('value','')):
|
||||
akc[norm(clean(n.text()))]=(clean(n.text()),n.attrs['value'])
|
||||
matches={}; entries=[]
|
||||
for row in rows:
|
||||
refs=[];fields={};field_sources={}
|
||||
existing={k:row.get(k,'') for k in ['english_name','origin','breed_group','fci_number','height','weight','temperament','traits'] if row.get(k)}
|
||||
if row['source_key'].startswith('cku:'):
|
||||
refs.append(references('cku',row['source_key'].split(':')[1],row['source_url'],{'name':row['name'],'aliases':row['aliases'],**existing}))
|
||||
for k in existing:field_sources[k]='cku'
|
||||
match=re.match(r'cku:(\d+)',row['source_key']); source=fci.get(match.group(1)) if match else None
|
||||
if source:
|
||||
source=json.loads(json.dumps(source))
|
||||
if re.search(r'[A-Z]$',row['source_key']):source['facts']['scope']='犬种基础标准(包含多个变种)'
|
||||
refs.append(source)
|
||||
for k,v in source['facts'].items():
|
||||
if k not in ['scope'] and not (k=='english_name' and re.search(r'[A-Z]$',row['source_key'])) and not row.get(k): fields[k]=v;field_sources[k]='fci'
|
||||
candidates=[row.get('english_name',''),fields.get('english_name','')]
|
||||
equivalents={'cku:39':'Pembroke Welsh Corgi','cku:38':'Cardigan Welsh Corgi','cku:167':'Cocker Spaniel'}
|
||||
if row['source_key'] in equivalents:candidates.insert(0,equivalents[row['source_key']])
|
||||
# Varieties are deliberately not collapsed into their parent breed.
|
||||
for candidate in candidates:
|
||||
if norm(candidate) in akc:
|
||||
item=akc[norm(candidate)];matches[item]=None;break
|
||||
entries.append({'source_key':row['source_key'],'name':row['name'],'fields':fields,'field_sources':field_sources,'references':refs})
|
||||
print('Exact AKC matches:',len(matches),flush=True)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
||||
futures={pool.submit(akc_page,item):item for item in matches}
|
||||
done=0
|
||||
for job in concurrent.futures.as_completed(futures):
|
||||
item=futures[job]
|
||||
try:matches[item]=job.result()
|
||||
except Exception as e: print('AKC unavailable:',item[0],type(e).__name__,flush=True)
|
||||
done+=1
|
||||
if done%20==0:print('AKC progress:',done,'/',len(matches),flush=True)
|
||||
by_name={norm(name):ref for (name,_),ref in matches.items() if ref}
|
||||
for row,entry in zip(rows,entries):
|
||||
names=[row.get('english_name',''),entry['fields'].get('english_name','')]
|
||||
if row['source_key'] in equivalents:names.insert(0,equivalents[row['source_key']])
|
||||
ref=next((by_name[norm(n)] for n in names if norm(n) in by_name),None)
|
||||
if not ref:continue
|
||||
ref=json.loads(json.dumps(ref))
|
||||
if re.search(r'[A-Z]$',row['source_key']):
|
||||
ref['facts']['scope']='父犬种资料,变种差异以对应标准为准'
|
||||
entry['references'].append(ref);continue
|
||||
entry['references'].append(ref)
|
||||
for k,v in ref['facts'].items():
|
||||
if k in ['height','weight','life_span'] or (not row.get(k) and not entry['fields'].get(k)):entry['fields'][k]=localized(k,v);entry['field_sources'][k]='akc'
|
||||
# Lifestyle ratings have an explicit AKC source, keeping CKU ratings in the reference.
|
||||
if ref['facts'].get('traits'):entry['fields']['traits']=ref['facts']['traits'];entry['field_sources']['traits']='akc'
|
||||
rkc={r['source_key']:r for r in json.loads((ROOT/'rkc_facts.json').read_text())}
|
||||
for row,entry in zip(rows,entries):
|
||||
if row['source_key'] in rkc:
|
||||
r=rkc[row['source_key']]
|
||||
rkc_ref=references('rkc',r['external_id'],r['url'],r['facts']);rkc_ref['retrieved_at']=r['retrieved_at'];entry['references'].append(rkc_ref)
|
||||
for k in ['exercise','grooming','life_span']:
|
||||
if k=='life_span' and entry['fields'].get(k):continue
|
||||
entry['fields'][k]=r['facts'][k];entry['field_sources'][k]='rkc'
|
||||
if not entry['fields'].get('summary') and not row.get('summary'):
|
||||
facts={**row,**entry['fields']};bits=[]
|
||||
if facts.get('origin'):bits.append('这一犬种起源于'+facts['origin'])
|
||||
if facts.get('breed_group'):bits.append('在 FCI 体系中归入'+facts['breed_group'])
|
||||
if facts.get('temperament'):bits.append('常见性格倾向包括'+facts['temperament'])
|
||||
if bits:entry['fields']['summary']='。'.join(bits)+'。';entry['field_sources']['summary']='editorial'
|
||||
if row.get('summary'):entry['field_sources']['summary']='editorial'
|
||||
if row.get('care_notes'):entry['field_sources']['care_notes']='editorial'
|
||||
if entry['references']:
|
||||
scores={}
|
||||
try:scores=json.loads(entry['fields'].get('traits') or row.get('traits') or '{}')
|
||||
except ValueError:pass
|
||||
if not entry['fields'].get('exercise') and scores.get('运动活力'):
|
||||
entry['fields']['exercise']='运动活力指数 '+str(scores['运动活力'])+'/5,结合年龄与体力安排散步和互动';entry['field_sources']['exercise']='editorial'
|
||||
if not entry['fields'].get('grooming') and scores.get('梳毛需求'):
|
||||
entry['fields']['grooming']='梳毛需求指数 '+str(scores['梳毛需求'])+'/5,按被毛情况安排梳理';entry['field_sources']['grooming']='editorial'
|
||||
(ROOT/'enrichment.json').write_text(json.dumps(entries,ensure_ascii=False,indent=2)+'\n')
|
||||
print('Saved',len(entries),'breeds,',sum(len(e['references']) for e in entries),'references',flush=True)
|
||||
if __name__=='__main__':main()
|
||||
Generated
+58
@@ -17,6 +17,7 @@
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
@@ -1600,6 +1601,19 @@
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"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": {
|
||||
"version": "2.2.3",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"recharts": "^3.10.1",
|
||||
"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 {
|
||||
Dog,
|
||||
CalendarDays,
|
||||
CheckCheck,
|
||||
Files,
|
||||
@@ -37,17 +40,21 @@ const icons = {
|
||||
overview: LayoutDashboard,
|
||||
users: Users,
|
||||
pets: PawPrint,
|
||||
breeds: Dog,
|
||||
walks: Route,
|
||||
meetups: CalendarDays,
|
||||
tasks: CheckCheck,
|
||||
files: Files,
|
||||
};
|
||||
type Tab = Section | "overview" | "breeds";
|
||||
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()),
|
||||
[username, setUsername] = useState("sundynix"),
|
||||
[password, setPassword] = useState("");
|
||||
const [tab, setTab] = useState<Section | "overview">("overview"),
|
||||
[revision, refresh] = useState(0),
|
||||
const [revision, refresh] = useState(0),
|
||||
[stats, setStats] = useState<Stats | null>(null);
|
||||
const [error, setError] = useState(""),
|
||||
[busy, setBusy] = useState(false),
|
||||
@@ -66,8 +73,10 @@ export default function App() {
|
||||
setConfirm(null);
|
||||
setError("登录已过期,请重新登录");
|
||||
};
|
||||
const sync = () => setAuth(getToken());
|
||||
window.addEventListener("storage", sync);
|
||||
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(() => {
|
||||
if (!token || tab !== "overview") return;
|
||||
@@ -94,7 +103,7 @@ export default function App() {
|
||||
setError("");
|
||||
try {
|
||||
const value = await api.login(username.trim(), password);
|
||||
setToken(value.token);
|
||||
setToken(value);
|
||||
setAuth(value.token);
|
||||
setPassword("");
|
||||
} catch (e) {
|
||||
@@ -104,6 +113,7 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
function logout() {
|
||||
void api.logout().catch(() => {});
|
||||
clearToken();
|
||||
setAuth("");
|
||||
setPassword("");
|
||||
@@ -113,9 +123,9 @@ export default function App() {
|
||||
setError("");
|
||||
setNotice("");
|
||||
}
|
||||
function navigate(section: Section | "overview") {
|
||||
function navigate(section: Tab) {
|
||||
setBusy(false);
|
||||
setTab(section);
|
||||
routerNavigate(`/admin/${section}`);
|
||||
setError("");
|
||||
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)
|
||||
return (
|
||||
<main className="login-page">
|
||||
@@ -231,20 +247,20 @@ export default function App() {
|
||||
</div>
|
||||
<p className="side-caption">陪伴工作台</p>
|
||||
<nav aria-label="管理导航">
|
||||
{(Object.keys(labels) as (Section | "overview")[]).map((key) => {
|
||||
{(Object.keys(labels) as Tab[]).map((key) => {
|
||||
const Icon = icons[key];
|
||||
return (
|
||||
<button
|
||||
<NavLink
|
||||
to={`/admin/${key}`}
|
||||
key={key}
|
||||
className={`nav-item ${tab === key ? "active" : ""}`}
|
||||
aria-current={tab === key ? "page" : undefined}
|
||||
title={labels[key]}
|
||||
onClick={() => navigate(key)}
|
||||
>
|
||||
<Icon size={19} />
|
||||
<span>{labels[key]}</span>
|
||||
{tab === key && <span className="nav-dot" />}
|
||||
</button>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
@@ -318,6 +334,8 @@ export default function App() {
|
||||
)}
|
||||
{tab === "overview" ? (
|
||||
<Overview stats={stats} navigate={navigate} />
|
||||
) : tab === "breeds" ? (
|
||||
<Breeds revision={revision} />
|
||||
) : (
|
||||
<Records
|
||||
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";
|
||||
export const labels: Record<Section | "overview", string> = {
|
||||
export const labels: Record<Section | "overview" | "breeds", string> = {
|
||||
overview: "工作台",
|
||||
users: "用户管理",
|
||||
pets: "宠物档案",
|
||||
breeds: "狗狗品种",
|
||||
walks: "遛狗记录",
|
||||
meetups: "约遛活动",
|
||||
tasks: "日常任务",
|
||||
|
||||
@@ -1090,3 +1090,28 @@ td strong {
|
||||
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";
|
||||
export type Section =
|
||||
"users" | "pets" | "walks" | "meetups" | "tasks" | "files";
|
||||
| "users"
|
||||
| "pets"
|
||||
| "walks"
|
||||
| "meetups"
|
||||
| "tasks"
|
||||
| "files";
|
||||
export interface Row {
|
||||
id: string;
|
||||
nickname?: string;
|
||||
@@ -52,6 +57,64 @@ export interface Row {
|
||||
bucket?: string;
|
||||
object_key?: string;
|
||||
}
|
||||
export interface BreedReference {
|
||||
id: string;
|
||||
provider: string;
|
||||
external_id: string;
|
||||
url: string;
|
||||
retrieved_at: string;
|
||||
facts: Record<string, string>;
|
||||
}
|
||||
export interface Breed {
|
||||
id: string;
|
||||
name: string;
|
||||
english_name: string;
|
||||
aliases: string;
|
||||
fci_number: string;
|
||||
life_span: string;
|
||||
exercise: string;
|
||||
grooming: string;
|
||||
traits: string;
|
||||
references?: BreedReference[];
|
||||
field_sources?: Record<string, string>;
|
||||
editorial_locked: boolean;
|
||||
initial: string;
|
||||
pinyin: string;
|
||||
enabled: boolean;
|
||||
source: string;
|
||||
source_url: string;
|
||||
origin: string;
|
||||
breed_group: string;
|
||||
height: string;
|
||||
weight: string;
|
||||
summary: string;
|
||||
temperament: string;
|
||||
care_notes: string;
|
||||
}
|
||||
export type BreedInput = Pick<
|
||||
Breed,
|
||||
| "name"
|
||||
| "english_name"
|
||||
| "aliases"
|
||||
| "enabled"
|
||||
| "origin"
|
||||
| "breed_group"
|
||||
| "height"
|
||||
| "weight"
|
||||
| "summary"
|
||||
| "temperament"
|
||||
| "care_notes"
|
||||
| "fci_number"
|
||||
| "life_span"
|
||||
| "exercise"
|
||||
| "grooming"
|
||||
> & { traits?: Record<string, number> };
|
||||
export interface BreedPage {
|
||||
items: Breed[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
export interface Point {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
@@ -79,13 +142,101 @@ export interface Stats {
|
||||
storage_bytes: 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 setToken = (value: string) => localStorage.setItem(key, value);
|
||||
export const clearToken = () => localStorage.removeItem(key);
|
||||
export const setToken = (value: Session) => {
|
||||
localStorage.setItem(refreshKey, value.refresh_token);
|
||||
localStorage.setItem(expiryKey, String(value.expires_at));
|
||||
localStorage.setItem(key, value.token);
|
||||
};
|
||||
export const clearToken = () => {
|
||||
localStorage.removeItem(key);
|
||||
localStorage.removeItem(refreshKey);
|
||||
localStorage.removeItem(expiryKey);
|
||||
};
|
||||
const raw = axios.create({ baseURL: "/api", timeout: 20000 });
|
||||
const http = axios.create({ baseURL: "/api", timeout: 20000 });
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = getToken();
|
||||
let refreshing: Promise<string> | null = null;
|
||||
function expireSession(usedRefresh: string) {
|
||||
if (localStorage.getItem(refreshKey) !== usedRefresh) return;
|
||||
clearToken();
|
||||
window.dispatchEvent(new Event("admin-auth-expired"));
|
||||
}
|
||||
async function performRefresh(): Promise<string> {
|
||||
const used = localStorage.getItem(refreshKey) || "";
|
||||
if (!used) {
|
||||
clearToken();
|
||||
window.dispatchEvent(new Event("admin-auth-expired"));
|
||||
throw new Error("登录已过期,请重新登录");
|
||||
}
|
||||
try {
|
||||
const response = await raw.post("/admin/refresh", { refresh_token: used });
|
||||
if (response.data?.code !== 0 || !response.data.data?.refresh_token)
|
||||
throw new Error(response.data?.message || "刷新登录失败");
|
||||
// Logout while the request is in flight must not resurrect the session.
|
||||
if (localStorage.getItem(refreshKey) !== used) {
|
||||
if (getToken()) return getToken();
|
||||
throw new Error("已退出登录");
|
||||
}
|
||||
setToken(response.data.data);
|
||||
return response.data.data.token;
|
||||
} catch (error) {
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
[401, 403].includes(error.response?.status || 0)
|
||||
) {
|
||||
if (
|
||||
localStorage.getItem(refreshKey) &&
|
||||
localStorage.getItem(refreshKey) !== used
|
||||
)
|
||||
return getToken();
|
||||
expireSession(used);
|
||||
}
|
||||
throw new Error(
|
||||
axios.isAxiosError(error)
|
||||
? error.response?.data?.message || "暂时无法刷新登录,请重试"
|
||||
: (error as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
function refreshSession(usedAccess?: string): Promise<string> {
|
||||
if (refreshing) return refreshing;
|
||||
const work = () => {
|
||||
if (usedAccess && getToken() !== usedAccess && getToken())
|
||||
return Promise.resolve(getToken());
|
||||
const expires = Number(localStorage.getItem(expiryKey));
|
||||
if (!usedAccess && expires > Date.now() / 1000 + 60 && getToken())
|
||||
return Promise.resolve(getToken());
|
||||
return performRefresh();
|
||||
};
|
||||
// Serialize browser tabs as well as requests in this tab when Web Locks are available.
|
||||
refreshing = (
|
||||
navigator.locks
|
||||
? navigator.locks.request("pets-admin-refresh", work)
|
||||
: work()
|
||||
).finally(() => {
|
||||
refreshing = null;
|
||||
});
|
||||
return refreshing;
|
||||
}
|
||||
http.interceptors.request.use(async (config) => {
|
||||
let token = getToken();
|
||||
const expires = Number(localStorage.getItem(expiryKey));
|
||||
if (
|
||||
token &&
|
||||
config.url !== "/admin/login" &&
|
||||
expires &&
|
||||
expires <= Date.now() / 1000 + 60
|
||||
)
|
||||
token = await refreshSession();
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
@@ -95,9 +246,27 @@ http.interceptors.response.use(
|
||||
if (body?.code === 0) return body.data;
|
||||
throw new Error(body?.message || "请求失败");
|
||||
},
|
||||
(error) => {
|
||||
async (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();
|
||||
window.dispatchEvent(new Event("admin-auth-expired"));
|
||||
}
|
||||
@@ -106,14 +275,36 @@ http.interceptors.response.use(
|
||||
error.response?.data?.message ||
|
||||
(error.code === "ECONNABORTED"
|
||||
? "请求超时,请重试"
|
||||
: "无法连接服务器,请稍后重试"),
|
||||
: error.message || "无法连接服务器,请稍后重试"),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
export const api = {
|
||||
logout: () => {
|
||||
const refresh_token = localStorage.getItem(refreshKey);
|
||||
return refresh_token
|
||||
? raw.post("/admin/logout", { refresh_token })
|
||||
: Promise.resolve();
|
||||
},
|
||||
breeds: (
|
||||
params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
q: string;
|
||||
status: string;
|
||||
initial: string;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
) => http.get<unknown, BreedPage>("/admin/breeds", { params, signal }),
|
||||
saveBreed: (id: string, data: BreedInput) =>
|
||||
id
|
||||
? http.put(`/admin/breeds/${id}`, data)
|
||||
: http.post("/admin/breeds", data),
|
||||
setBreedEnabled: (id: string, enabled: boolean) =>
|
||||
http.put(`/admin/breeds/${id}/enabled`, { enabled }),
|
||||
login: (username: string, password: string) =>
|
||||
http.post<unknown, { token: string }>("/admin/login", {
|
||||
http.post<unknown, Session>("/admin/login", {
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
|
||||
@@ -2,9 +2,15 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<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>,
|
||||
)
|
||||
|
||||
@@ -18,3 +18,5 @@
|
||||
4. 用另一个微信账号报名约遛,确认已报名和退出状态。
|
||||
|
||||
小程序源码约 600 KB,低于微信主包 2 MB 限制。真机调试前请确认小程序后台已配置 `pet.sundynix.cn` 为合法请求域名。
|
||||
|
||||
底部导航顺序:日常、任务、遛狗、宠物、知识。遛狗页面内切换轨迹记录和约遛;知识提供犬种 A–Z 搜索与百科详情,宠物表单支持选品种或手动填写。访问与刷新 token 均为两小时,到期前自动续期,刷新失效时重新微信登录。
|
||||
|
||||
+23
-8
@@ -1,15 +1,26 @@
|
||||
App({
|
||||
globalData: { token: "", user: null },
|
||||
globalData: { token: "", user: null, expiresAt: 0 },
|
||||
onLaunch() {
|
||||
this.globalData.token = wx.getStorageSync("pet_token") || "";
|
||||
this.globalData.expiresAt = Number(wx.getStorageSync("pet_token_expires_at")) || 0;
|
||||
// Start immediately; pages await the same promise and display failures themselves.
|
||||
this.login().catch(() => {});
|
||||
},
|
||||
async login() {
|
||||
if (this.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 () => {
|
||||
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) =>
|
||||
wx.login({
|
||||
timeout: 10000,
|
||||
@@ -19,16 +30,20 @@ App({
|
||||
);
|
||||
if (!code) throw new Error("微信登录未取得凭证,请重新连接");
|
||||
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;
|
||||
wx.setStorageSync("pet_token", data.token);
|
||||
return data.token;
|
||||
return this.saveSession(data);
|
||||
})().finally(() => {
|
||||
this.loginPromise = null;
|
||||
});
|
||||
return this.loginPromise;
|
||||
},
|
||||
saveSession(data) {
|
||||
if (!data || typeof data.token !== "string" || !data.token) throw new Error("登录未完成,请重新连接");
|
||||
this.globalData.token = data.token;
|
||||
this.globalData.expiresAt = Number(data.expires_at) || 0;
|
||||
wx.setStorageSync("pet_token", data.token);
|
||||
wx.setStorageSync("pet_token_expires_at", this.globalData.expiresAt);
|
||||
if (data.refresh_token) wx.setStorageSync("pet_refresh_token", data.refresh_token);
|
||||
return data.token;
|
||||
},
|
||||
});
|
||||
|
||||
+3
-2
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/home/home",
|
||||
"pages/meetups/meetups",
|
||||
"pages/walk/walk",
|
||||
"pages/tasks/tasks",
|
||||
"pages/pets/pets",
|
||||
"pages/pet-edit/pet-edit",
|
||||
"pages/meetup-edit/meetup-edit",
|
||||
"pages/share/share",
|
||||
"pages/walk-records/walk-records"
|
||||
"pages/walk-records/walk-records",
|
||||
"pages/knowledge/knowledge",
|
||||
"pages/breed-detail/breed-detail"
|
||||
],
|
||||
"window": {
|
||||
"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": {
|
||||
"tab-bar": "/components/tab-bar/index",
|
||||
"pet-picker": "/components/pet-picker/index"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
<page-layout id="page-layout" title="约个伴一起遛">
|
||||
<view class="screen">
|
||||
<view class="banner">
|
||||
<view class="banner-copy">
|
||||
@@ -35,8 +34,7 @@
|
||||
</view>
|
||||
<button class="primary" bindtap="create">+ 发起一场约遛</button>
|
||||
</view>
|
||||
</page-layout>
|
||||
<view wx:if="{{active}}" class="overlay">
|
||||
<root-portal wx:if="{{active}}"><view class="overlay">
|
||||
<view class="sheet">
|
||||
<view class="row">
|
||||
<strong>{{active.title}}</strong>
|
||||
@@ -57,4 +55,6 @@
|
||||
<button wx:else class="secondary" bindtap="cancel" disabled="{{busy}}">取消这场约遛</button>
|
||||
</view>
|
||||
</view>
|
||||
<tab-bar current="/pages/meetups/meetups" />
|
||||
|
||||
|
||||
</root-portal>
|
||||
@@ -1,9 +1,9 @@
|
||||
const tabs = [
|
||||
{ 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/walk/walk", label: "遛狗", key: "walk" },
|
||||
{ path: "/pages/pets/pets", label: "宠物", key: "pets" },
|
||||
{ path: "/pages/knowledge/knowledge", label: "知识", key: "knowledge" },
|
||||
];
|
||||
Component({
|
||||
properties: { current: String },
|
||||
|
||||
@@ -25,10 +25,7 @@
|
||||
<view class="daily-rays"></view>
|
||||
<view class="daily-sun"></view>
|
||||
</view>
|
||||
<view wx:elif="{{item.key==='meetups'}}" class="friends-icon">
|
||||
<view class="friend friend-left"></view>
|
||||
<view class="friend friend-right"></view>
|
||||
</view>
|
||||
<view wx:elif="{{item.key==='knowledge'}}" class="book-icon"><view class="book-spine"></view><view class="book-line"></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>
|
||||
|
||||
@@ -101,32 +101,9 @@
|
||||
background: #fff;
|
||||
}
|
||||
.active .daily-sun { background: #fff0e2; }
|
||||
.friends-icon {
|
||||
position: relative;
|
||||
width: 48rpx;
|
||||
height: 45rpx;
|
||||
}
|
||||
.friend {
|
||||
position: absolute;
|
||||
bottom: 2rpx;
|
||||
width: 25rpx;
|
||||
height: 16rpx;
|
||||
border: 4rpx solid currentColor;
|
||||
border-radius: 18rpx 18rpx 5rpx 5rpx;
|
||||
}
|
||||
.friend::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -25rpx;
|
||||
left: 3rpx;
|
||||
width: 13rpx;
|
||||
height: 13rpx;
|
||||
border: 4rpx solid currentColor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.friend-left { left: 1rpx; }
|
||||
.friend-right { right: 1rpx; background: #fff; }
|
||||
.active .friend-right { background: #fff0e2; }
|
||||
.book-icon { position:relative;width:42rpx;height:34rpx;border:3rpx solid currentColor;border-radius:8rpx 8rpx 5rpx 5rpx; }
|
||||
.book-spine { position:absolute;left:18rpx;top:0;height:100%;border-left:3rpx solid currentColor; }
|
||||
.book-line { position:absolute;left:5rpx;right:5rpx;top:9rpx;border-top:2rpx solid currentColor; }
|
||||
.task-icon {
|
||||
position: relative;
|
||||
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: "",
|
||||
name: "",
|
||||
breed: "",
|
||||
breed_id: "",
|
||||
gender: "male",
|
||||
birth_date: "",
|
||||
weight_kg: "",
|
||||
@@ -30,6 +31,9 @@ Page({
|
||||
field(e) {
|
||||
this.setData({ [e.currentTarget.dataset.key]: e.detail.value });
|
||||
},
|
||||
breedChange(e) {
|
||||
this.setData({ breed: e.detail.name, breed_id: e.detail.id || "" });
|
||||
},
|
||||
date(e) {
|
||||
this.setData({ birth_date: e.detail.value });
|
||||
},
|
||||
@@ -55,6 +59,7 @@ Page({
|
||||
name: this.data.name,
|
||||
species: "dog",
|
||||
breed: this.data.breed,
|
||||
breed_id: this.data.breed_id || null,
|
||||
gender: this.data.gender,
|
||||
birth_date: this.data.birth_date || "",
|
||||
weight_kg: Number(this.data.weight_kg) || 0,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"disableScroll": true,
|
||||
"usingComponents": {}
|
||||
"usingComponents": {
|
||||
"breed-picker": "/components/breed-picker/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</view>
|
||||
<view class="field">
|
||||
<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 class="field">
|
||||
<view class="field-label">性别</view>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { secure, toast } = require("../../utils/api");
|
||||
Page({
|
||||
data: {
|
||||
mode: "walk",
|
||||
pets: [],
|
||||
selected: [],
|
||||
walk: null,
|
||||
@@ -14,6 +15,10 @@ Page({
|
||||
busy: false,
|
||||
locationError: "",
|
||||
},
|
||||
changeMode(e) {
|
||||
const mode = e.currentTarget.dataset.mode;
|
||||
if (mode !== this.data.mode) this.setData({ mode });
|
||||
},
|
||||
onShow() {
|
||||
this.load();
|
||||
clearInterval(this.clock);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"disableScroll": true,
|
||||
"usingComponents": {
|
||||
"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="一起去兜风">
|
||||
<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">
|
||||
<map
|
||||
id="walk-map"
|
||||
|
||||
@@ -111,3 +111,8 @@
|
||||
border-radius: 20rpx;
|
||||
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;
|
||||
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];
|
||||
require(filename);
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user