Compare commits
13
Commits
25c2596ce9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdf9b9829c | ||
|
|
3175936266 | ||
|
|
fbc1eda561 | ||
|
|
abd64d1cdd | ||
|
|
9d7cf720a5 | ||
|
|
607d709100 | ||
|
|
d6cb582ffe | ||
|
|
b1439b0fe5 | ||
|
|
cb4ffa13e9 | ||
|
|
f0ee63e642 | ||
|
|
42aea10101 | ||
|
|
1d8c1693ac | ||
|
|
4916c31edd |
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 117 KiB |
+3
-3
@@ -2,11 +2,11 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/brand-logo.png" />
|
||||
<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-DkKuSusU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B39ofd9P.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
|
||||
}
|
||||
|
||||
@@ -360,7 +360,9 @@ func (a *API) adminTasks(c *gin.Context) {
|
||||
ids, petIDs, taskIDs := []string{}, []string{}, []string{}
|
||||
for _, t := range rows {
|
||||
ids = append(ids, t.UserID)
|
||||
petIDs = append(petIDs, t.PetID)
|
||||
if t.PetID != nil {
|
||||
petIDs = append(petIDs, *t.PetID)
|
||||
}
|
||||
taskIDs = append(taskIDs, t.ID)
|
||||
}
|
||||
names, err := a.userNames(ids)
|
||||
@@ -388,7 +390,11 @@ func (a *API) adminTasks(c *gin.Context) {
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, t := range rows {
|
||||
items = append(items, gin.H{"id": t.ID, "user_id": t.UserID, "user_name": names[t.UserID], "pet_id": t.PetID, "pet_name": petNames[t.PetID], "title": t.Title, "category": t.Category, "time_of_day": t.TimeOfDay, "repeat_rule": t.RepeatRule, "reminder_enabled": t.ReminderEnabled, "completed": done[t.ID], "created_at": t.CreatedAt})
|
||||
petName := "全部宠物"
|
||||
if t.PetID != nil {
|
||||
petName = petNames[*t.PetID]
|
||||
}
|
||||
items = append(items, gin.H{"id": t.ID, "user_id": t.UserID, "user_name": names[t.UserID], "pet_id": t.PetID, "all_pets": t.PetID == nil, "pet_name": petName, "title": t.Title, "category": t.Category, "time_of_day": t.TimeOfDay, "repeat_rule": t.RepeatRule, "reminder_enabled": t.ReminderEnabled, "completed": done[t.ID], "created_at": t.CreatedAt})
|
||||
}
|
||||
page.Items = items
|
||||
ok(c, page)
|
||||
|
||||
@@ -2,9 +2,11 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -57,12 +59,13 @@ func (a *API) adminLogin(c *gin.Context) {
|
||||
fail(c, http.StatusUnauthorized, "用户名或密码错误")
|
||||
return
|
||||
}
|
||||
token, err := a.token(admin.ID, "admin")
|
||||
pair, err := a.newSession(admin.ID, "admin")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "签发令牌失败")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"token": token, "admin": gin.H{"id": admin.ID, "username": admin.Username}})
|
||||
pair["admin"] = gin.H{"id": admin.ID, "username": admin.Username}
|
||||
ok(c, pair)
|
||||
}
|
||||
|
||||
func (a *API) devLogin(c *gin.Context) {
|
||||
@@ -79,8 +82,9 @@ func (a *API) devLogin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(in.Nickname)
|
||||
if name == "" {
|
||||
name = "柯基家长"
|
||||
if len([]rune(name)) > 64 {
|
||||
fail(c, http.StatusBadRequest, "昵称不能超过 64 字")
|
||||
return
|
||||
}
|
||||
user, err := a.findOrCreateUser("dev:"+in.Key, name)
|
||||
if err != nil {
|
||||
@@ -123,7 +127,7 @@ func (a *API) login(c *gin.Context) {
|
||||
fail(c, http.StatusUnauthorized, "微信登录凭证无效")
|
||||
return
|
||||
}
|
||||
user, err := a.findOrCreateUser(wx.OpenID, "柯基家长")
|
||||
user, err := a.findOrCreateUser(wx.OpenID, "")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "登录失败")
|
||||
return
|
||||
@@ -140,28 +144,73 @@ func (a *API) findOrCreateUser(openID, nickname string) (model.User, error) {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return user, err
|
||||
}
|
||||
user = model.User{OpenID: openID, Nickname: nickname}
|
||||
if err := a.db.Create(&user).Error; err != nil {
|
||||
// A concurrent login may have inserted the same open_id.
|
||||
if lookup := a.db.Where("open_id = ?", openID).First(&user).Error; lookup == nil {
|
||||
return user, nil
|
||||
// Serialize new-user naming across server instances without requiring custom nicknames to be unique.
|
||||
err = a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(7270139527)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Another login for this open ID may have completed while waiting for the lock.
|
||||
if err := tx.Where("open_id = ?", openID).First(&user).Error; err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSpace(nickname)
|
||||
if name == "" {
|
||||
generated, err := defaultNickname(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name = generated
|
||||
}
|
||||
user = model.User{OpenID: openID, Nickname: name}
|
||||
return tx.Create(&user).Error
|
||||
})
|
||||
if err != nil {
|
||||
return user, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func defaultNickname(tx *gorm.DB) (string, error) {
|
||||
// Prefer four digits. Expand when occupied names make retries frequent.
|
||||
for digits := 4; digits <= 6; digits++ {
|
||||
limit := int64(10000)
|
||||
if digits == 5 {
|
||||
limit = 100000
|
||||
} else if digits == 6 {
|
||||
limit = 1000000
|
||||
}
|
||||
for attempt := 0; attempt < 20; attempt++ {
|
||||
number, err := rand.Int(rand.Reader, big.NewInt(limit))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate nickname: %w", err)
|
||||
}
|
||||
name := fmt.Sprintf("毛孩家长%0*d", digits, number.Int64())
|
||||
var count int64
|
||||
if err := tx.Model(&model.User{}).Where("nickname = ?", name).Count(&count).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
if count == 0 {
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", errors.New("default nickname allocation failed")
|
||||
}
|
||||
|
||||
func (a *API) loginUser(c *gin.Context, user model.User) {
|
||||
if user.Disabled {
|
||||
fail(c, http.StatusForbidden, "账号已停用")
|
||||
return
|
||||
}
|
||||
token, err := a.token(user.ID, "user")
|
||||
pair, err := a.newSession(user.ID, "user")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "签发令牌失败")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"token": token, "user": a.userView(user)})
|
||||
pair["user"] = a.userView(user)
|
||||
ok(c, pair)
|
||||
}
|
||||
|
||||
func (a *API) me(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sundynix/pets-be/internal/breeds"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (a *API) breedQuery(c *gin.Context) *gorm.DB {
|
||||
return search(a.db.Model(&model.Breed{}), c, "name", "english_name", "aliases", "pinyin", "pinyin_initials")
|
||||
}
|
||||
func (a *API) listBreeds(c *gin.Context) {
|
||||
if len([]rune(c.Query("q"))) > 128 {
|
||||
fail(c, 400, "搜索内容过长")
|
||||
return
|
||||
}
|
||||
query := a.breedQuery(c).Where("enabled = true")
|
||||
var rows []model.Breed
|
||||
if err := query.Order("initial ASC, pinyin ASC, name ASC").Find(&rows).Error; err != nil {
|
||||
fail(c, 500, "读取品种失败")
|
||||
return
|
||||
}
|
||||
// Users only need the selection fields; no internal source metadata.
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, gin.H{"id": row.ID, "name": row.Name, "english_name": row.EnglishName, "aliases": row.Aliases, "pinyin": row.Pinyin, "pinyin_initials": row.PinyinInitials, "initial": row.Initial, "has_details": row.Summary != "" || row.Origin != "", "origin": row.Origin})
|
||||
}
|
||||
ok(c, items)
|
||||
}
|
||||
func (a *API) adminBreeds(c *gin.Context) {
|
||||
query := a.breedQuery(c).Preload("References")
|
||||
if status := c.Query("status"); status != "" {
|
||||
if status != "enabled" && status != "disabled" {
|
||||
fail(c, 400, "状态无效")
|
||||
return
|
||||
}
|
||||
query = query.Where("enabled = ?", status == "enabled")
|
||||
}
|
||||
if initial := c.Query("initial"); initial != "" {
|
||||
if len(initial) != 1 || (initial != "#" && (initial < "A" || initial > "Z")) {
|
||||
fail(c, 400, "字母无效")
|
||||
return
|
||||
}
|
||||
query = query.Where("initial = ?", initial)
|
||||
}
|
||||
rows, page, valid := adminRows[model.Breed](c, query, "initial ASC, pinyin ASC, name ASC")
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
page.Items = rows
|
||||
ok(c, page)
|
||||
}
|
||||
|
||||
type breedInput struct {
|
||||
LifeSpan string `json:"life_span"`
|
||||
Exercise string `json:"exercise"`
|
||||
Grooming string `json:"grooming"`
|
||||
Traits map[string]int `json:"traits"`
|
||||
FCINumber string `json:"fci_number"`
|
||||
Origin string `json:"origin"`
|
||||
BreedGroup string `json:"breed_group"`
|
||||
Height string `json:"height"`
|
||||
Weight string `json:"weight"`
|
||||
Summary string `json:"summary"`
|
||||
Temperament string `json:"temperament"`
|
||||
CareNotes string `json:"care_notes"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
EnglishName string `json:"english_name"`
|
||||
Aliases string `json:"aliases"`
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
|
||||
func bindBreed(c *gin.Context) (model.Breed, bool) {
|
||||
var in breedInput
|
||||
if c.ShouldBindJSON(&in) != nil {
|
||||
fail(c, 400, "请填写品种名称和状态")
|
||||
return model.Breed{}, false
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
in.EnglishName = strings.TrimSpace(in.EnglishName)
|
||||
in.Aliases = strings.TrimSpace(in.Aliases)
|
||||
if in.Name == "" || len([]rune(in.Name)) > 128 || len([]rune(in.EnglishName)) > 128 || len([]rune(in.Aliases)) > 512 {
|
||||
fail(c, 400, "品种资料长度无效")
|
||||
return model.Breed{}, false
|
||||
}
|
||||
if len([]rune(in.Origin)) > 128 || len([]rune(in.BreedGroup)) > 255 || len([]rune(in.Height)) > 128 || len([]rune(in.Weight)) > 128 || len([]rune(in.Summary)) > 2000 || len([]rune(in.Temperament)) > 255 || len([]rune(in.CareNotes)) > 2000 {
|
||||
fail(c, 400, "百科资料长度无效")
|
||||
return model.Breed{}, false
|
||||
}
|
||||
if len([]rune(in.LifeSpan)) > 128 || len([]rune(in.Exercise)) > 255 || len([]rune(in.Grooming)) > 255 || len(in.FCINumber) > 16 {
|
||||
fail(c, 400, "照顾资料长度无效")
|
||||
return model.Breed{}, false
|
||||
}
|
||||
validTraits := map[string]bool{"运动需求": true, "运动活力": true, "训练接受度": true, "掉毛程度": true, "吠叫倾向": true, "梳毛需求": true, "亲近家人": true}
|
||||
for key, value := range in.Traits {
|
||||
if !validTraits[key] || value < 1 || value > 5 {
|
||||
fail(c, 400, "指数需要为 1–5")
|
||||
return model.Breed{}, false
|
||||
}
|
||||
}
|
||||
scores, _ := json.Marshal(in.Traits)
|
||||
full, short, initial := breeds.SortFields(in.Name)
|
||||
return model.Breed{LifeSpan: strings.TrimSpace(in.LifeSpan), Exercise: strings.TrimSpace(in.Exercise), Grooming: strings.TrimSpace(in.Grooming), FCINumber: strings.TrimSpace(in.FCINumber), Traits: string(scores), Origin: strings.TrimSpace(in.Origin), BreedGroup: strings.TrimSpace(in.BreedGroup), Height: strings.TrimSpace(in.Height), Weight: strings.TrimSpace(in.Weight), Summary: strings.TrimSpace(in.Summary), Temperament: strings.TrimSpace(in.Temperament), CareNotes: strings.TrimSpace(in.CareNotes), Name: in.Name, EnglishName: in.EnglishName, Aliases: in.Aliases, Pinyin: full, PinyinInitials: short, Initial: initial, Enabled: *in.Enabled}, true
|
||||
}
|
||||
func breedWriteError(c *gin.Context, err error) {
|
||||
// pgx PostgreSQL unique_violation, without disclosing DB details.
|
||||
type sqlState interface{ SQLState() string }
|
||||
var state sqlState
|
||||
if errors.As(err, &state) && state.SQLState() == "23505" {
|
||||
fail(c, http.StatusConflict, "该品种名称已存在")
|
||||
return
|
||||
}
|
||||
fail(c, 500, "保存品种失败")
|
||||
}
|
||||
func (a *API) createBreed(c *gin.Context) {
|
||||
row, valid := bindBreed(c)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
row.Source = "管理端录入"
|
||||
row.EditorialLocked = true
|
||||
row.EncyclopediaUpdatedAt = time.Now().Format("2006-01-02")
|
||||
// GORM defaults true for zero values on Create; explicitly select false after insertion.
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
enabled := row.Enabled
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled {
|
||||
row.Enabled = false
|
||||
return tx.Model(&row).Update("enabled", false).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
breedWriteError(c, err)
|
||||
return
|
||||
}
|
||||
created(c, row)
|
||||
}
|
||||
func (a *API) updateBreed(c *gin.Context) {
|
||||
if !validID(c.Param("id")) {
|
||||
fail(c, 404, "品种不存在")
|
||||
return
|
||||
}
|
||||
update, valid := bindBreed(c)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
var row model.Breed
|
||||
if a.db.First(&row, "id = ?", c.Param("id")).Error != nil {
|
||||
fail(c, 404, "品种不存在")
|
||||
return
|
||||
}
|
||||
if row.FieldSources == nil {
|
||||
row.FieldSources = map[string]string{}
|
||||
}
|
||||
before := map[string]string{"name": row.Name, "english_name": row.EnglishName, "aliases": row.Aliases, "origin": row.Origin, "breed_group": row.BreedGroup, "height": row.Height, "weight": row.Weight, "summary": row.Summary, "temperament": row.Temperament, "care_notes": row.CareNotes, "life_span": row.LifeSpan, "exercise": row.Exercise, "grooming": row.Grooming, "fci_number": row.FCINumber, "traits": row.Traits}
|
||||
after := map[string]string{"name": update.Name, "english_name": update.EnglishName, "aliases": update.Aliases, "origin": update.Origin, "breed_group": update.BreedGroup, "height": update.Height, "weight": update.Weight, "summary": update.Summary, "temperament": update.Temperament, "care_notes": update.CareNotes, "life_span": update.LifeSpan, "exercise": update.Exercise, "grooming": update.Grooming, "fci_number": update.FCINumber, "traits": update.Traits}
|
||||
var previousTraits map[string]int
|
||||
if json.Unmarshal([]byte(row.Traits), &previousTraits) == nil {
|
||||
normalized, _ := json.Marshal(previousTraits)
|
||||
before["traits"] = string(normalized)
|
||||
}
|
||||
for key, value := range after {
|
||||
if value != before[key] && !(key == "traits" && value == "null") {
|
||||
row.FieldSources[key] = "editorial"
|
||||
}
|
||||
}
|
||||
row.Origin, row.BreedGroup, row.Height, row.Weight, row.Summary, row.Temperament, row.CareNotes = update.Origin, update.BreedGroup, update.Height, update.Weight, update.Summary, update.Temperament, update.CareNotes
|
||||
row.LifeSpan, row.Exercise, row.Grooming, row.FCINumber = update.LifeSpan, update.Exercise, update.Grooming, update.FCINumber
|
||||
if update.Traits != "null" {
|
||||
row.Traits = update.Traits
|
||||
}
|
||||
row.EditorialLocked = true
|
||||
row.EncyclopediaUpdatedAt = time.Now().Format("2006-01-02")
|
||||
row.Name, row.EnglishName, row.Aliases = update.Name, update.EnglishName, update.Aliases
|
||||
row.Pinyin, row.PinyinInitials, row.Initial, row.Enabled = update.Pinyin, update.PinyinInitials, update.Initial, update.Enabled
|
||||
if err := a.db.Save(&row).Error; err != nil {
|
||||
breedWriteError(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, row)
|
||||
}
|
||||
func (a *API) setBreedEnabled(c *gin.Context) {
|
||||
var in struct {
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil {
|
||||
fail(c, 400, "状态参数无效")
|
||||
return
|
||||
}
|
||||
result := a.db.Model(&model.Breed{}).Where("id = ?", c.Param("id")).Update("enabled", *in.Enabled)
|
||||
if result.Error != nil {
|
||||
fail(c, 500, "更新状态失败")
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
fail(c, 404, "品种不存在")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"id": c.Param("id"), "enabled": *in.Enabled})
|
||||
}
|
||||
|
||||
func (a *API) breedDetail(c *gin.Context) {
|
||||
var row model.Breed
|
||||
if !validID(c.Param("id")) || a.db.Preload("References").Where("id = ? AND enabled = true", c.Param("id")).First(&row).Error != nil {
|
||||
fail(c, 404, "品种不存在或已停用")
|
||||
return
|
||||
}
|
||||
ok(c, row)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sundynix/pets-be/internal/breeds"
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/database"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"gorm.io/gorm"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func featureDB(t *testing.T) (*gorm.DB, *config.Config) {
|
||||
t.Helper()
|
||||
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
|
||||
t.Skip("PostgreSQL integration disabled")
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := database.Open(cfg.Postgres, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
t.Cleanup(func() { sqlDB.Close() })
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatal(tx.Error)
|
||||
}
|
||||
t.Cleanup(func() { tx.Rollback() })
|
||||
return tx, cfg
|
||||
}
|
||||
func featureCall(handler gin.HandlerFunc, method, path string, body any, actorID, id string) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(body)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(method, path, bytes.NewReader(raw))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set("actor_id", actorID)
|
||||
c.Params = gin.Params{{Key: "id", Value: id}}
|
||||
handler(c)
|
||||
return rec
|
||||
}
|
||||
func pairFrom(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
if rec.Code != 200 && rec.Code != 201 {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var result struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result.Data
|
||||
}
|
||||
func TestAccessTokenTwoHours(t *testing.T) {
|
||||
api := New(nil, nil, &config.Config{JWT: config.JWTConfig{Secret: "test", ExpireHours: 168}})
|
||||
raw, err := api.token(uuid.NewString(), "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &claims{}
|
||||
_, err = jwt.ParseWithClaims(raw, c, func(*jwt.Token) (any, error) { return []byte("test"), nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.ExpiresAt.Sub(c.IssuedAt.Time) != 2*time.Hour {
|
||||
t.Fatal("access token lifetime is not two hours")
|
||||
}
|
||||
}
|
||||
func TestRefreshRotationPostgres(t *testing.T) {
|
||||
tx, cfg := featureDB(t)
|
||||
a := New(tx, nil, cfg)
|
||||
user := model.User{OpenID: "refresh-test:" + uuid.NewString(), Nickname: "测试家长"}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pair, err := a.newSession(user.ID, "user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := pair["refresh_token"].(string)
|
||||
var session model.RefreshSession
|
||||
if err := tx.Where("token_hash = ?", refreshHash(old)).First(&session).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.TokenHash == old {
|
||||
t.Fatal("raw refresh stored")
|
||||
}
|
||||
call := func(raw, role string) *httptest.ResponseRecorder {
|
||||
return featureCall(a.refreshToken(role), "POST", "/refresh", gin.H{"refresh_token": raw}, "", "")
|
||||
}
|
||||
if rec := call(old, "admin"); rec.Code != 401 {
|
||||
t.Fatal("user credential accepted for admin")
|
||||
}
|
||||
next := pairFrom(t, call(old, "user"))
|
||||
fresh := next["refresh_token"].(string)
|
||||
if fresh == old || next["refresh_expires_at"].(float64) < float64(time.Now().Add(2*time.Hour-time.Second).Unix()) {
|
||||
t.Fatal("rotation or two-hour expiry wrong")
|
||||
}
|
||||
if rec := call(old, "user"); rec.Code != 401 {
|
||||
t.Fatal("old credential reusable")
|
||||
}
|
||||
revoked := featureCall(a.revokeRefresh("user"), "POST", "/logout", gin.H{"refresh_token": fresh}, "", "")
|
||||
if revoked.Code != 200 {
|
||||
t.Fatal(revoked.Body.String())
|
||||
}
|
||||
if rec := call(fresh, "user"); rec.Code != 401 {
|
||||
t.Fatal("logout failed to revoke")
|
||||
}
|
||||
// Logout invalidates the access token tied to the same session too.
|
||||
r := gin.New()
|
||||
r.GET("/protected", a.requireRole("user"), func(c *gin.Context) { ok(c, true) })
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+next["token"].(string))
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != 401 {
|
||||
t.Fatal("revoked session access accepted")
|
||||
}
|
||||
pair, err = a.newSession(user.ID, "user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh = pair["refresh_token"].(string)
|
||||
if err := tx.Model(&user).Update("disabled", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := call(fresh, "user"); rec.Code != 401 {
|
||||
t.Fatal("disabled user refreshed")
|
||||
}
|
||||
if err := tx.Model(&user).Update("disabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx.Model(&model.RefreshSession{}).Where("token_hash = ?", refreshHash(fresh)).Update("expires_at", time.Now().Add(-time.Minute))
|
||||
if rec := call(fresh, "user"); rec.Code != 401 {
|
||||
t.Fatal("expired credential refreshed")
|
||||
}
|
||||
admin := model.Admin{Username: "refresh-test:" + uuid.NewString(), PasswordHash: "fixture"}
|
||||
if err := tx.Create(&admin).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pair, err = a.newSession(admin.ID, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pairFrom(t, call(pair["refresh_token"].(string), "admin"))
|
||||
}
|
||||
func TestBreedCatalogPostgres(t *testing.T) {
|
||||
tx, cfg := featureDB(t)
|
||||
a := New(tx, nil, cfg)
|
||||
if added, err := breeds.Seed(tx); err != nil || added != 0 {
|
||||
t.Fatalf("idempotent seed: %d %v", added, err)
|
||||
}
|
||||
var source model.Breed
|
||||
if err := tx.Where("name = ?", "彭布罗克威尔士柯基").First(&source).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx.Model(&source).Updates(map[string]any{"enabled": false, "name": "改名柯基测试"})
|
||||
if added, err := breeds.Seed(tx); err != nil || added != 0 {
|
||||
t.Fatalf("seed restored renamed record: %d %v", added, err)
|
||||
}
|
||||
tx.First(&source, "id = ?", source.ID)
|
||||
if source.Enabled || source.Name != "改名柯基测试" {
|
||||
t.Fatal("seed overwrote edits")
|
||||
}
|
||||
rec := featureCall(a.listBreeds, "GET", "/breeds?q=bianmu", nil, "", "")
|
||||
if rec.Code != 200 {
|
||||
t.Fatal(rec.Body.String())
|
||||
}
|
||||
// Short initials and Chinese aliases must both work.
|
||||
for _, q := range []string{"边牧", "bjm", "bianjing"} {
|
||||
rec = featureCall(a.listBreeds, "GET", "/breeds?q="+q, nil, "", "")
|
||||
if rec.Code != 200 || !bytes.Contains(rec.Body.Bytes(), []byte("边境牧羊犬")) {
|
||||
t.Fatalf("search %s: %s", q, rec.Body)
|
||||
}
|
||||
}
|
||||
name := "测试品种" + uuid.NewString()
|
||||
body := gin.H{"name": name, "aliases": "testbreed", "enabled": false}
|
||||
created := pairFrom(t, featureCall(a.createBreed, "POST", "/breeds", body, "", ""))
|
||||
id := created["id"].(string)
|
||||
if created["enabled"].(bool) {
|
||||
t.Fatal("disabled creation ignored")
|
||||
}
|
||||
pairFrom(t, featureCall(a.setBreedEnabled, "PUT", "/breeds", gin.H{"enabled": true}, "", id))
|
||||
body["enabled"] = true
|
||||
body["name"] = name + "更新"
|
||||
pairFrom(t, featureCall(a.updateBreed, "PUT", "/breeds", body, "", id))
|
||||
// Duplicate request runs in a savepoint because PostgreSQL aborts a transaction on unique violations.
|
||||
tx.SavePoint("duplicate_breed")
|
||||
rec = featureCall(a.createBreed, "POST", "/breeds", body, "", "")
|
||||
if rec.Code != 409 {
|
||||
t.Fatalf("duplicate status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
tx.RollbackTo("duplicate_breed")
|
||||
user := model.User{OpenID: "breed-test:" + uuid.NewString(), Nickname: "测试家长"}
|
||||
tx.Create(&user)
|
||||
selected, valid := a.buildPet(testPetContext(user.ID, ""), petInput{Name: "测试毛孩", Breed: "forged", BreedID: &id})
|
||||
if !valid || selected.Breed != body["name"] {
|
||||
t.Fatal("selected name not resolved by ID")
|
||||
}
|
||||
custom, valid := a.buildPet(testPetContext(user.ID, ""), petInput{Name: "测试毛孩", Breed: "未收录地方犬"})
|
||||
if !valid || custom.BreedID != nil || custom.Breed != "未收录地方犬" {
|
||||
t.Fatal("manual breed unavailable")
|
||||
}
|
||||
selected.UserID = user.ID
|
||||
tx.Create(&selected)
|
||||
tx.Model(&model.Breed{}).Where("id = ?", id).Update("enabled", false)
|
||||
_, valid = a.buildPet(testPetContext(user.ID, ""), petInput{Name: "新宠物", BreedID: &id})
|
||||
if valid {
|
||||
t.Fatal("disabled breed newly selected")
|
||||
}
|
||||
_, valid = a.buildPet(testPetContext(user.ID, selected.ID), petInput{Name: "旧宠物", BreedID: &id})
|
||||
if !valid {
|
||||
t.Fatal("existing disabled breed cannot be retained")
|
||||
}
|
||||
}
|
||||
func testPetContext(actorID, id string) *gin.Context {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Set("actor_id", actorID)
|
||||
c.Params = gin.Params{{Key: "id", Value: id}}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestBreedSourcesAndEditorialProtection(t *testing.T) {
|
||||
tx, cfg := featureDB(t)
|
||||
a := New(tx, nil, cfg)
|
||||
var breed model.Breed
|
||||
if err := tx.Where("name = ?", "彭布罗克威尔士柯基").First(&breed).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail := pairFrom(t, featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID))
|
||||
if len(detail["references"].([]any)) != 4 {
|
||||
t.Fatal("Corgi references missing")
|
||||
}
|
||||
if detail["field_sources"].(map[string]any)["exercise"] != "rkc" {
|
||||
t.Fatal("exercise attribution wrong")
|
||||
}
|
||||
body := gin.H{"name": breed.Name, "english_name": breed.EnglishName, "enabled": true, "exercise": "编辑安排的散步活动", "summary": "新的中文介绍", "traits": gin.H{"运动活力": 2}}
|
||||
pairFrom(t, featureCall(a.updateBreed, "PUT", "/breeds/"+breed.ID, body, "", breed.ID))
|
||||
if _, err := breeds.Seed(tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail = pairFrom(t, featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID))
|
||||
if detail["exercise"] != "编辑安排的散步活动" || detail["summary"] != "新的中文介绍" || detail["height"] != "" {
|
||||
t.Fatal("import overwrote manual edits or cleared fields")
|
||||
}
|
||||
if detail["field_sources"].(map[string]any)["exercise"] != "editorial" || len(detail["references"].([]any)) != 4 {
|
||||
t.Fatal("edited provenance or reference uniqueness lost")
|
||||
}
|
||||
body["traits"] = gin.H{"运动活力": 6}
|
||||
if rec := featureCall(a.updateBreed, "PUT", "/breeds/"+breed.ID, body, "", breed.ID); rec.Code != 400 {
|
||||
t.Fatalf("invalid rating accepted: %d", rec.Code)
|
||||
}
|
||||
tx.Model(&model.Breed{}).Where("id = ?", breed.ID).Update("enabled", false)
|
||||
if rec := featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID); rec.Code != 404 {
|
||||
t.Fatal("disabled encyclopedia visible")
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (a *API) petView(pet model.Pet) gin.H {
|
||||
if pet.BirthDate != nil {
|
||||
birthDate = pet.BirthDate.Format("2006-01-02")
|
||||
}
|
||||
return gin.H{"id": pet.ID, "name": pet.Name, "species": pet.Species, "breed": pet.Breed,
|
||||
return gin.H{"id": pet.ID, "name": pet.Name, "species": pet.Species, "breed": pet.Breed, "breed_id": pet.BreedID,
|
||||
"gender": pet.Gender, "birth_date": birthDate, "weight_kg": pet.WeightKg,
|
||||
"avatar_file_id": pet.AvatarFileID, "avatar_url": a.fileURL(pet.AvatarFileID),
|
||||
"coat": pet.Coat, "allergies": pet.Allergies, "care_notes": pet.CareNotes,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/database"
|
||||
)
|
||||
|
||||
func TestDefaultNicknamePostgres(t *testing.T) {
|
||||
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
|
||||
t.Skip("PostgreSQL integration disabled")
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := database.Open(cfg.Postgres, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
defer sqlDB.Close()
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatal(tx.Error)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
api := New(tx, nil, cfg)
|
||||
seen := map[string]bool{}
|
||||
for range 12 {
|
||||
openID := "nickname-test:" + uuid.NewString()
|
||||
user, err := api.findOrCreateUser(openID, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !regexp.MustCompile(`^毛孩家长\d{4,6}$`).MatchString(user.Nickname) {
|
||||
t.Fatalf("unexpected name %q", user.Nickname)
|
||||
}
|
||||
if seen[user.Nickname] {
|
||||
t.Fatalf("duplicate name %q", user.Nickname)
|
||||
}
|
||||
seen[user.Nickname] = true
|
||||
again, err := api.findOrCreateUser(openID, "不应覆盖的名字")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.ID != user.ID || again.Nickname != user.Nickname {
|
||||
t.Fatal("relogin changed existing user")
|
||||
}
|
||||
}
|
||||
custom, err := api.findOrCreateUser("nickname-test:"+uuid.NewString(), "小豆的家长")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if custom.Nickname != "小豆的家长" {
|
||||
t.Fatal("custom nickname lost")
|
||||
}
|
||||
again, err := api.findOrCreateUser(custom.OpenID, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.Nickname != custom.Nickname {
|
||||
t.Fatal("relogin replaced custom nickname")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ type petInput struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Species string `json:"species"`
|
||||
Breed string `json:"breed"`
|
||||
BreedID *string `json:"breed_id"`
|
||||
Gender string `json:"gender"`
|
||||
BirthDate string `json:"birth_date"`
|
||||
WeightKg float64 `json:"weight_kg"`
|
||||
@@ -81,6 +82,7 @@ func (a *API) updatePet(c *gin.Context) {
|
||||
}
|
||||
pet.Name, pet.Species, pet.Breed, pet.Gender = updated.Name, updated.Species, updated.Breed, updated.Gender
|
||||
pet.BirthDate, pet.WeightKg, pet.AvatarFileID = updated.BirthDate, updated.WeightKg, updated.AvatarFileID
|
||||
pet.BreedID = updated.BreedID
|
||||
pet.Coat, pet.Allergies, pet.CareNotes = updated.Coat, updated.Allergies, updated.CareNotes
|
||||
if err := a.db.Save(&pet).Error; err != nil {
|
||||
fail(c, http.StatusInternalServerError, "更新宠物失败")
|
||||
@@ -110,6 +112,27 @@ func (a *API) buildPet(c *gin.Context, in petInput) (model.Pet, bool) {
|
||||
return pet, false
|
||||
}
|
||||
}
|
||||
in.Breed = strings.TrimSpace(in.Breed)
|
||||
if len([]rune(in.Breed)) > 128 {
|
||||
fail(c, http.StatusBadRequest, "品种名称过长")
|
||||
return pet, false
|
||||
}
|
||||
if in.BreedID != nil {
|
||||
var breed model.Breed
|
||||
if !validID(*in.BreedID) || a.db.First(&breed, "id = ?", *in.BreedID).Error != nil {
|
||||
fail(c, http.StatusBadRequest, "品种不存在,请重新选择或手动填写")
|
||||
return pet, false
|
||||
}
|
||||
// A disabled breed can be retained on an existing pet, but not newly selected.
|
||||
if !breed.Enabled {
|
||||
var current model.Pet
|
||||
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ? AND breed_id = ?", c.Param("id"), actor(c), *in.BreedID).First(¤t).Error != nil {
|
||||
fail(c, http.StatusBadRequest, "该品种已停用,请手动填写或重新选择")
|
||||
return pet, false
|
||||
}
|
||||
}
|
||||
in.Breed = breed.Name
|
||||
}
|
||||
var birthday *time.Time
|
||||
if in.BirthDate != "" {
|
||||
d, err := parseDay(in.BirthDate)
|
||||
@@ -120,7 +143,7 @@ func (a *API) buildPet(c *gin.Context, in petInput) (model.Pet, bool) {
|
||||
birthday = &d
|
||||
}
|
||||
pet = model.Pet{Name: in.Name, Species: in.Species, Breed: strings.TrimSpace(in.Breed),
|
||||
Gender: in.Gender, BirthDate: birthday, WeightKg: in.WeightKg,
|
||||
BreedID: in.BreedID, Gender: in.Gender, BirthDate: birthday, WeightKg: in.WeightKg,
|
||||
AvatarFileID: in.AvatarFileID, Coat: in.Coat, Allergies: in.Allergies, CareNotes: in.CareNotes}
|
||||
return pet, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var errRefreshInvalid = errors.New("refresh credential invalid")
|
||||
|
||||
func refreshCredential() (string, error) {
|
||||
data := make([]byte, 32)
|
||||
if _, err := rand.Read(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(data), nil
|
||||
}
|
||||
func refreshHash(value string) string {
|
||||
hash := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
func (a *API) sessionPair(id, role, sessionID, refresh string, expires time.Time) (gin.H, error) {
|
||||
token, err := a.sessionToken(id, role, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"token": token, "refresh_token": refresh, "expires_in": 7200, "expires_at": time.Now().Add(2 * time.Hour).Unix(), "refresh_expires_at": expires.Unix()}, nil
|
||||
}
|
||||
func (a *API) newSession(id, role string) (gin.H, error) {
|
||||
raw, err := refreshCredential()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expires := time.Now().Add(2 * time.Hour)
|
||||
sessionID := uuid.NewString()
|
||||
pair, err := a.sessionPair(id, role, sessionID, raw, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Opportunistic cleanup bounded by indexed expiry, without a background scheduler.
|
||||
if err := a.db.Where("expires_at <= ?", time.Now()).Delete(&model.RefreshSession{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = a.db.Create(&model.RefreshSession{Base: model.Base{ID: sessionID}, ActorID: id, Role: role, TokenHash: refreshHash(raw), ExpiresAt: expires}).Error
|
||||
return pair, err
|
||||
}
|
||||
func readRefresh(c *gin.Context) (string, bool) {
|
||||
var in struct {
|
||||
Token string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
if c.ShouldBindJSON(&in) != nil || len(in.Token) != 43 {
|
||||
fail(c, http.StatusUnauthorized, "登录已过期,请重新登录")
|
||||
return "", false
|
||||
}
|
||||
return in.Token, true
|
||||
}
|
||||
func (a *API) refreshToken(role string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw, valid := readRefresh(c)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
var pair gin.H
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
var session model.RefreshSession
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("token_hash = ? AND role = ?", refreshHash(raw), role).First(&session).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errRefreshInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if session.Revoked || !session.ExpiresAt.After(time.Now()) {
|
||||
return errRefreshInvalid
|
||||
}
|
||||
if role == "user" {
|
||||
var user model.User
|
||||
err = tx.Select("id", "disabled").First(&user, "id = ?", session.ActorID).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || (err == nil && user.Disabled) {
|
||||
return errRefreshInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
var count int64
|
||||
if err := tx.Model(&model.Admin{}).Where("id = ?", session.ActorID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return errRefreshInvalid
|
||||
}
|
||||
}
|
||||
next, err := refreshCredential()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nextExpiry := time.Now().Add(2 * time.Hour)
|
||||
pair, err = a.sessionPair(session.ActorID, role, session.ID, next, nextExpiry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Rotate inside the lock. The old credential can only succeed once.
|
||||
return tx.Model(&session).Updates(map[string]any{"token_hash": refreshHash(next), "expires_at": nextExpiry}).Error
|
||||
})
|
||||
if errors.Is(err, errRefreshInvalid) {
|
||||
fail(c, 401, "登录已过期,请重新登录")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
fail(c, 500, "暂时无法刷新登录,请重试")
|
||||
return
|
||||
}
|
||||
ok(c, pair)
|
||||
}
|
||||
}
|
||||
func (a *API) revokeRefresh(role string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw, valid := readRefresh(c)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
if err := a.db.Model(&model.RefreshSession{}).Where("token_hash = ? AND role = ?", refreshHash(raw), role).Update("revoked", true).Error; err != nil {
|
||||
fail(c, 500, "退出登录失败")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"logged_out": true})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -54,6 +60,7 @@ func (a *API) Register(r *gin.Engine) {
|
||||
user.PUT("/tasks/:id", a.updateTask)
|
||||
user.DELETE("/tasks/:id", a.deleteTask)
|
||||
user.PUT("/tasks/:id/completion", a.setTaskCompletion)
|
||||
user.PUT("/tasks/completions", a.completeTasks)
|
||||
user.GET("/walks", a.listWalks)
|
||||
user.POST("/walks", a.startWalk)
|
||||
user.GET("/walks/:id", a.getWalk)
|
||||
@@ -71,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)
|
||||
@@ -86,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))
|
||||
}
|
||||
@@ -126,6 +135,20 @@ func (a *API) requireRole(role string) gin.HandlerFunc {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// Reject credentials issued with the old seven-day lifetime.
|
||||
if cl.IssuedAt == nil || cl.ExpiresAt == nil || cl.ExpiresAt.Sub(cl.IssuedAt.Time) > 2*time.Hour {
|
||||
fail(c, http.StatusUnauthorized, "登录已过期")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if cl.SessionID != "" {
|
||||
var count int64
|
||||
if !validID(cl.SessionID) || a.db.Model(&model.RefreshSession{}).Where("id = ? AND actor_id = ? AND role = ? AND revoked = false AND expires_at > ?", cl.SessionID, cl.Subject, role, time.Now()).Count(&count).Error != nil || count != 1 {
|
||||
fail(c, http.StatusUnauthorized, "登录已过期")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
if role == "user" {
|
||||
var user model.User
|
||||
if err := a.db.Select("id", "disabled").First(&user, "id = ?", cl.Subject).Error; err != nil || user.Disabled {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
@@ -15,7 +16,8 @@ import (
|
||||
var clockPattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
|
||||
|
||||
type taskInput struct {
|
||||
PetID string `json:"pet_id" binding:"required"`
|
||||
PetID string `json:"pet_id"`
|
||||
AllPets bool `json:"all_pets"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
Category string `json:"category"`
|
||||
TimeOfDay string `json:"time_of_day"`
|
||||
@@ -49,7 +51,7 @@ func (a *API) listTasks(c *gin.Context) {
|
||||
fail(c, http.StatusNotFound, "宠物不存在")
|
||||
return
|
||||
}
|
||||
query = query.Where("pet_id = ?", petID)
|
||||
query = query.Where("(pet_id = ? OR pet_id IS NULL)", petID)
|
||||
}
|
||||
var tasks []model.CareTask
|
||||
if err := query.Order("time_of_day ASC").Find(&tasks).Error; err != nil {
|
||||
@@ -75,7 +77,7 @@ func (a *API) listTasks(c *gin.Context) {
|
||||
}
|
||||
|
||||
func taskView(task model.CareTask, completed bool) gin.H {
|
||||
return gin.H{"id": task.ID, "pet_id": task.PetID, "title": task.Title,
|
||||
return gin.H{"id": task.ID, "pet_id": task.PetID, "all_pets": task.PetID == nil, "title": task.Title,
|
||||
"category": task.Category, "time_of_day": task.TimeOfDay,
|
||||
"repeat_rule": task.RepeatRule, "reminder_enabled": task.ReminderEnabled,
|
||||
"completed": completed}
|
||||
@@ -83,9 +85,26 @@ func taskView(task model.CareTask, completed bool) gin.H {
|
||||
|
||||
func (a *API) buildTask(c *gin.Context, in taskInput) (model.CareTask, bool) {
|
||||
task := model.CareTask{}
|
||||
if _, err := a.ownedPet(actor(c), in.PetID); err != nil {
|
||||
var petID *string
|
||||
if in.AllPets {
|
||||
if in.PetID != "" {
|
||||
fail(c, http.StatusBadRequest, "全部宠物与单只宠物不能同时选择")
|
||||
return task, false
|
||||
}
|
||||
var count int64
|
||||
if err := a.db.Model(&model.Pet{}).Where("user_id = ? AND archived = false", actor(c)).Count(&count).Error; err != nil {
|
||||
fail(c, http.StatusInternalServerError, "读取宠物失败")
|
||||
return task, false
|
||||
}
|
||||
if count == 0 {
|
||||
fail(c, http.StatusBadRequest, "请先添加宠物")
|
||||
return task, false
|
||||
}
|
||||
} else if _, err := a.ownedPet(actor(c), in.PetID); err != nil {
|
||||
fail(c, http.StatusNotFound, "宠物不存在")
|
||||
return task, false
|
||||
} else {
|
||||
petID = &in.PetID
|
||||
}
|
||||
in.Title = strings.TrimSpace(in.Title)
|
||||
if in.Title == "" || len([]rune(in.Title)) > 128 {
|
||||
@@ -109,7 +128,7 @@ func (a *API) buildTask(c *gin.Context, in taskInput) (model.CareTask, bool) {
|
||||
fail(c, http.StatusBadRequest, "重复规则无效")
|
||||
return task, false
|
||||
}
|
||||
task = model.CareTask{UserID: actor(c), PetID: in.PetID, Title: in.Title,
|
||||
task = model.CareTask{UserID: actor(c), PetID: petID, Title: in.Title,
|
||||
Category: in.Category, TimeOfDay: in.TimeOfDay, RepeatRule: in.RepeatRule,
|
||||
ReminderEnabled: in.ReminderEnabled}
|
||||
return task, true
|
||||
@@ -179,11 +198,6 @@ func (a *API) deleteTask(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *API) setTaskCompletion(c *gin.Context) {
|
||||
var task model.CareTask
|
||||
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error != nil {
|
||||
fail(c, http.StatusNotFound, "任务不存在")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Date string `json:"date" binding:"required"`
|
||||
Completed bool `json:"completed"`
|
||||
@@ -192,20 +206,82 @@ func (a *API) setTaskCompletion(c *gin.Context) {
|
||||
fail(c, http.StatusBadRequest, "完成状态参数无效")
|
||||
return
|
||||
}
|
||||
if !in.Completed {
|
||||
fail(c, http.StatusBadRequest, "已完成的任务不能取消完成")
|
||||
return
|
||||
}
|
||||
var task model.CareTask
|
||||
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error != nil {
|
||||
fail(c, http.StatusNotFound, "任务不存在")
|
||||
return
|
||||
}
|
||||
date, err := parseDay(in.Date)
|
||||
if err != nil || !due(task, date) {
|
||||
fail(c, http.StatusBadRequest, "该日期没有这项任务")
|
||||
return
|
||||
}
|
||||
if in.Completed {
|
||||
item := model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()}
|
||||
if err := a.db.Where("task_id = ? AND date = ?", task.ID, date).FirstOrCreate(&item).Error; err != nil {
|
||||
if err := a.db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "task_id"}, {Name: "date"}}, DoNothing: true}).Create(&item).Error; err != nil {
|
||||
fail(c, http.StatusInternalServerError, "保存完成状态失败")
|
||||
return
|
||||
}
|
||||
} else if err := a.db.Where("task_id = ? AND date = ?", task.ID, date).Delete(&model.TaskCompletion{}).Error; err != nil {
|
||||
fail(c, http.StatusInternalServerError, "取消完成失败")
|
||||
ok(c, gin.H{"id": task.ID, "date": in.Date, "completed": true})
|
||||
}
|
||||
|
||||
// Complete the displayed tasks atomically, allowing safe retries and concurrent taps.
|
||||
func (a *API) completeTasks(c *gin.Context) {
|
||||
var in struct {
|
||||
Date string `json:"date" binding:"required"`
|
||||
TaskIDs []string `json:"task_ids" binding:"required,min=1,max=200"`
|
||||
}
|
||||
if c.ShouldBindJSON(&in) != nil {
|
||||
fail(c, http.StatusBadRequest, "请选择 1 至 200 项任务")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"id": task.ID, "date": in.Date, "completed": in.Completed})
|
||||
date, err := parseDay(in.Date)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "日期格式应为 YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(in.TaskIDs))
|
||||
seen := map[string]bool{}
|
||||
for _, id := range in.TaskIDs {
|
||||
if !validID(id) {
|
||||
fail(c, http.StatusBadRequest, "任务 ID 无效")
|
||||
return
|
||||
}
|
||||
if !seen[id] {
|
||||
ids = append(ids, id)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
invalid := false
|
||||
err = a.db.Transaction(func(tx *gorm.DB) error {
|
||||
var tasks []model.CareTask
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ? AND user_id = ?", ids, actor(c)).Order("id").Find(&tasks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tasks) != len(ids) {
|
||||
invalid = true
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
items := make([]model.TaskCompletion, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if !due(task, date) {
|
||||
invalid = true
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
items = append(items, model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()})
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "task_id"}, {Name: "date"}}, DoNothing: true}).Create(&items).Error
|
||||
})
|
||||
if err != nil {
|
||||
if invalid {
|
||||
fail(c, http.StatusBadRequest, "包含不存在或该日期无需完成的任务")
|
||||
} else {
|
||||
fail(c, http.StatusInternalServerError, "完成任务失败,请重试")
|
||||
}
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"task_ids": ids, "date": in.Date, "completed": true})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/database"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
func TestTaskCompletionCannotBeUndone(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, "/completion", bytes.NewBufferString(`{"date":"2026-10-05","completed":false}`))
|
||||
api := New(nil, nil, &config.Config{})
|
||||
api.setTaskCompletion(c)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("undo status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// All fixtures and writes are rolled back, including inner batch transactions.
|
||||
func TestTaskCompletionPostgres(t *testing.T) {
|
||||
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
|
||||
t.Skip("PostgreSQL integration disabled")
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := database.Open(cfg.Postgres, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
defer sqlDB.Close()
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatal(tx.Error)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// Verify the nullable pet association migration without changing live schema.
|
||||
if err := tx.AutoMigrate(&model.CareTask{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := model.User{OpenID: "completion-test:" + uuid.NewString(), Nickname: "测试家长"}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pet := model.Pet{UserID: user.ID, Name: "测试毛孩", Species: "dog", Gender: "male"}
|
||||
if err := tx.Create(&pet).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tasks := []model.CareTask{{UserID: user.ID, PetID: &pet.ID, Title: "喝水", Category: "water", TimeOfDay: "08:00", RepeatRule: "daily"}, {UserID: user.ID, PetID: &pet.ID, Title: "喂食", Category: "food", TimeOfDay: "09:00", RepeatRule: "daily"}}
|
||||
if err := tx.Create(&tasks).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
api := New(tx, nil, cfg)
|
||||
request := func(method, route, id string, body gin.H, handler gin.HandlerFunc) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
encoded, _ := json.Marshal(body)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Set("actor_id", user.ID)
|
||||
c.Params = gin.Params{{Key: "id", Value: id}}
|
||||
c.Request = httptest.NewRequest(method, route, bytes.NewReader(encoded))
|
||||
handler(c)
|
||||
return rec
|
||||
}
|
||||
allBody := gin.H{"all_pets": true, "title": "全部换水", "category": "water", "time_of_day": "10:00", "repeat_rule": "daily"}
|
||||
rec := request("POST", "/tasks", "", allBody, api.createTask)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("all-pet creation: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var allTask model.CareTask
|
||||
if err := tx.Where("user_id = ? AND title = ?", user.ID, "全部换水").First(&allTask).Error; err != nil || allTask.PetID != nil {
|
||||
t.Fatalf("all-pet association should be null: %v", err)
|
||||
}
|
||||
// Single-pet filters include shared tasks; other users' tasks remain isolated.
|
||||
rec = request("GET", "/tasks?date=2026-10-05&pet_id="+pet.ID, "", nil, api.listTasks)
|
||||
var listing struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
AllPets bool `json:"all_pets"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &listing); err != nil || rec.Code != 200 || len(listing.Data.Items) != 3 {
|
||||
t.Fatalf("pet filter omitted shared task: %s", rec.Body.String())
|
||||
}
|
||||
if rec = request("PUT", "/tasks/"+allTask.ID, allTask.ID, gin.H{"pet_id": pet.ID, "title": "单只换水"}, api.updateTask); rec.Code != 200 {
|
||||
t.Fatalf("switch to single pet: %s", rec.Body.String())
|
||||
}
|
||||
if rec = request("PUT", "/tasks/"+allTask.ID, allTask.ID, allBody, api.updateTask); rec.Code != 200 {
|
||||
t.Fatalf("switch to all pets: %s", rec.Body.String())
|
||||
}
|
||||
conflicting := gin.H{"all_pets": true, "pet_id": pet.ID, "title": "冲突选择"}
|
||||
if rec = request("POST", "/tasks", "", conflicting, api.createTask); rec.Code != 400 {
|
||||
t.Fatalf("ambiguous selection accepted: %s", rec.Body.String())
|
||||
}
|
||||
call := func(ids []string) int {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(gin.H{"date": "2026-10-05", "task_ids": ids})
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Set("actor_id", user.ID)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, "/tasks/completions", bytes.NewReader(body))
|
||||
api.completeTasks(c)
|
||||
return rec.Code
|
||||
}
|
||||
if status := call([]string{tasks[0].ID, uuid.NewString()}); status != 400 {
|
||||
t.Fatalf("invalid batch status %d", status)
|
||||
}
|
||||
var count int64
|
||||
tx.Model(&model.TaskCompletion{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("invalid batch partially completed")
|
||||
}
|
||||
ids := []string{tasks[0].ID, tasks[1].ID}
|
||||
for range 2 {
|
||||
if status := call(ids); status != 200 {
|
||||
t.Fatalf("batch status %d", status)
|
||||
}
|
||||
}
|
||||
tx.Model(&model.TaskCompletion{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("retry duplicated completions: %d", count)
|
||||
}
|
||||
if status := call([]string{allTask.ID}); status != 200 {
|
||||
t.Fatalf("all-pet completion status %d", status)
|
||||
}
|
||||
tx.Model(&model.TaskCompletion{}).Where("task_id = ?", allTask.ID).Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatalf("shared task should have one completion: %d", count)
|
||||
}
|
||||
}
|
||||
@@ -47,12 +47,64 @@ type Admin struct {
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
}
|
||||
|
||||
// RefreshSession stores only a hash of the rotating refresh credential.
|
||||
type RefreshSession struct {
|
||||
Base
|
||||
ActorID string `gorm:"type:uuid;index;not null" json:"-"`
|
||||
Role string `gorm:"size:16;index;not null" json:"-"`
|
||||
TokenHash string `gorm:"size:64;uniqueIndex;not null" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"index;not null" json:"-"`
|
||||
Revoked bool `gorm:"not null;default:false" json:"-"`
|
||||
}
|
||||
|
||||
type Breed struct {
|
||||
Base
|
||||
LifeSpan string `gorm:"size:128" json:"life_span"`
|
||||
Exercise string `gorm:"size:255" json:"exercise"`
|
||||
Grooming string `gorm:"size:255" json:"grooming"`
|
||||
EditorialLocked bool `gorm:"not null;default:false" json:"editorial_locked"`
|
||||
FieldSources map[string]string `gorm:"serializer:json;type:jsonb" json:"field_sources"`
|
||||
References []BreedReference `gorm:"foreignKey:BreedID" json:"references,omitempty"`
|
||||
Origin string `gorm:"size:128" json:"origin"`
|
||||
BreedGroup string `gorm:"size:255" json:"breed_group"`
|
||||
FCINumber string `gorm:"size:16" json:"fci_number"`
|
||||
Height string `gorm:"size:128" json:"height"`
|
||||
Weight string `gorm:"size:128" json:"weight"`
|
||||
Summary string `gorm:"type:text" json:"summary"`
|
||||
Temperament string `gorm:"size:255" json:"temperament"`
|
||||
Traits string `gorm:"type:text" json:"traits"`
|
||||
CareNotes string `gorm:"type:text" json:"care_notes"`
|
||||
EncyclopediaUpdatedAt string `gorm:"size:10" json:"encyclopedia_updated_at"`
|
||||
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
|
||||
EnglishName string `gorm:"size:128" json:"english_name"`
|
||||
Aliases string `gorm:"size:512" json:"aliases"`
|
||||
Pinyin string `gorm:"size:1024;index;not null" json:"pinyin"`
|
||||
PinyinInitials string `gorm:"size:128" json:"pinyin_initials"`
|
||||
Initial string `gorm:"size:1;index;not null" json:"initial"`
|
||||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||
Source string `gorm:"size:128" json:"source"`
|
||||
SourceKey *string `gorm:"size:128;uniqueIndex" json:"source_key"`
|
||||
SourceURL string `gorm:"size:512" json:"source_url"`
|
||||
}
|
||||
|
||||
// Facts contain short structured facts, never copied articles or remote images.
|
||||
type BreedReference struct {
|
||||
Base
|
||||
BreedID string `gorm:"type:uuid;uniqueIndex:idx_breed_provider;not null" json:"breed_id"`
|
||||
Provider string `gorm:"size:32;uniqueIndex:idx_breed_provider;not null" json:"provider"`
|
||||
ExternalID string `gorm:"size:128;not null" json:"external_id"`
|
||||
URL string `gorm:"size:512;not null" json:"url"`
|
||||
RetrievedAt string `gorm:"size:10;not null" json:"retrieved_at"`
|
||||
Facts map[string]string `gorm:"serializer:json;type:jsonb;not null" json:"facts"`
|
||||
}
|
||||
|
||||
type Pet struct {
|
||||
Base
|
||||
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
Species string `gorm:"size:16;not null;default:dog" json:"species"`
|
||||
Breed string `gorm:"size:64" json:"breed"`
|
||||
Breed string `gorm:"size:128" json:"breed"`
|
||||
BreedID *string `gorm:"type:uuid;index" json:"breed_id"`
|
||||
Gender string `gorm:"size:16" json:"gender"`
|
||||
BirthDate *time.Time `gorm:"type:date" json:"birth_date"`
|
||||
WeightKg float64 `gorm:"type:numeric(6,2)" json:"weight_kg"`
|
||||
@@ -66,7 +118,7 @@ type Pet struct {
|
||||
type CareTask struct {
|
||||
Base
|
||||
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
|
||||
PetID string `gorm:"type:uuid;index;not null" json:"pet_id"`
|
||||
PetID *string `gorm:"type:uuid;index" json:"pet_id"` // nil applies to all of the user's pets.
|
||||
Title string `gorm:"size:128;not null" json:"title"`
|
||||
Category string `gorm:"size:32;not null" json:"category"`
|
||||
TimeOfDay string `gorm:"size:5;not null" json:"time_of_day"`
|
||||
@@ -140,7 +192,7 @@ type MeetupPet struct {
|
||||
|
||||
func All() []any {
|
||||
return []any{
|
||||
&File{}, &User{}, &Admin{}, &Pet{}, &CareTask{}, &TaskCompletion{},
|
||||
&File{}, &User{}, &Admin{}, &RefreshSession{}, &Breed{}, &BreedReference{}, &Pet{}, &CareTask{}, &TaskCompletion{},
|
||||
&Walk{}, &WalkPet{}, &WalkPoint{}, &Meetup{}, &MeetupParticipant{}, &MeetupPet{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Collect official factual metadata. Run from repo root; no third-party packages.
|
||||
HTML is cached outside the repository. No source prose or photos are persisted.
|
||||
Mappings use FCI numbers and exact English names, never fuzzy breed matching.
|
||||
"""
|
||||
import argparse
|
||||
from datetime import date
|
||||
import concurrent.futures
|
||||
import hashlib
|
||||
import html
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unicodedata
|
||||
import urllib.request
|
||||
from urllib.parse import urljoin
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1] / 'internal/breeds'
|
||||
CACHE = Path('/private/tmp/pawprint-breed-sources')
|
||||
CACHE.mkdir(exist_ok=True)
|
||||
DATE = date.today().isoformat()
|
||||
|
||||
class Node:
|
||||
def __init__(self, tag='', attrs=None):
|
||||
self.tag, self.attrs, self.children = tag, dict(attrs or []), []
|
||||
def text(self):
|
||||
return ''.join(c if isinstance(c, str) else c.text() for c in self.children)
|
||||
def all(self):
|
||||
yield self
|
||||
for c in self.children:
|
||||
if isinstance(c, Node):
|
||||
yield from c.all()
|
||||
|
||||
class DOM(HTMLParser):
|
||||
def __init__(self, text):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.root = Node(); self.stack = [self.root]; self.feed(text)
|
||||
def handle_starttag(self, tag, attrs):
|
||||
n = Node(tag, attrs); self.stack[-1].children.append(n)
|
||||
if tag not in ('br','img','hr','meta','link','input','source','wbr','area','embed'):
|
||||
self.stack.append(n)
|
||||
def handle_startendtag(self, tag, attrs):
|
||||
self.stack[-1].children.append(Node(tag,attrs))
|
||||
def handle_endtag(self, tag):
|
||||
for i in range(len(self.stack)-1,0,-1):
|
||||
if self.stack[i].tag == tag:
|
||||
self.stack = self.stack[:i]; return
|
||||
def handle_data(self, data):
|
||||
self.stack[-1].children.append(data)
|
||||
|
||||
def clean(value):
|
||||
return re.sub(r'\s+', ' ', html.unescape(re.sub('<[^>]*>',' ',str(value or '')))).strip()
|
||||
def norm(value):
|
||||
return re.sub('[^a-z0-9]', '', unicodedata.normalize('NFKD', value).encode('ascii','ignore').decode().lower())
|
||||
def fetch(url):
|
||||
file = CACHE / (hashlib.sha256(url.encode()).hexdigest()+'.html')
|
||||
if file.exists() and date.fromtimestamp(file.stat().st_mtime).isoformat()==DATE: return file.read_text()
|
||||
req = urllib.request.Request(url, headers={'User-Agent':'PawprintPlan/1.0 (breed facts)'})
|
||||
with urllib.request.urlopen(req, timeout=20) as r: text = r.read().decode('utf-8','replace')
|
||||
file.write_text(text); return text
|
||||
|
||||
def references(provider, key, url, facts):
|
||||
return {'provider':provider,'external_id':str(key),'url':url,'retrieved_at':DATE,
|
||||
'facts':{k:clean(v) for k,v in facts.items() if v is not None and clean(v)}}
|
||||
|
||||
GROUPS=['牧羊犬与牧牛犬组','宾莎犬、雪纳瑞、獒犬与瑞士山地牧牛犬组','梗犬组','腊肠犬组','尖嘴犬与原始犬种组','嗅觉猎犬与相关犬种组','指示猎犬组','寻回犬、搜寻犬与水猎犬组','伴侣犬与玩具犬组','灵缇(视觉猎犬)组']
|
||||
COUNTRIES={'AUSTRALIA':'澳大利亚','BELGIUM':'比利时','CROATIA':'克罗地亚','CZECHOSLOVAKIA':'捷克斯洛伐克','FRANCE':'法国','GERMANY':'德国','GREAT BRITAIN':'英国','HUNGARY':'匈牙利','ITALY':'意大利','POLAND':'波兰','PORTUGAL':'葡萄牙','ROMANIA':'罗马尼亚','RUSSIA':'俄罗斯','SLOVAKIA':'斯洛伐克','SPAIN':'西班牙','SWITZERLAND':'瑞士','THE NETHERLANDS':'荷兰','NETHERLANDS':'荷兰','AUSTRIA':'奥地利','DENMARK':'丹麦','FINLAND':'芬兰','IRELAND':'爱尔兰','NORWAY':'挪威','SWEDEN':'瑞典','USA':'美国','UNITED STATES OF AMERICA':'美国','CANADA':'加拿大','JAPAN':'日本','CHINA':'中国','KOREA':'韩国','ISRAEL':'以色列','MEXICO':'墨西哥','BRAZIL':'巴西','ARGENTINA':'阿根廷','AFGHANISTAN':'阿富汗','MIDDLE EAST':'中东','TURKEY':'土耳其','THAILAND':'泰国','SOUTH AFRICA':'南非','MOROCCO':'摩洛哥','MALI':'马里','ICELAND':'冰岛','GREENLAND':'格陵兰','MALTA':'马耳他','BOSNIA AND HERZEGOVINA':'波斯尼亚和黑塞哥维那','SERBIA':'塞尔维亚','MONTENEGRO':'黑山','GREECE':'希腊','URUGUAY':'乌拉圭','PERU':'秘鲁','INDONESIA':'印度尼西亚','LITHUANIA':'立陶宛','ESTONIA':'爱沙尼亚','BELGIUM, FRANCE':'比利时、法国','CENTRAL AFRICA':'非洲中部','CUBA':'古巴','CZECH REPUBLIC':'捷克','GERMANY, SWITZERLAND':'德国、瑞士','KOREA (REPUBLIC OF)':'韩国','NORTH MACEDONIA, SERBIA':'北马其顿、塞尔维亚','RUSSIAN FEDERATION':'俄罗斯','SLOVENIA':'斯洛文尼亚','TAIWAN':'中国台湾','TIBET (CHINA)':'中国西藏'}
|
||||
|
||||
def fci_group(link):
|
||||
number, url=link
|
||||
nodes=list(DOM(fetch(url)).root.all()); country=''; records={}
|
||||
for n in nodes:
|
||||
node_id=n.attrs.get('id','')
|
||||
if re.search(r'_PaysLabel_\d+$',node_id): country=re.sub(r'^\d+\.\s*','',clean(n.text()))
|
||||
if n.tag=='a' and 'RaceHyperLink' in node_id:
|
||||
match=re.search(r'\((\d+)\)',n.text())
|
||||
if not match: continue
|
||||
key=match.group(1); title=clean(n.text()); en=re.findall(r'\(([^\d][^)]*)\)',title)
|
||||
name = re.sub(r'-\d+\.html$', '', n.attrs['href'].split('/')[-1]).replace('-', ' ')
|
||||
facts={'english_name':name.title(),'origin':COUNTRIES.get(country,country.title()),'breed_group':GROUPS[number-1],'fci_number':key}
|
||||
records[key]=references('fci',key,urljoin(url,n.attrs['href']),facts)
|
||||
return records
|
||||
|
||||
def akc_page(item):
|
||||
name,url=item
|
||||
nodes=list(DOM(fetch(url)).root.all()); props=None
|
||||
for n in nodes:
|
||||
if n.attrs.get('data-js-component')=='breedPage': props=json.loads(n.attrs['data-js-props']);break
|
||||
if not props: return None
|
||||
data=props['settings']['breed_data']; slug=props['settings']['current_breed']
|
||||
basic=data.get('basics',{}).get(slug,{})
|
||||
standard=data.get('standards',{}).get(slug,{})
|
||||
traits=data.get('traits',{}).get(slug,{})
|
||||
facts={'english_name':name,'life_span':basic.get('life_expectancy'),'height':standard.get('height_display'),'weight':standard.get('weight_display')}
|
||||
scores={}
|
||||
labels={'energy_level':'运动活力','trainability_level':'训练接受度','shedding_level':'掉毛程度','barking_level':'吠叫倾向','coat_grooming_frequency':'梳毛需求','affectionate_with_family':'亲近家人'}
|
||||
for key, label in labels.items():
|
||||
value=traits.get('traits',{}).get(key,{}).get('score')
|
||||
if str(value) in ['1','2','3','4','5']:scores[label]=int(value)
|
||||
if scores:facts['traits']=json.dumps(scores,ensure_ascii=False,separators=(',',':'))
|
||||
words={'alert':'对环境敏锐','affectionate':'喜欢亲近家人','smart':'学习反应快','intelligent':'学习反应快','friendly':'亲人友善','active':'喜欢活动','energetic':'精力较充沛','playful':'喜欢玩耍','loyal':'重视与家人的联系','independent':'有自主性','gentle':'性情较温和','confident':'比较自信','courageous':'胆量较足','curious':'好奇心较强','calm':'较为沉着','outgoing':'乐于互动','devoted':'重视与家人的联系','lively':'喜欢活动','watchful':'对环境敏锐'}
|
||||
tags=list(dict.fromkeys(words[w.strip().lower()] for w in traits.get('temperament','').split('/') if w.strip().lower() in words))
|
||||
if tags:facts['temperament']='、'.join(tags)
|
||||
return references('akc',slug,url,facts)
|
||||
|
||||
def localized(key, value):
|
||||
value=clean(value).replace('(male)','(公犬)').replace('(female)','(母犬)').replace('up to','不超过').replace('years','年').replace('year','年')
|
||||
unit, factor, label = ('inches',2.54,'cm') if key=='height' else ('pounds',0.45359237,'kg')
|
||||
def convert(match):
|
||||
low=f"{float(match.group(1))*factor:.1f}".rstrip('0').rstrip('.')
|
||||
high=("–"+f"{float(match.group(2))*factor:.1f}".rstrip('0').rstrip('.')) if match.group(2) else ''
|
||||
return low+high+' '+label
|
||||
return re.sub(r'(\d+(?:\.\d+)?)(?:\s*[-–]\s*(\d+(?:\.\d+)?))?\s*'+unit,convert,value)
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser();parser.parse_args()
|
||||
rows=json.loads((ROOT/'data.json').read_text())
|
||||
home=list(DOM(fetch('https://www.fci.be/en/nomenclature/')).root.all())
|
||||
links=[]
|
||||
for n in home:
|
||||
if n.tag=='a' and 'GroupesRepeater_GroupeHyperLink' in n.attrs.get('id',''):
|
||||
links.append((int(clean(n.text()).split()[-1]),urljoin('https://www.fci.be',n.attrs['href'])))
|
||||
fci={}
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
|
||||
for result in pool.map(fci_group,links): fci.update(result)
|
||||
print('FCI catalog:',len(fci),flush=True)
|
||||
akc={}
|
||||
for n in DOM(fetch('https://www.akc.org/dog-breeds/')).root.all():
|
||||
if n.tag=='option' and re.fullmatch(r'https://www.akc.org/dog-breeds/[a-z0-9-]+/',n.attrs.get('value','')):
|
||||
akc[norm(clean(n.text()))]=(clean(n.text()),n.attrs['value'])
|
||||
matches={}; entries=[]
|
||||
for row in rows:
|
||||
refs=[];fields={};field_sources={}
|
||||
existing={k:row.get(k,'') for k in ['english_name','origin','breed_group','fci_number','height','weight','temperament','traits'] if row.get(k)}
|
||||
if row['source_key'].startswith('cku:'):
|
||||
refs.append(references('cku',row['source_key'].split(':')[1],row['source_url'],{'name':row['name'],'aliases':row['aliases'],**existing}))
|
||||
for k in existing:field_sources[k]='cku'
|
||||
match=re.match(r'cku:(\d+)',row['source_key']); source=fci.get(match.group(1)) if match else None
|
||||
if source:
|
||||
source=json.loads(json.dumps(source))
|
||||
if re.search(r'[A-Z]$',row['source_key']):source['facts']['scope']='犬种基础标准(包含多个变种)'
|
||||
refs.append(source)
|
||||
for k,v in source['facts'].items():
|
||||
if k not in ['scope'] and not (k=='english_name' and re.search(r'[A-Z]$',row['source_key'])) and not row.get(k): fields[k]=v;field_sources[k]='fci'
|
||||
candidates=[row.get('english_name',''),fields.get('english_name','')]
|
||||
equivalents={'cku:39':'Pembroke Welsh Corgi','cku:38':'Cardigan Welsh Corgi','cku:167':'Cocker Spaniel'}
|
||||
if row['source_key'] in equivalents:candidates.insert(0,equivalents[row['source_key']])
|
||||
# Varieties are deliberately not collapsed into their parent breed.
|
||||
for candidate in candidates:
|
||||
if norm(candidate) in akc:
|
||||
item=akc[norm(candidate)];matches[item]=None;break
|
||||
entries.append({'source_key':row['source_key'],'name':row['name'],'fields':fields,'field_sources':field_sources,'references':refs})
|
||||
print('Exact AKC matches:',len(matches),flush=True)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
||||
futures={pool.submit(akc_page,item):item for item in matches}
|
||||
done=0
|
||||
for job in concurrent.futures.as_completed(futures):
|
||||
item=futures[job]
|
||||
try:matches[item]=job.result()
|
||||
except Exception as e: print('AKC unavailable:',item[0],type(e).__name__,flush=True)
|
||||
done+=1
|
||||
if done%20==0:print('AKC progress:',done,'/',len(matches),flush=True)
|
||||
by_name={norm(name):ref for (name,_),ref in matches.items() if ref}
|
||||
for row,entry in zip(rows,entries):
|
||||
names=[row.get('english_name',''),entry['fields'].get('english_name','')]
|
||||
if row['source_key'] in equivalents:names.insert(0,equivalents[row['source_key']])
|
||||
ref=next((by_name[norm(n)] for n in names if norm(n) in by_name),None)
|
||||
if not ref:continue
|
||||
ref=json.loads(json.dumps(ref))
|
||||
if re.search(r'[A-Z]$',row['source_key']):
|
||||
ref['facts']['scope']='父犬种资料,变种差异以对应标准为准'
|
||||
entry['references'].append(ref);continue
|
||||
entry['references'].append(ref)
|
||||
for k,v in ref['facts'].items():
|
||||
if k in ['height','weight','life_span'] or (not row.get(k) and not entry['fields'].get(k)):entry['fields'][k]=localized(k,v);entry['field_sources'][k]='akc'
|
||||
# Lifestyle ratings have an explicit AKC source, keeping CKU ratings in the reference.
|
||||
if ref['facts'].get('traits'):entry['fields']['traits']=ref['facts']['traits'];entry['field_sources']['traits']='akc'
|
||||
rkc={r['source_key']:r for r in json.loads((ROOT/'rkc_facts.json').read_text())}
|
||||
for row,entry in zip(rows,entries):
|
||||
if row['source_key'] in rkc:
|
||||
r=rkc[row['source_key']]
|
||||
rkc_ref=references('rkc',r['external_id'],r['url'],r['facts']);rkc_ref['retrieved_at']=r['retrieved_at'];entry['references'].append(rkc_ref)
|
||||
for k in ['exercise','grooming','life_span']:
|
||||
if k=='life_span' and entry['fields'].get(k):continue
|
||||
entry['fields'][k]=r['facts'][k];entry['field_sources'][k]='rkc'
|
||||
if not entry['fields'].get('summary') and not row.get('summary'):
|
||||
facts={**row,**entry['fields']};bits=[]
|
||||
if facts.get('origin'):bits.append('这一犬种起源于'+facts['origin'])
|
||||
if facts.get('breed_group'):bits.append('在 FCI 体系中归入'+facts['breed_group'])
|
||||
if facts.get('temperament'):bits.append('常见性格倾向包括'+facts['temperament'])
|
||||
if bits:entry['fields']['summary']='。'.join(bits)+'。';entry['field_sources']['summary']='editorial'
|
||||
if row.get('summary'):entry['field_sources']['summary']='editorial'
|
||||
if row.get('care_notes'):entry['field_sources']['care_notes']='editorial'
|
||||
if entry['references']:
|
||||
scores={}
|
||||
try:scores=json.loads(entry['fields'].get('traits') or row.get('traits') or '{}')
|
||||
except ValueError:pass
|
||||
if not entry['fields'].get('exercise') and scores.get('运动活力'):
|
||||
entry['fields']['exercise']='运动活力指数 '+str(scores['运动活力'])+'/5,结合年龄与体力安排散步和互动';entry['field_sources']['exercise']='editorial'
|
||||
if not entry['fields'].get('grooming') and scores.get('梳毛需求'):
|
||||
entry['fields']['grooming']='梳毛需求指数 '+str(scores['梳毛需求'])+'/5,按被毛情况安排梳理';entry['field_sources']['grooming']='editorial'
|
||||
(ROOT/'enrichment.json').write_text(json.dumps(entries,ensure_ascii=False,indent=2)+'\n')
|
||||
print('Saved',len(entries),'breeds,',sum(len(e['references']) for e in entries),'references',flush=True)
|
||||
if __name__=='__main__':main()
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/brand-logo.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/brand-icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>肉垫计划 · 管理后台</title>
|
||||
</head>
|
||||
|
||||
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"
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 117 KiB |
@@ -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: "日常任务",
|
||||
|
||||
@@ -83,7 +83,9 @@ strong {
|
||||
.brand img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 17px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #fffaf4;
|
||||
box-shadow: 0 0 0 1px #ead8ca;
|
||||
}
|
||||
.brand strong {
|
||||
display: block;
|
||||
@@ -221,7 +223,8 @@ strong {
|
||||
.welcome img {
|
||||
width: 116px;
|
||||
height: 116px;
|
||||
border-radius: 32px;
|
||||
border-radius: 50%;
|
||||
border: 5px solid #fffaf4;
|
||||
transform: rotate(-8deg);
|
||||
box-shadow: 0 8px 30px #a5623615;
|
||||
}
|
||||
@@ -798,7 +801,8 @@ td strong {
|
||||
.login-story > img {
|
||||
width: 94px;
|
||||
height: 94px;
|
||||
border-radius: 28px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid #fffaf4;
|
||||
box-shadow: 0 12px 40px #aa683219;
|
||||
margin-bottom: 30px;
|
||||
transform: rotate(-6deg);
|
||||
@@ -1062,7 +1066,7 @@ td strong {
|
||||
.login-story > img {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 17px;
|
||||
border-radius: 50%;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.login-story h1 {
|
||||
@@ -1086,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 均为两小时,到期前自动续期,刷新失效时重新微信登录。
|
||||
|
||||
+34
-8
@@ -1,23 +1,49 @@
|
||||
App({
|
||||
globalData: { token: "", user: null },
|
||||
globalData: { token: "", user: null, expiresAt: 0 },
|
||||
onLaunch() {
|
||||
this.globalData.token = wx.getStorageSync("pet_token") || "";
|
||||
this.globalData.expiresAt = Number(wx.getStorageSync("pet_token_expires_at")) || 0;
|
||||
// Start immediately; pages await the same promise and display failures themselves.
|
||||
this.login().catch(() => {});
|
||||
},
|
||||
async login() {
|
||||
if (this.globalData.token) return this.globalData.token;
|
||||
if (!this.loginPromise)
|
||||
if (this.loginPromise) return this.loginPromise;
|
||||
if (this.globalData.token && (!this.globalData.expiresAt || this.globalData.expiresAt > Date.now() / 1000 + 60)) return this.globalData.token;
|
||||
this.loginPromise = (async () => {
|
||||
const { request } = require("./utils/api");
|
||||
const { code } = await wx.login();
|
||||
if (!code) throw new Error("微信登录未取得凭证,请重试");
|
||||
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,
|
||||
success: resolve,
|
||||
fail: () => reject(new Error("暂时无法连接微信,请重新连接")),
|
||||
}),
|
||||
);
|
||||
if (!code) throw new Error("微信登录未取得凭证,请重新连接");
|
||||
const data = await request("/auth/login", "POST", { code }, false);
|
||||
this.globalData.token = data.token;
|
||||
this.globalData.user = data.user;
|
||||
wx.setStorageSync("pet_token", data.token);
|
||||
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",
|
||||
|
||||
+42
-1
@@ -3,7 +3,8 @@ page {
|
||||
overflow: hidden;
|
||||
background: #fff9f3;
|
||||
color: #604a40;
|
||||
font-family: ui-rounded, "Hiragino Maru Gothic ProN", "PingFang SC", sans-serif;
|
||||
font-family:
|
||||
ui-rounded, "Hiragino Maru Gothic ProN", "PingFang SC", sans-serif;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -112,3 +113,43 @@ button::after {
|
||||
.field textarea {
|
||||
height: 170rpx;
|
||||
}
|
||||
|
||||
.completion-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
margin: 8rpx 0 20rpx;
|
||||
font-size: 23rpx;
|
||||
color: #886c5f;
|
||||
}
|
||||
.finish-all {
|
||||
margin: 0;
|
||||
padding: 12rpx 22rpx;
|
||||
background: #fff0e2;
|
||||
color: #b95738;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.4;
|
||||
border-radius: 99rpx;
|
||||
}
|
||||
.all-done {
|
||||
color: #b95738;
|
||||
}
|
||||
.task-completed .check {
|
||||
animation: completion-pop 0.35s ease-out;
|
||||
}
|
||||
.task-completed .task-name,
|
||||
.task-completed .info strong {
|
||||
color: #9d8779;
|
||||
}
|
||||
@keyframes completion-pop {
|
||||
0% {
|
||||
transform: scale(0.7);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.18);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,55 @@
|
||||
const { profile, renderCard, saveToAlbum } = require("../../utils/pet-card");
|
||||
const { toast } = require("../../utils/api");
|
||||
Component({
|
||||
properties: { pet: { type: Object, value: {} } },
|
||||
data: { card: {}, saving: false },
|
||||
observers: {
|
||||
pet(value) {
|
||||
this.setData({ card: profile(value || {}) });
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async more() {
|
||||
if (this.data.saving) return;
|
||||
try {
|
||||
const result = await wx.showActionSheet({
|
||||
itemList: ["编辑档案", "保存身份卡到相册"],
|
||||
itemColor: "#795a4b",
|
||||
});
|
||||
if (result.tapIndex === 0) this.edit();
|
||||
else if (result.tapIndex === 1) await this.save();
|
||||
} catch (error) {
|
||||
if (!/cancel/.test(error.errMsg || "")) toast(error);
|
||||
}
|
||||
},
|
||||
edit() {
|
||||
this.triggerEvent("edit", { id: this.data.pet.id });
|
||||
},
|
||||
async generate() {
|
||||
const canvas = await new Promise((resolve, reject) =>
|
||||
this.createSelectorQuery()
|
||||
.select("#card-canvas")
|
||||
.fields({ node: true, size: true })
|
||||
.exec((result) =>
|
||||
result[0] && result[0].node
|
||||
? resolve(result[0].node)
|
||||
: reject(new Error("卡片还未准备好,请重试")),
|
||||
),
|
||||
);
|
||||
return renderCard(canvas, this.data.pet);
|
||||
},
|
||||
async save() {
|
||||
if (this.data.saving) return;
|
||||
this.setData({ saving: true });
|
||||
try {
|
||||
const path = await this.generate();
|
||||
if (await saveToAlbum(path))
|
||||
wx.showToast({ title: "身份卡已保存到相册", icon: "success" });
|
||||
} catch (error) {
|
||||
toast(error);
|
||||
} finally {
|
||||
this.setData({ saving: false });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": { "app-icon": "/components/app-icon/index" }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<view class="identity">
|
||||
<view class="id-top"><view class="brand"><image src="/assets/logo-corgi-v2.png" /><view><text class="brand-name">肉垫计划</text><text class="brand-en">PAWPRINT ID</text></view></view><view class="id-heading"><text class="id-title">毛孩身份卡</text><button class="more" bindtap="more" disabled="{{saving}}" aria-label="身份卡更多操作"><view></view><view></view><view></view></button></view></view>
|
||||
<view class="id-body" bindtap="edit" aria-role="button" aria-label="编辑宠物档案">
|
||||
<view class="portrait"><image wx:if="{{card.avatar_url}}" src="{{card.avatar_url}}" mode="aspectFill" /><app-icon wx:else size="80" color="#d18b67" /></view>
|
||||
<view class="details"><text class="pet-name">{{card.name}}</text><text class="breed">{{card.breed||'品种未填写'}} · {{card.genderLabel}}</text><view class="facts"><view><text class="label">生日</text><text class="value">{{card.birthday}}</text></view><view><text class="label">体重</text><text class="value">{{card.weightLabel}}</text></view></view><view class="coat"><text class="label">毛色</text><text class="value">{{card.coat||'未填写'}}</text></view></view>
|
||||
</view>
|
||||
<view class="id-bottom"><view><text class="label">陪伴编号</text><text class="number">{{card.cardNumber}}</text></view><text class="motto">{{saving?'正在生成身份卡…':'把陪伴,认真记下来'}}</text></view>
|
||||
</view>
|
||||
<canvas type="2d" id="card-canvas" class="export-canvas" />
|
||||
@@ -0,0 +1,176 @@
|
||||
:host {
|
||||
display: block;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
.identity {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 28rpx;
|
||||
border: 1rpx solid #ead8ca;
|
||||
padding: 25rpx;
|
||||
background: linear-gradient(125deg, #fff3e5, #f0e9f8);
|
||||
box-shadow: 0 14rpx 35rpx #79543a0b;
|
||||
color: #604a40;
|
||||
}
|
||||
.id-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 20rpx;
|
||||
border-bottom: 1rpx solid #dfcdbf;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: 9rpx;
|
||||
align-items: center;
|
||||
}
|
||||
.brand image {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 50%;
|
||||
border: 3rpx solid #fffaf4;
|
||||
box-shadow: 0 0 0 1rpx #dfcdbf;
|
||||
}
|
||||
.brand-name,
|
||||
.brand-en {
|
||||
display: block;
|
||||
}
|
||||
.brand-name {
|
||||
font-size: 18rpx;
|
||||
font-weight: 600;
|
||||
color: #795a4b;
|
||||
}
|
||||
.brand-en {
|
||||
font-size: 11rpx;
|
||||
letter-spacing: 2rpx;
|
||||
color: #a48d7e;
|
||||
}
|
||||
.id-title {
|
||||
font-size: 23rpx;
|
||||
letter-spacing: 3rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
.id-body {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
.portrait {
|
||||
width: 166rpx;
|
||||
height: 180rpx;
|
||||
flex: none;
|
||||
border: 6rpx solid #fffaf4;
|
||||
border-radius: 18rpx;
|
||||
overflow: hidden;
|
||||
background: #fffaf4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.portrait image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.pet-name {
|
||||
display: block;
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.breed {
|
||||
display: block;
|
||||
font-size: 21rpx;
|
||||
color: #886c5f;
|
||||
margin: 2rpx 0 12rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.facts {
|
||||
display: flex;
|
||||
gap: 22rpx;
|
||||
}
|
||||
.facts > view {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 17rpx;
|
||||
color: #a48d7e;
|
||||
}
|
||||
.value {
|
||||
display: block;
|
||||
font-size: 21rpx;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.coat {
|
||||
margin-top: 9rpx;
|
||||
}
|
||||
.id-bottom {
|
||||
border-top: 1rpx solid #dfcdbf;
|
||||
padding-top: 15rpx;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.number {
|
||||
display: block;
|
||||
font-size: 18rpx;
|
||||
letter-spacing: 1rpx;
|
||||
color: #795a4b;
|
||||
}
|
||||
.motto {
|
||||
font-size: 16rpx;
|
||||
color: #886c5f;
|
||||
max-width: 200rpx;
|
||||
text-align: right;
|
||||
}
|
||||
.id-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
}
|
||||
.more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5rpx;
|
||||
margin: -10rpx -10rpx -10rpx 0;
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.more::after {
|
||||
border: 0;
|
||||
}
|
||||
.more view {
|
||||
width: 5rpx;
|
||||
height: 5rpx;
|
||||
border-radius: 50%;
|
||||
background: #9d8779;
|
||||
}
|
||||
.more[disabled] {
|
||||
opacity: 0.4;
|
||||
background: transparent;
|
||||
}
|
||||
.export-canvas {
|
||||
position: fixed;
|
||||
left: -2000px;
|
||||
top: 0;
|
||||
width: 600px;
|
||||
height: 380px;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
Component({
|
||||
properties: { error: String },
|
||||
methods: {
|
||||
retry() {
|
||||
this.triggerEvent("retry");
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "component": true }
|
||||
@@ -0,0 +1,16 @@
|
||||
<view class="startup {{error?'failed':''}}">
|
||||
<view class="glow"></view>
|
||||
<view class="brand-wrap"><image class="brand" src="/assets/logo-corgi-v2.png" mode="aspectFit" /></view>
|
||||
<view class="brand-name">肉垫计划</view>
|
||||
<view class="brand-tagline">给陪伴一个温暖的地方</view>
|
||||
<view wx:if="{{error}}" class="connection-error">
|
||||
<view class="message">暂时没能连接</view>
|
||||
<view class="error-copy">{{error}}</view>
|
||||
<button class="retry" bindtap="retry">重新连接</button>
|
||||
</view>
|
||||
<view wx:else class="preparing" role="status">
|
||||
<view class="dots"><view></view><view></view><view></view></view>
|
||||
<view class="message">正在准备今天的小日常</view>
|
||||
</view>
|
||||
<view class="footer">陪伴它们的每一天,都值得记录</view>
|
||||
</view>
|
||||
@@ -0,0 +1,128 @@
|
||||
.startup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 80;
|
||||
background: linear-gradient(160deg, #fff9f3, #fff0e2 65%, #f7edf7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60rpx;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
color: #694c3f;
|
||||
}
|
||||
.glow {
|
||||
position: absolute;
|
||||
width: 520rpx;
|
||||
height: 520rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, #ffe1c2, transparent 70%);
|
||||
top: 18%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.brand-wrap {
|
||||
position: relative;
|
||||
animation: breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
.brand {
|
||||
width: 172rpx;
|
||||
height: 172rpx;
|
||||
border-radius: 55rpx;
|
||||
box-shadow: 0 20rpx 60rpx #bd84551c;
|
||||
}
|
||||
.brand-name {
|
||||
font-size: 45rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 5rpx;
|
||||
margin-top: 34rpx;
|
||||
}
|
||||
.brand-tagline {
|
||||
font-size: 23rpx;
|
||||
color: #9d7965;
|
||||
margin-top: 14rpx;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
.preparing,
|
||||
.connection-error {
|
||||
min-height: 170rpx;
|
||||
margin-top: 64rpx;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
.message {
|
||||
font-size: 25rpx;
|
||||
color: #886c5f;
|
||||
}
|
||||
.dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 25rpx;
|
||||
}
|
||||
.dots view {
|
||||
width: 10rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 50%;
|
||||
background: #d99a75;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.dots view:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.dots view:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
.error-copy {
|
||||
font-size: 23rpx;
|
||||
line-height: 1.8;
|
||||
color: #9d7965;
|
||||
margin-top: 15rpx;
|
||||
max-width: 570rpx;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.retry {
|
||||
font-size: 25rpx;
|
||||
background: #b95738;
|
||||
color: white;
|
||||
border-radius: 24rpx;
|
||||
padding: 0 44rpx;
|
||||
margin-top: 26rpx;
|
||||
line-height: 82rpx;
|
||||
}
|
||||
.retry::after {
|
||||
border: 0;
|
||||
}
|
||||
.failed .brand-wrap {
|
||||
animation: none;
|
||||
}
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: calc(65rpx + env(safe-area-inset-bottom));
|
||||
font-size: 20rpx;
|
||||
color: #a88c78;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10rpx) scale(1.025);
|
||||
}
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
const tabs = [
|
||||
{ path: "/pages/home/home", label: "首页", key: "home" },
|
||||
{ path: "/pages/meetups/meetups", label: "约遛", key: "meetups" },
|
||||
{ path: "/pages/walk/walk", label: "遛狗", key: "walk" },
|
||||
{ path: "/pages/home/home", label: "日常", key: "daily" },
|
||||
{ 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 },
|
||||
|
||||
@@ -18,15 +18,14 @@
|
||||
</view>
|
||||
</view>
|
||||
<view wx:else class="icon-wrap">
|
||||
<view wx:if="{{item.key==='home'}}" class="home-icon">
|
||||
<view class="home-roof"></view>
|
||||
<view class="home-body"></view>
|
||||
<view class="home-door"></view>
|
||||
</view>
|
||||
<view wx:elif="{{item.key==='meetups'}}" class="friends-icon">
|
||||
<view class="friend friend-left"></view>
|
||||
<view class="friend friend-right"></view>
|
||||
<view wx:if="{{item.key==='daily'}}" class="daily-icon">
|
||||
<view class="calendar-ring ring-left"></view>
|
||||
<view class="calendar-ring ring-right"></view>
|
||||
<view class="calendar-divider"></view>
|
||||
<view class="daily-rays"></view>
|
||||
<view class="daily-sun"></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>
|
||||
|
||||
@@ -50,70 +50,60 @@
|
||||
border-radius: 50%;
|
||||
background: #df936a;
|
||||
}
|
||||
.home-icon {
|
||||
.daily-icon {
|
||||
position: relative;
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
width: 39rpx;
|
||||
height: 37rpx;
|
||||
margin-top: 5rpx;
|
||||
border: 3rpx solid currentColor;
|
||||
border-radius: 9rpx;
|
||||
}
|
||||
.home-roof {
|
||||
.calendar-ring {
|
||||
position: absolute;
|
||||
top: 3rpx;
|
||||
left: 10rpx;
|
||||
width: 25rpx;
|
||||
height: 25rpx;
|
||||
border-left: 4rpx solid currentColor;
|
||||
border-top: 4rpx solid currentColor;
|
||||
top: -8rpx;
|
||||
width: 3rpx;
|
||||
height: 11rpx;
|
||||
border-radius: 3rpx;
|
||||
transform: rotate(45deg);
|
||||
background: currentColor;
|
||||
}
|
||||
.home-body {
|
||||
.ring-left { left: 7rpx; }
|
||||
.ring-right { right: 7rpx; }
|
||||
.calendar-divider {
|
||||
position: absolute;
|
||||
top: 7rpx;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-top: 3rpx solid currentColor;
|
||||
}
|
||||
.daily-rays {
|
||||
position: absolute;
|
||||
left: 6rpx;
|
||||
right: 6rpx;
|
||||
bottom: 1rpx;
|
||||
height: 24rpx;
|
||||
border-left: 4rpx solid currentColor;
|
||||
border-right: 4rpx solid currentColor;
|
||||
border-bottom: 4rpx solid currentColor;
|
||||
border-radius: 0 0 5rpx 5rpx;
|
||||
top: 12rpx;
|
||||
width: 21rpx;
|
||||
height: 21rpx;
|
||||
}
|
||||
.home-door {
|
||||
position: absolute;
|
||||
left: 18rpx;
|
||||
bottom: 2rpx;
|
||||
width: 10rpx;
|
||||
height: 15rpx;
|
||||
border-left: 3rpx solid currentColor;
|
||||
border-right: 3rpx solid currentColor;
|
||||
border-top: 3rpx solid currentColor;
|
||||
border-radius: 5rpx 5rpx 0 0;
|
||||
}
|
||||
.friends-icon {
|
||||
position: relative;
|
||||
width: 48rpx;
|
||||
height: 45rpx;
|
||||
}
|
||||
.friend {
|
||||
position: absolute;
|
||||
bottom: 2rpx;
|
||||
width: 25rpx;
|
||||
height: 16rpx;
|
||||
border: 4rpx solid currentColor;
|
||||
border-radius: 18rpx 18rpx 5rpx 5rpx;
|
||||
}
|
||||
.friend::before {
|
||||
.daily-rays::before, .daily-rays::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -25rpx;
|
||||
left: 3rpx;
|
||||
border-radius: 2rpx;
|
||||
background: currentColor;
|
||||
}
|
||||
.daily-rays::before { left: 9rpx; top: 0; width: 3rpx; height: 21rpx; }
|
||||
.daily-rays::after { left: 0; top: 9rpx; width: 21rpx; height: 3rpx; }
|
||||
.daily-sun {
|
||||
position: absolute;
|
||||
left: 10rpx;
|
||||
top: 16rpx;
|
||||
width: 13rpx;
|
||||
height: 13rpx;
|
||||
border: 4rpx solid currentColor;
|
||||
border: 3rpx solid currentColor;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
.friend-left { left: 1rpx; }
|
||||
.friend-right { right: 1rpx; background: #fff; }
|
||||
.active .friend-right { background: #fff0e2; }
|
||||
.active .daily-sun { 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}
|
||||
+108
-47
@@ -1,9 +1,15 @@
|
||||
const { secure, toast } = require("../../utils/api");
|
||||
const completion = require("../../utils/task-completion");
|
||||
const { secure } = require("../../utils/api");
|
||||
const { localDate } = require("../../utils/date");
|
||||
const environment = require("../../utils/environment");
|
||||
function dateLabel() {
|
||||
const now = new Date();
|
||||
return `${now.getMonth() + 1}月${now.getDate()}日 周${"日一二三四五六"[now.getDay()]}`;
|
||||
}
|
||||
function accountKey() {
|
||||
const state = getApp().globalData || {};
|
||||
return (state.user && state.user.id) || state.token || "";
|
||||
}
|
||||
Page({
|
||||
data: {
|
||||
locationName: "当前位置",
|
||||
@@ -11,80 +17,139 @@ Page({
|
||||
pets: [],
|
||||
selected: [],
|
||||
tasks: [],
|
||||
taskBusyIds: [],
|
||||
completingAll: false,
|
||||
undoneCount: 0,
|
||||
walkCount: 0,
|
||||
name: "铲屎官",
|
||||
loading: true,
|
||||
ready: false,
|
||||
phase: "auth",
|
||||
error: "",
|
||||
},
|
||||
onShow() {
|
||||
this.load();
|
||||
this.updateEnvironmentLabel();
|
||||
if (!this.environmentAt || Date.now() - this.environmentAt > 15 * 60 * 1000) {
|
||||
this.refreshEnvironment();
|
||||
onLoad() {
|
||||
const cached = getApp().homeSnapshot;
|
||||
if (cached && cached.account === accountKey() && cached.date === localDate()) {
|
||||
this.setData({ ...cached.data, ready: true, phase: "ready", loading: false });
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.applyEnvironment(environment.cached());
|
||||
this.load();
|
||||
},
|
||||
onHide() {
|
||||
this.rememberHome();
|
||||
},
|
||||
onUnload() {
|
||||
this.rememberHome();
|
||||
this.disposed = true;
|
||||
},
|
||||
rememberHome() {
|
||||
if (!this.data.ready || !accountKey()) return;
|
||||
const { pets, selected, tasks, undoneCount, walkCount, name } = this.data;
|
||||
getApp().homeSnapshot = {
|
||||
account: accountKey(),
|
||||
date: localDate(),
|
||||
data: { pets, selected, tasks, undoneCount, walkCount, name },
|
||||
};
|
||||
},
|
||||
updateEnvironmentLabel() {
|
||||
this.setData({ headerSubtitle: dateLabel() + " · " + (this.weatherLabel || "轻触获取天气") });
|
||||
this.setData({
|
||||
headerSubtitle:
|
||||
dateLabel() + " · " + (this.weatherLabel || "轻触获取天气"),
|
||||
});
|
||||
},
|
||||
applyEnvironment(value) {
|
||||
if (this.disposed) return;
|
||||
if (value) {
|
||||
this.weatherLabel = value.weatherLabel;
|
||||
this.setData({ locationName: value.locationName });
|
||||
}
|
||||
this.updateEnvironmentLabel();
|
||||
},
|
||||
async refreshEnvironment(event) {
|
||||
if (this.environmentBusy) return;
|
||||
this.environmentBusy = true;
|
||||
const tapped = !!event;
|
||||
let positioned = false;
|
||||
try {
|
||||
const settings = await new Promise((resolve, reject) => wx.getSetting({ success: resolve, fail: reject }));
|
||||
if (settings.authSetting["scope.userLocation"] === false) {
|
||||
if (!tapped) throw new Error("location-denied");
|
||||
const enabled = await new Promise((resolve, reject) => wx.openSetting({ success: resolve, fail: reject }));
|
||||
if (!enabled.authSetting["scope.userLocation"]) throw new Error("location-denied");
|
||||
}
|
||||
const cached = environment.cached();
|
||||
if (!cached) {
|
||||
this.weatherLabel = "正在获取天气";
|
||||
this.updateEnvironmentLabel();
|
||||
const point = await new Promise((resolve, reject) => wx.getLocation({
|
||||
type: "gcj02",
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
}));
|
||||
positioned = true;
|
||||
const result = await secure("/environment", "GET", { latitude: point.latitude, longitude: point.longitude });
|
||||
this.setData({ locationName: result.location_name });
|
||||
this.weatherLabel = result.weather
|
||||
? `${result.weather.description} ${result.weather.temperature}°C`
|
||||
: "天气暂不可用";
|
||||
this.environmentAt = result.weather ? Date.now() : 0;
|
||||
}
|
||||
try {
|
||||
this.applyEnvironment(await environment.refresh(tapped));
|
||||
} catch (error) {
|
||||
const denied = /denied|deny|auth/.test(error.errMsg || error.message || "");
|
||||
this.setData({ locationName: positioned ? "位置暂不可用" : denied ? "开启定位" : "定位暂不可用" });
|
||||
if (this.disposed) return;
|
||||
if (cached) {
|
||||
this.applyEnvironment(cached);
|
||||
} else {
|
||||
const denied = /denied|deny|auth/.test(
|
||||
error.errMsg || error.message || "",
|
||||
);
|
||||
this.setData({ locationName: denied ? "开启定位" : "定位暂不可用" });
|
||||
this.weatherLabel = denied ? "轻触开启定位" : "轻触重试";
|
||||
if (tapped && !denied) wx.showToast({ title: positioned ? "位置和天气获取失败,请重试" : "定位失败,请重试", icon: "none" });
|
||||
this.updateEnvironmentLabel();
|
||||
}
|
||||
if (tapped) toast(error);
|
||||
} finally {
|
||||
this.environmentBusy = false;
|
||||
this.updateEnvironmentLabel();
|
||||
}
|
||||
},
|
||||
async load() {
|
||||
if (this.loadPromise) return this.loadPromise;
|
||||
this.loadPromise = this.loadHome().finally(() => {
|
||||
this.loadPromise = null;
|
||||
});
|
||||
return this.loadPromise;
|
||||
},
|
||||
async loadHome() {
|
||||
this.setData({
|
||||
loading: true,
|
||||
error: "",
|
||||
phase: this.data.ready ? "data" : "auth",
|
||||
});
|
||||
let authenticated = false;
|
||||
try {
|
||||
await getApp().login();
|
||||
authenticated = true;
|
||||
if (this.disposed) return;
|
||||
this.setData({ phase: "data" });
|
||||
const [pets, tasks, walks, me] = await Promise.all([
|
||||
secure("/pets"),
|
||||
secure("/tasks"),
|
||||
secure("/walks"),
|
||||
secure("/me"),
|
||||
]);
|
||||
if (this.disposed) return;
|
||||
const chosen = wx.getStorageSync("walk_pet_ids") || [],
|
||||
month = localDate().slice(0, 7);
|
||||
this.setData({
|
||||
pets,
|
||||
selected: chosen.filter((id) => pets.some((p) => p.id === id)),
|
||||
tasks: tasks.items,
|
||||
undoneCount: tasks.items.filter((t) => !t.completed).length,
|
||||
walkCount: walks.filter((w) => w.started_at.slice(0, 7) === month)
|
||||
.length,
|
||||
name: me.nickname || "铲屎官",
|
||||
name: me.nickname || "毛孩家长",
|
||||
loading: false,
|
||||
ready: true,
|
||||
phase: "ready",
|
||||
});
|
||||
this.rememberHome();
|
||||
// Location permission and weather never hold up the first usable screen.
|
||||
this.refreshEnvironment();
|
||||
} catch (error) {
|
||||
if (this.disposed) return;
|
||||
this.setData({
|
||||
loading: false,
|
||||
phase: !authenticated && !this.data.ready ? "auth-error" : "data-error",
|
||||
error: error.message || "暂时没能连接,请稍后重试",
|
||||
});
|
||||
} catch (e) {
|
||||
this.setData({ loading: false });
|
||||
toast(e);
|
||||
}
|
||||
},
|
||||
addPet() {
|
||||
wx.navigateTo({ url: "/pages/pet-edit/pet-edit" });
|
||||
},
|
||||
pick(e) {
|
||||
this.setData({ selected: e.detail.selected });
|
||||
wx.setStorageSync("walk_pet_ids", e.detail.selected);
|
||||
@@ -102,17 +167,13 @@ Page({
|
||||
goWalks() {
|
||||
wx.navigateTo({ url: "/pages/walk-records/walk-records" });
|
||||
},
|
||||
async complete(e) {
|
||||
const id = e.currentTarget.dataset.id,
|
||||
task = this.data.tasks.find((t) => t.id === id);
|
||||
try {
|
||||
await secure("/tasks/" + id + "/completion", "PUT", {
|
||||
date: localDate(),
|
||||
completed: !task.completed,
|
||||
});
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
toast(err);
|
||||
}
|
||||
complete(e) {
|
||||
return completion.complete(
|
||||
this,
|
||||
this.data.tasks.find((t) => t.id === e.currentTarget.dataset.id),
|
||||
);
|
||||
},
|
||||
completeAll() {
|
||||
return completion.completeAll(this);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
"startup-state": "/components/startup-state/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
<page-layout id="page-layout" title="{{locationName}}" subtitle="{{headerSubtitle}}" location="{{true}}" bind:locationtap="refreshEnvironment">
|
||||
<startup-state wx:if="{{!ready && (!error || phase==='auth-error')}}" error="{{error}}" bind:retry="load" />
|
||||
<page-layout wx:else id="page-layout" title="{{locationName}}" subtitle="{{headerSubtitle}}" location="{{true}}" bind:locationtap="refreshEnvironment">
|
||||
<view class="screen">
|
||||
<view wx:if="{{!ready && error}}" class="card home-error">
|
||||
<app-icon kind="paw" size="54" />
|
||||
<view class="error-title">暂时没能准备好首页</view>
|
||||
<view class="muted">{{error}}</view>
|
||||
<button class="primary" bindtap="load">重新加载</button>
|
||||
</view>
|
||||
<block wx:else>
|
||||
<view wx:if="{{error}}" class="refresh-error"><text>更新失败,保留上次的日常记录</text><text class="link" bindtap="load">重试</text></view>
|
||||
<view class="hero">
|
||||
<view class="hero-copy">
|
||||
<text class="hero-small">今日的主角</text>
|
||||
@@ -21,9 +30,16 @@
|
||||
<view class="muted">选择同行的狗狗,一起出门转转。</view>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:if="{{!pets.length}}" class="first-pet">
|
||||
<view class="first-pet-title">认识你的第一位毛孩子</view>
|
||||
<view class="muted">先建立档案,再一起出门收集小快乐。</view>
|
||||
<button class="primary start" bindtap="addPet">添加毛孩子</button>
|
||||
</view>
|
||||
<block wx:else>
|
||||
<view class="picker-label">这次带谁?可以多选</view>
|
||||
<pet-picker pets="{{pets}}" selected="{{selected}}" bind:change="pick" />
|
||||
<button class="primary start" bindtap="start">开始遛狗 →</button>
|
||||
</block>
|
||||
</view>
|
||||
<view class="stats">
|
||||
<view>
|
||||
@@ -52,29 +68,35 @@
|
||||
<text class="section-title">今天要做的事</text>
|
||||
<text class="link" bindtap="goTasks">全部任务 ›</text>
|
||||
</view>
|
||||
<view wx:if="{{tasks.length}}" class="completion-toolbar">
|
||||
<text>{{undoneCount ? '还有 '+undoneCount+' 项小事' : '这一份日常,已全部照顾好'}}</text>
|
||||
<button wx:if="{{undoneCount}}" class="finish-all" bindtap="completeAll" loading="{{completingAll}}" disabled="{{completingAll || taskBusyIds.length}}">一键完成</button>
|
||||
<text wx:else class="all-done">全部完成</text>
|
||||
</view>
|
||||
<view class="card tasks">
|
||||
<view
|
||||
wx:for="{{tasks}}"
|
||||
wx:key="id"
|
||||
class="task"
|
||||
class="task {{item.completed?'task-completed':''}}"
|
||||
data-id="{{item.id}}"
|
||||
bindtap="complete"
|
||||
>
|
||||
<view class="check {{item.completed?'done':''}}">
|
||||
{{item.completed?'✓':''}}
|
||||
{{item.completed?'✓':taskBusyIds[0]===item.id?'…':''}}
|
||||
</view>
|
||||
<view class="task-icon">
|
||||
<app-icon kind="{{item.category}}" size="36" />
|
||||
</view>
|
||||
<view class="task-main">
|
||||
<text class="task-name">{{item.title}}</text>
|
||||
<text class="muted">{{item.time_of_day}}</text>
|
||||
<text class="muted">{{item.all_pets?'全部宠物 · ':''}}{{item.time_of_day}} · {{item.completed?'已完成':taskBusyIds[0]===item.id?'正在记录…':'点击完成'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:if="{{!tasks.length}}" class="empty">
|
||||
今天还没有任务,给狗狗安排一件小事吧
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</page-layout>
|
||||
<tab-bar current="/pages/home/home" />
|
||||
<tab-bar wx:if="{{ready}}" current="/pages/home/home" />
|
||||
|
||||
@@ -157,3 +157,47 @@
|
||||
.task-main .muted {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.home-error {
|
||||
text-align: center;
|
||||
padding: 70rpx 35rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 26rpx;
|
||||
margin-top: 40rpx;
|
||||
}
|
||||
.error-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
.home-error .muted {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.home-error button {
|
||||
margin-top: 15rpx;
|
||||
min-width: 300rpx;
|
||||
}
|
||||
.refresh-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 22rpx;
|
||||
background: #fff0e2;
|
||||
color: #886c5f;
|
||||
padding: 20rpx 24rpx;
|
||||
border-radius: 22rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.first-pet {
|
||||
padding-top: 30rpx;
|
||||
}
|
||||
.first-pet-title {
|
||||
font-size: 27rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.first-pet .muted {
|
||||
font-size: 23rpx;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
@@ -37,9 +41,18 @@ Page({
|
||||
this.setData({ gender: e.detail.value });
|
||||
},
|
||||
async choose() {
|
||||
let selection;
|
||||
try {
|
||||
const r = await wx.chooseMedia({ count: 1, mediaType: ["image"] });
|
||||
const file = await upload(r.tempFiles[0].tempFilePath);
|
||||
selection = await wx.chooseMedia({ count: 1, mediaType: ["image"] });
|
||||
} catch (e) {
|
||||
if (/^chooseMedia:fail cancel\b/i.test((e && e.errMsg) || "")) return;
|
||||
toast(e);
|
||||
return;
|
||||
}
|
||||
const photo = selection && selection.tempFiles && selection.tempFiles[0];
|
||||
if (!photo || !photo.tempFilePath) return;
|
||||
try {
|
||||
const file = await upload(photo.tempFilePath);
|
||||
this.setData({ avatar_file_id: file.id, avatar_url: file.url });
|
||||
} catch (e) {
|
||||
toast(e);
|
||||
@@ -55,6 +68,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>
|
||||
|
||||
@@ -48,3 +48,25 @@
|
||||
font-size: 24rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.field input {
|
||||
height: 104rpx;
|
||||
min-height: 104rpx;
|
||||
padding: 0 26rpx;
|
||||
line-height: normal;
|
||||
}
|
||||
.picker {
|
||||
min-height: 104rpx;
|
||||
padding: 30rpx 26rpx;
|
||||
}
|
||||
.field radio-group {
|
||||
min-height: 104rpx;
|
||||
padding: 26rpx;
|
||||
}
|
||||
.field textarea {
|
||||
height: 210rpx;
|
||||
padding: 26rpx;
|
||||
}
|
||||
.field {
|
||||
margin: 28rpx 0;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ Page({
|
||||
},
|
||||
edit(e) {
|
||||
wx.navigateTo({
|
||||
url: "/pages/pet-edit/pet-edit?id=" + e.currentTarget.dataset.id,
|
||||
url: "/pages/pet-edit/pet-edit?id=" + e.detail.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"disableScroll": true,
|
||||
"usingComponents": {
|
||||
"tab-bar": "/components/tab-bar/index"
|
||||
"tab-bar": "/components/tab-bar/index",
|
||||
"pet-id-card": "/components/pet-id-card/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,31 +11,7 @@
|
||||
<text class="section-title">宠物档案 · {{pets.length}}</text>
|
||||
<text class="add" bindtap="add">+ 添加</text>
|
||||
</view>
|
||||
<view
|
||||
wx:for="{{pets}}"
|
||||
wx:key="id"
|
||||
class="card pet"
|
||||
data-id="{{item.id}}"
|
||||
bindtap="edit"
|
||||
>
|
||||
<view class="pet-avatar">
|
||||
<image
|
||||
wx:if="{{item.avatar_url}}"
|
||||
src="{{item.avatar_url}}"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<app-icon wx:else size="70" />
|
||||
</view>
|
||||
<view class="pet-info">
|
||||
<strong>{{item.name}}</strong>
|
||||
<text>
|
||||
{{item.breed||'小可爱'}} ·
|
||||
{{item.gender==='male'?'男孩':item.gender==='female'?'女孩':'未知'}}
|
||||
</text>
|
||||
<text>体重 {{item.weight_kg||'—'}} kg</text>
|
||||
</view>
|
||||
<text class="arrow">›</text>
|
||||
</view>
|
||||
<pet-id-card wx:for="{{pets}}" wx:key="id" pet="{{item}}" bind:edit="edit" />
|
||||
<view wx:if="{{!pets.length}}" class="empty">给第一只狗狗建个档案吧 </view>
|
||||
<button class="primary add-button" bindtap="add">+ 新建宠物档案</button>
|
||||
</view>
|
||||
|
||||
@@ -33,43 +33,6 @@
|
||||
font-weight: 600;
|
||||
color: #b95738;
|
||||
}
|
||||
.pet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22rpx;
|
||||
}
|
||||
.pet-avatar {
|
||||
width: 125rpx;
|
||||
height: 125rpx;
|
||||
flex: none;
|
||||
border-radius: 28rpx;
|
||||
background: #f1d2a3;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 63rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pet-avatar image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.pet-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
flex: 1;
|
||||
}
|
||||
.pet-info strong {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
.pet-info text {
|
||||
font-size: 23rpx;
|
||||
color: #886c5f;
|
||||
}
|
||||
.arrow {
|
||||
font-size: 42rpx;
|
||||
color: #886c5f;
|
||||
}
|
||||
.add-button {
|
||||
margin-top: 25rpx;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
const completion = require("../../utils/task-completion");
|
||||
const { secure, toast } = require("../../utils/api");
|
||||
const { localDate, shiftDate } = require("../../utils/date");
|
||||
Page({
|
||||
data: {
|
||||
pets: [],
|
||||
petOptions: [],
|
||||
tasks: [],
|
||||
taskBusyIds: [],
|
||||
completingAll: false,
|
||||
undoneCount: 0,
|
||||
date: localDate(),
|
||||
showForm: false,
|
||||
editingId: "",
|
||||
@@ -28,28 +33,27 @@ Page({
|
||||
if (date !== this.data.date) return;
|
||||
const tasks = result.items.map((t) => ({
|
||||
...t,
|
||||
petName: (pets.find((p) => p.id === t.pet_id) || {}).name || "宠物",
|
||||
petName: t.all_pets ? "全部宠物" : (pets.find((p) => p.id === t.pet_id) || {}).name || "宠物",
|
||||
}));
|
||||
this.setData({
|
||||
pets,
|
||||
petOptions: [...pets, { id: "", name: "全部宠物" }],
|
||||
tasks,
|
||||
undoneCount: tasks.filter((t) => !t.completed).length,
|
||||
done: tasks.filter((t) => t.completed).length,
|
||||
});
|
||||
} catch (e) {
|
||||
toast(e);
|
||||
}
|
||||
},
|
||||
async toggle(e) {
|
||||
const t = this.data.tasks[e.currentTarget.dataset.index];
|
||||
try {
|
||||
await secure("/tasks/" + t.id + "/completion", "PUT", {
|
||||
date: this.data.date,
|
||||
completed: !t.completed,
|
||||
});
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
toast(err);
|
||||
}
|
||||
toggle(e) {
|
||||
return completion.complete(
|
||||
this,
|
||||
this.data.tasks[e.currentTarget.dataset.index],
|
||||
);
|
||||
},
|
||||
completeAll() {
|
||||
return completion.completeAll(this);
|
||||
},
|
||||
open() {
|
||||
if (!this.data.pets.length) {
|
||||
@@ -76,18 +80,24 @@ Page({
|
||||
showForm: true,
|
||||
editingId: task.id,
|
||||
title: task.title,
|
||||
petIndex: Math.max(petIndex, 0),
|
||||
petIndex: task.all_pets ? this.data.pets.length : Math.max(petIndex, 0),
|
||||
category: task.category,
|
||||
time_of_day: task.time_of_day,
|
||||
repeat_rule: task.repeat_rule,
|
||||
});
|
||||
},
|
||||
changeDay(e) {
|
||||
this.setData({ date: shiftDate(this.data.date, Number(e.currentTarget.dataset.days)) });
|
||||
if (this.data.completingAll || this.data.taskBusyIds.length) return;
|
||||
this.setData({
|
||||
date: shiftDate(this.data.date, Number(e.currentTarget.dataset.days)),
|
||||
});
|
||||
this.load();
|
||||
},
|
||||
showToday() {
|
||||
this.setData({ date: localDate() });
|
||||
if (this.data.completingAll || this.data.taskBusyIds.length) return;
|
||||
const today = localDate();
|
||||
if (this.data.date === today) return;
|
||||
this.setData({ date: today });
|
||||
this.load();
|
||||
},
|
||||
field(e) {
|
||||
@@ -113,13 +123,19 @@ Page({
|
||||
}
|
||||
this.setData({ saving: true });
|
||||
try {
|
||||
await secure(this.data.editingId ? "/tasks/" + this.data.editingId : "/tasks", this.data.editingId ? "PUT" : "POST", {
|
||||
pet_id: this.data.pets[this.data.petIndex].id,
|
||||
await secure(
|
||||
this.data.editingId ? "/tasks/" + this.data.editingId : "/tasks",
|
||||
this.data.editingId ? "PUT" : "POST",
|
||||
{
|
||||
...(this.data.petIndex === this.data.pets.length
|
||||
? { all_pets: true }
|
||||
: { pet_id: this.data.pets[this.data.petIndex].id }),
|
||||
title: this.data.title.trim(),
|
||||
category: this.data.category,
|
||||
time_of_day: this.data.time_of_day,
|
||||
repeat_rule: this.data.repeat_rule,
|
||||
});
|
||||
},
|
||||
);
|
||||
this.setData({ showForm: false, editingId: "" });
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
|
||||
@@ -19,14 +19,19 @@
|
||||
<text bindtap="showToday">回到今天</text>
|
||||
<text data-days="1" bindtap="changeDay">后一天 ›</text>
|
||||
</view>
|
||||
<view wx:if="{{tasks.length}}" class="completion-toolbar">
|
||||
<text>{{undoneCount ? '还有 '+undoneCount+' 项小事' : '这一份日常,已全部照顾好'}}</text>
|
||||
<button wx:if="{{undoneCount}}" class="finish-all" bindtap="completeAll" loading="{{completingAll}}" disabled="{{completingAll || taskBusyIds.length}}">一键完成</button>
|
||||
<text wx:else class="all-done">全部完成</text>
|
||||
</view>
|
||||
<view class="card list">
|
||||
<view wx:for="{{tasks}}" wx:key="id" class="task">
|
||||
<view wx:for="{{tasks}}" wx:key="id" class="task {{item.completed?'task-completed':''}}">
|
||||
<view
|
||||
class="check {{item.completed?'done':''}}"
|
||||
data-index="{{index}}"
|
||||
bindtap="toggle"
|
||||
>
|
||||
{{item.completed?'✓':''}}
|
||||
{{item.completed?'✓':taskBusyIds[0]===item.id?'…':''}}
|
||||
</view>
|
||||
<view class="icon">
|
||||
<app-icon kind="{{item.category}}" size="36" />
|
||||
@@ -37,7 +42,7 @@
|
||||
{{item.petName}} · {{item.time_of_day}} ·
|
||||
{{item.repeat_rule==='daily'?'每天':item.repeat_rule==='weekdays'?'工作日':'周末'}}
|
||||
</text>
|
||||
<text>点此编辑</text>
|
||||
<text>{{item.completed?'已完成 · 点此编辑任务':taskBusyIds[0]===item.id?'正在记录…':'点击左侧完成 · 点此编辑'}}</text>
|
||||
</view>
|
||||
<text class="remove" data-index="{{index}}" bindtap="remove">×</text>
|
||||
</view>
|
||||
@@ -66,12 +71,12 @@
|
||||
<view class="field">
|
||||
<view class="field-label">照顾谁</view>
|
||||
<picker
|
||||
range="{{pets}}"
|
||||
range="{{petOptions}}"
|
||||
range-key="name"
|
||||
value="{{petIndex}}"
|
||||
bindchange="petChange"
|
||||
>
|
||||
<view class="picker">{{pets[petIndex].name||'请先添加宠物'}}</view>
|
||||
<view class="picker">{{petOptions[petIndex].name||'请先添加宠物'}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="field">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+12
-7
@@ -6,23 +6,29 @@ function request(path, method = "GET", data, auth = true) {
|
||||
url: baseURL + path,
|
||||
method,
|
||||
data,
|
||||
timeout: 15000,
|
||||
header: token && auth ? { Authorization: "Bearer " + token } : {},
|
||||
success(res) {
|
||||
if (res.data && res.data.code === 0) resolve(res.data.data);
|
||||
else {
|
||||
if (res.statusCode === 401) {
|
||||
wx.removeStorageSync("pet_token");
|
||||
getApp().globalData.token = "";
|
||||
}
|
||||
if (res.statusCode === 401 && auth) invalidateToken(token);
|
||||
const error = new Error((res.data && res.data.message) || "请求失败");
|
||||
error.status = res.statusCode;
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: reject,
|
||||
fail: () => reject(new Error("网络连接失败,请检查网络后重试")),
|
||||
});
|
||||
});
|
||||
}
|
||||
function invalidateToken(usedToken) {
|
||||
const app = getApp();
|
||||
// A late response using the old token must not clear a newly refreshed session.
|
||||
if (app.globalData.token && app.globalData.token !== usedToken) return;
|
||||
wx.removeStorageSync("pet_token");
|
||||
app.globalData.token = "";
|
||||
app.globalData.user = null;
|
||||
}
|
||||
async function secure(path, method, data) {
|
||||
await getApp().login();
|
||||
try {
|
||||
@@ -53,6 +59,7 @@ function uploadOnce(path) {
|
||||
else {
|
||||
const error = new Error(body.message || "上传失败");
|
||||
error.status = res.statusCode;
|
||||
if (res.statusCode === 401) invalidateToken(token);
|
||||
reject(error);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -69,8 +76,6 @@ async function upload(path) {
|
||||
return await uploadOnce(path);
|
||||
} catch (error) {
|
||||
if (error.status !== 401) throw error;
|
||||
wx.removeStorageSync("pet_token");
|
||||
getApp().globalData.token = "";
|
||||
await getApp().login();
|
||||
return uploadOnce(path);
|
||||
}
|
||||
|
||||
@@ -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,85 @@
|
||||
const { secure } = require("./api");
|
||||
|
||||
const STORAGE_KEY = "pet_environment_v1";
|
||||
let value;
|
||||
let attempted = false;
|
||||
let pending;
|
||||
let failure;
|
||||
|
||||
function cached() {
|
||||
if (value === undefined) {
|
||||
const saved = wx.getStorageSync(STORAGE_KEY);
|
||||
value =
|
||||
saved &&
|
||||
typeof saved.locationName === "string" &&
|
||||
typeof saved.weatherLabel === "string" &&
|
||||
Number.isFinite(saved.updatedAt)
|
||||
? saved
|
||||
: null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function fetchEnvironment(tapped) {
|
||||
const settings = await new Promise((resolve, reject) =>
|
||||
wx.getSetting({ success: resolve, fail: reject }),
|
||||
);
|
||||
if (settings.authSetting["scope.userLocation"] === false) {
|
||||
if (!tapped) throw new Error("location-denied");
|
||||
const enabled = await new Promise((resolve, reject) =>
|
||||
wx.openSetting({ success: resolve, fail: reject }),
|
||||
);
|
||||
if (!enabled.authSetting["scope.userLocation"])
|
||||
throw new Error("location-denied");
|
||||
}
|
||||
const point = await new Promise((resolve, reject) =>
|
||||
wx.getLocation({ type: "gcj02", success: resolve, fail: reject }),
|
||||
);
|
||||
const result = await secure("/environment", "GET", {
|
||||
latitude: point.latitude,
|
||||
longitude: point.longitude,
|
||||
});
|
||||
if (!result || !result.location_name)
|
||||
throw new Error("位置和天气获取失败,请重试");
|
||||
const previous = cached();
|
||||
value = {
|
||||
locationName: result.location_name,
|
||||
weatherLabel: result.weather
|
||||
? `${result.weather.description} ${result.weather.temperature}°C`
|
||||
: previous && previous.locationName === result.location_name
|
||||
? previous.weatherLabel
|
||||
: "天气暂不可用",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
// Store display data only; precise coordinates are not persisted here.
|
||||
try {
|
||||
wx.setStorageSync(STORAGE_KEY, value);
|
||||
} catch (_) {}
|
||||
return value;
|
||||
}
|
||||
|
||||
function refresh(tapped = false) {
|
||||
if (pending) return pending;
|
||||
// Module lifetime follows the app, including recreated pages and warm resumes.
|
||||
// Failed automatic attempts also wait for a tap or the next cold start.
|
||||
if (attempted && !tapped)
|
||||
return failure ? Promise.reject(failure) : Promise.resolve(cached());
|
||||
attempted = true;
|
||||
pending = fetchEnvironment(tapped)
|
||||
.then(
|
||||
(result) => {
|
||||
failure = null;
|
||||
return result;
|
||||
},
|
||||
(error) => {
|
||||
failure = error;
|
||||
throw error;
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
pending = null;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
module.exports = { cached, refresh };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user