diff --git a/.gitignore b/.gitignore index c98ba57..4848cd5 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,5 @@ yarn-error.log* # Logs / runtime *.log -# Secrets — real config stays local; copy from config.example.yaml -pets-be/configs/config.yaml - # 服务器环境变量(含密钥) deploy/.env diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..5b60248 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/sundynix-pets.iml b/.idea/sundynix-pets.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/sundynix-pets.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/deploy/.env.example b/deploy/.env.example index d045344..0291a61 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,67 +1,19 @@ -# 肉垫计划 · 服务器环境变量 -# 用法:cp .env.example .env && vi .env -# .env 只放在服务器 /home/workspace/sundynix-pet 下,不进 git,CI 也不会覆盖它。 +POSTGRES_HOST=192.168.100.127 +POSTGRES_PORT=5432 +POSTGRES_USER=root +POSTGRES_PASSWORD=root +POSTGRES_DATABASE=sundynix-pets +POSTGRES_SSL_MODE=disable -# ── 外部 MySQL ── -# 注意:容器里的 127.0.0.1 是容器自己。若 MySQL 装在宿主机上, -# 填 host.docker.internal 并在 compose 里加 extra_hosts,或直接填内网 IP。 -MYSQL_HOST=192.168.100.127 -MYSQL_PORT=3307 -MYSQL_USER=root -MYSQL_PASSWORD=root -MYSQL_DATABASE=sundynix_pet - -# ── 外部 MinIO(对象存储 / 图片) ── -# ⚠️ 这是多个服务共用的 MinIO 实例(sundynix-agent / sundynix-plant 等也在用): -# 1) BUCKET 必须填本项目自己的桶,填错别的服务的桶会污染人家数据; -# 2) 强烈建议不要用 root 凭据,另开一个只能访问 sundynix-pet 的账号: -# mc admin user add pets-svc <强密码> -# mc admin policy create pets-only pets-only.json # 只授权 sundynix-pet -# mc admin policy attach pets-only --user pets-svc -# 3) 桶已存在时程序不会修改其访问策略;图片直链需要该桶为 public-read, -# 这一项由运维一次性配好。 -# ENDPOINT 是「写入」用的 MinIO API 地址:内网直连、不带协议、不走 nginx。 -# 公网等价地址是 162.14.122.200:3407(本机调试时可用,跨公网慢)。 -MINIO_ENDPOINT=192.168.100.126:9000 -MINIO_USE_SSL=false +MINIO_ENDPOINT=https://s3.sundynix.cn MINIO_ACCESS_KEY=sundynix MINIO_SECRET_KEY=sundynix MINIO_BUCKET=sundynix-pet -# 图片对外地址,小程序直接加载它。正式版必须 https。 -MINIO_PUBLIC_BASE_URL=https://res.sundynix.cn +MINIO_USE_SSL=true -# ── 登录鉴权 ── -JWT_SECRET=改成随机长字符串 -# access token 有效期(小时)。短一点更安全,过期由前端拿 refresh token 静默续期 -JWT_EXPIRE_HOURS=2 -# 刷新令牌有效期:小程序 30 天、后台 7 天。到期才需要重新登录 -JWT_REFRESH_EXPIRE_HOURS=720 -JWT_ADMIN_REFRESH_EXPIRE_HOURS=168 -# 生产必须 false,否则任何人都能用昵称直接登录 -AUTH_DEV_LOGIN=false - -# ── 微信小程序 ── -WECHAT_APP_ID=wx开头的AppID -WECHAT_APP_SECRET=小程序密钥 -# 内容安全图片异步检测的结果回调校验 Token。 -# 要和「小程序后台 → 开发管理 → 消息推送」里填的 Token 完全一致(英文数字,3-32 位)。 -# 消息推送 URL 填 https://pet.sundynix.cn/api/wx/sec-callback,加密方式选「明文模式」。 -# 没配消息推送前可留空(社区内容会走后台手动审核)。 -WECHAT_MSG_TOKEN=petsSecCheck2026 - -# ── 管理后台(/admin) ── +JWT_SECRET= +WECHAT_APP_ID=wx52dfc635739a9c19 +WECHAT_APP_SECRET= ADMIN_USERNAME=sundynix -ADMIN_PASSWORD=改成强密码 - -# ── AI(provider 中立,OpenAI 兼容协议) ── -AI_ENABLED=true -AI_PROVIDER=openai -AI_BASE_URL=https://api.deepseek.com -AI_API_KEY=sk-你的key -AI_MODEL=deepseek-chat -AI_TEMPERATURE=0.6 -AI_MAX_TOKENS=1024 -AI_TIMEOUT_SEC=30 - -# ── 镜像 tag(一般不用改,CI 推的是 latest) ── +ADMIN_PASSWORD=sundynix TAG=latest diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 842d9d0..84af1ea 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,15 +1,3 @@ -# 肉垫计划 · 应用部署(只有一个容器) -# -# 用法(服务器 /home/workspace/sundynix-pet 下): -# cp .env.example .env && vi .env # 填 MySQL / MinIO / 微信 / AI 的真实值 -# docker compose up -d --no-build # 镜像由 CI 推送并 docker load,这里不构建 -# -# 说明: -# - 一个容器同时提供 /api(小程序接口)和 /admin(管理后台,go:embed 内嵌), -# 容器内监听 9090,映射到宿主机 4000。 -# - MySQL、MinIO 都用外部现成的,不在这里起容器;地址和凭据全部走 .env。 -# - 配置一律通过 PETS_ 前缀的环境变量注入,容器内没有 config.yaml。 - services: app: image: sundynix/pets-be:${TAG:-latest} @@ -19,52 +7,25 @@ services: environment: PETS_SERVER_PORT: "9090" PETS_SERVER_MODE: release - - # ── 外部 MySQL ── - PETS_MYSQL_HOST: ${MYSQL_HOST:?} - PETS_MYSQL_PORT: ${MYSQL_PORT:-3306} - PETS_MYSQL_USER: ${MYSQL_USER:?} - PETS_MYSQL_PASSWORD: ${MYSQL_PASSWORD:?} - PETS_MYSQL_DATABASE: ${MYSQL_DATABASE:-pets} - PETS_MYSQL_CHARSET: utf8mb4 - - # ── 外部 MinIO ── - PETS_MINIO_ENDPOINT: ${MINIO_ENDPOINT:?} - PETS_MINIO_USE_SSL: ${MINIO_USE_SSL:-true} + PETS_POSTGRES_HOST: ${POSTGRES_HOST:-192.168.100.127} + PETS_POSTGRES_PORT: ${POSTGRES_PORT:-5432} + PETS_POSTGRES_USER: ${POSTGRES_USER:-root} + PETS_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?} + PETS_POSTGRES_DATABASE: ${POSTGRES_DATABASE:-sundynix-pets} + PETS_POSTGRES_SSL_MODE: ${POSTGRES_SSL_MODE:-disable} + PETS_MINIO_ENDPOINT: ${MINIO_ENDPOINT:-https://s3.sundynix.cn} PETS_MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?} PETS_MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?} - PETS_MINIO_BUCKET: ${MINIO_BUCKET:-pets} - # 图片对外访问地址,小程序直接加载;正式版必须 https - PETS_MINIO_PUBLIC_BASE_URL: ${MINIO_PUBLIC_BASE_URL:?} - + PETS_MINIO_BUCKET: ${MINIO_BUCKET:-sundynix-pet} + PETS_MINIO_USE_SSL: ${MINIO_USE_SSL:-true} PETS_JWT_SECRET: ${JWT_SECRET:?} - # access token 2 小时;过期由前端拿 refresh token 静默续期 - PETS_JWT_EXPIRE_HOURS: ${JWT_EXPIRE_HOURS:-2} - PETS_JWT_REFRESH_EXPIRE_HOURS: ${JWT_REFRESH_EXPIRE_HOURS:-720} - PETS_JWT_ADMIN_REFRESH_EXPIRE_HOURS: ${JWT_ADMIN_REFRESH_EXPIRE_HOURS:-168} - PETS_WECHAT_APP_ID: ${WECHAT_APP_ID:?} PETS_WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?} - # 内容安全图片异步检测的结果回调校验 Token(消息推送服务器配置里的 Token)。 - # 可选:留空则回调不校验签名;配了才和微信后台的 Token 对上 - PETS_WECHAT_MSG_TOKEN: ${WECHAT_MSG_TOKEN:-} - # 生产必须 false,否则任何人都能用昵称直接登录 - PETS_AUTH_DEV_LOGIN: ${AUTH_DEV_LOGIN:-false} - + PETS_AUTH_DEV_LOGIN: "false" PETS_ADMIN_USERNAME: ${ADMIN_USERNAME:-sundynix} PETS_ADMIN_PASSWORD: ${ADMIN_PASSWORD:?} - - PETS_AI_ENABLED: ${AI_ENABLED:-true} - PETS_AI_PROVIDER: ${AI_PROVIDER:-openai} - PETS_AI_BASE_URL: ${AI_BASE_URL:-https://api.deepseek.com} - PETS_AI_API_KEY: ${AI_API_KEY:-} - PETS_AI_MODEL: ${AI_MODEL:-deepseek-chat} - PETS_AI_TEMPERATURE: ${AI_TEMPERATURE:-0.6} - PETS_AI_MAX_TOKENS: ${AI_MAX_TOKENS:-1024} - PETS_AI_TIMEOUT_SEC: ${AI_TIMEOUT_SEC:-30} healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9090/api/ping"] interval: 15s timeout: 5s retries: 5 - start_period: 20s diff --git a/pet-walk-prototype.html b/pet-walk-prototype.html new file mode 100644 index 0000000..127452a --- /dev/null +++ b/pet-walk-prototype.html @@ -0,0 +1,163 @@ + + + + + + + + 肉垫计划 · 宠物生活原型 + + + +
+ +
+
+ + + + + diff --git a/pets-be/Makefile b/pets-be/Makefile index 28678a0..af7d592 100644 --- a/pets-be/Makefile +++ b/pets-be/Makefile @@ -1,15 +1,8 @@ -.PHONY: env-up env-down admin-install admin-build admin-dev build run tidy - -# 起/停 MySQL + MinIO -env-up: - cd deployments && docker compose up -d - -env-down: - cd deployments && docker compose down +.PHONY: admin-install admin-build admin-dev build run tidy # 后台前端 admin-install: - cd web/admin && npm install + cd web/admin && npm ci admin-build: cd web/admin && npm run build diff --git a/pets-be/README.md b/pets-be/README.md index 0cd3e4b..f6c2880 100644 --- a/pets-be/README.md +++ b/pets-be/README.md @@ -1,64 +1,35 @@ -# 毛孩子计划 · 后端 (pets-be) +# 肉垫计划 -Gin + GORM + MySQL + MinIO 的宠物小程序后端,内嵌 React(Vite + Tailwind + shadcn/ui) 后台管理。 - -## 技术栈与约定 -- 模块路径 `github.com/sundynix/pets-be`,标准 Go 布局(`cmd/ internal/ pkg/ configs/ deployments/ web/`) -- 所有数据库表带前缀 `sundynix_` -- 统一响应 `{code, message, data}`(`pkg/response`),列表接口统一分页参数 `page` / `page_size`(`response.PageQuery` + `PageResult`) -- MySQL `root/root` 库 `pets`;MinIO `sundynix/sundynix` 桶 `pets` -- 服务端口 `:8080`;后台在 `http://localhost:8080/admin`(seed 管理员 `sundynix/sundynix`) - -## 快速开始 -```bash -# 1. 起依赖(MySQL + MinIO) -make env-up # 或 cd deployments && docker compose up -d - -# 2. 构建后台前端 + 后端单二进制 -make build # 产物 bin/pets-be(已 embed 后台) -./bin/pets-be - -# 或开发期直接运行(使用现有 internal/admin/dist) -make run -``` - -### 后台前端开发(热更新) -```bash -make admin-dev # vite dev server :5173,已代理 /api → :8080 -# 改完执行 make admin-build 让产物落到 internal/admin/dist,再 go build 即 embed -``` +核心功能:多宠物档案、日常照顾任务、多宠物遛狗与主动位置分享、约遛。后端用 Gin + GORM + PostgreSQL,管理端用 React + Vite + shadcn/ui + Tailwind,构建后通过 `go:embed` 随服务提供 `/admin/`。 ## 目录 -- `cmd/server` 入口:载配置→连库→迁移→seed→建桶→路由→启动 -- `internal/config` 配置(viper,支持 `PETS_` 环境变量覆盖) -- `internal/model` GORM 模型(`sundynix_` 前缀) -- `internal/database` 连接/迁移/seed -- `internal/storage` MinIO 封装(自动建桶 + 上传) -- `internal/service` 业务逻辑;`internal/handler` HTTP 处理器;`internal/router` 路由 -- `internal/middleware` CORS / 用户鉴权 / 管理员鉴权 -- `internal/admin` `go:embed` 内嵌后台产物 + SPA fallback -- `pkg/response` 统一响应与分页;`pkg/jwt` 令牌;`pkg/errcode` 错误码 -- `web/admin` 后台前端源码 -## 接口 -- 小程序 `/api/*`(用户 JWT):auth / user / pets / onboarding / records / tasks / plan / reminders / report / bill / community / articles / pro / ai / upload -- 后台 `/api/admin/*`(管理员 JWT):login / stats / users / pets / posts / comments / articles / memberships +- `cmd/server`:服务入口 +- `internal/config`:配置 +- `internal/model`:数据库模型 +- `internal/database`:PostgreSQL 连接和迁移 +- `internal/storage`:MinIO 文件存储 +- `internal/httpapi`:用户与管理端 API +- `web/admin`:管理端源码 +- `internal/admin/dist`:嵌入的管理端构建产物 -## 登录鉴权 -默认 `auth.dev_login: true`,`POST /api/auth/login {"nickname":"xxx"}` 即发用户 JWT。 -接真微信:在 `configs/config.yaml` 填 `wechat.app_secret` 并把 `dev_login` 置 false,实现 `/api/auth/wechat`(code2session)。 +## 本地启动 -### access token + refresh token -登录接口返回 `token`(access) 与 `refresh_token` 两个令牌: +1. `cp configs/config.example.yaml configs/config.yaml`,设置 PostgreSQL、MinIO、JWT 和微信参数。配置文件不进入 Git。 +2. `cd web/admin && npm ci && npm run build`。 +3. 若数据库还不存在,在 `pets-be` 目录执行一次 `go run ./cmd/dbinit`。 +4. 执行 `go run ./cmd/server`。 -- **access token**:JWT,默认 **2 小时**(`jwt.expire_hours`),放 `Authorization: Bearer`,服务端无状态校验。 -- **refresh token**:随机串,落库 `sundynix_refresh_tokens`(**只存 sha256**)。小程序 30 天、后台 7 天。 -- 续期:`POST /api/auth/refresh` / `POST /api/admin/refresh`,入参 `{"refresh_token":"..."}`,返回新的一对令牌。 -- 退出:`POST /api/auth/logout` / `POST /api/admin/logout` 作废该刷新令牌。 +默认业务接口 `http://localhost:9090/api`,管理端 `http://localhost:9090/admin/`。小程序生产 API 地址是 `https://pet.sundynix.cn/api`。 -安全约定: -- **每次续期都轮换**刷新令牌,旧的立即作废;作废后 60 秒内再到达算并发重试(放行),超过则判定泄露,**吊销该账号全部会话**。 -- 主动退出、被连坐吊销的令牌不享受宽限期。 -- 禁用用户时一并吊销其刷新令牌,最多 2 小时后彻底失去访问。 +配置可用 `PETS_` 前缀的环境变量覆盖,例如 `PETS_POSTGRES_PASSWORD`。生产模式必须设置 `PETS_ADMIN_PASSWORD`,微信登录需要 App ID 和 App Secret。`auth.dev_login` 只能在 debug 模式开启。 -前端两端都做了单飞续期:并发请求同时 401 只会发一次 refresh(否则刷新令牌会被并发轮换掉)。小程序续期失败会回退到 `wx.login` 重新登录。 +## 数据 + +GORM 使用 `sundynix_` 表前缀、单数 snake_case 表名和字段名,例如 `sundynix_user`。主键均为 PostgreSQL `uuid`,Go 字段为 `string`。文件内容存 MinIO;`sundynix_file` 存 bucket、object_key、md5、url 和元信息。头像与约遛封面只通过 `*_file_id` 关联文件表。上传会先按 MD5 查重。 + +现有 MySQL 数据未自动迁移。新的 PostgreSQL 表在服务启动时自动创建。正式切换旧数据前,应单独设计数据迁移映射并备份。 + +## 部署 + +`../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/`。`res.sundynix.cn/browser/...` 是 MinIO 控制台页面,不能作为图片直链。 diff --git a/pets-be/cmd/dbinit/main.go b/pets-be/cmd/dbinit/main.go new file mode 100644 index 0000000..d23130f --- /dev/null +++ b/pets-be/cmd/dbinit/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "log" + "regexp" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + + "github.com/sundynix/pets-be/internal/config" +) + +var databaseName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +// dbinit creates the configured PostgreSQL database once. It does not change other databases. +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatal(err) + } + name := cfg.Postgres.Database + if !databaseName.MatchString(name) { + log.Fatal("invalid database name") + } + maintenance := cfg.Postgres + maintenance.Database = "postgres" + db, err := gorm.Open(postgres.Open(maintenance.DSN()), &gorm.Config{}) + if err != nil { + log.Fatal(err) + } + var count int64 + if err := db.Raw("SELECT count(*) FROM pg_database WHERE datname = ?", name).Scan(&count).Error; err != nil { + log.Fatal(err) + } + if count > 0 { + log.Printf("database %s already exists", name) + return + } + if err := db.Exec(fmt.Sprintf("CREATE DATABASE \"%s\"", name)).Error; err != nil { + log.Fatal(err) + } + log.Printf("created database %s", name) +} diff --git a/pets-be/cmd/server/main.go b/pets-be/cmd/server/main.go index 648b518..0fc87a0 100644 --- a/pets-be/cmd/server/main.go +++ b/pets-be/cmd/server/main.go @@ -2,56 +2,40 @@ package main import ( "fmt" - "log" - - "github.com/sundynix/pets-be/internal/ai" + "github.com/gin-gonic/gin" + "github.com/sundynix/pets-be/internal/admin" "github.com/sundynix/pets-be/internal/config" "github.com/sundynix/pets-be/internal/database" - "github.com/sundynix/pets-be/internal/handler" - "github.com/sundynix/pets-be/internal/router" - "github.com/sundynix/pets-be/internal/service" + "github.com/sundynix/pets-be/internal/httpapi" "github.com/sundynix/pets-be/internal/storage" - appjwt "github.com/sundynix/pets-be/pkg/jwt" + "log" ) func main() { cfg, err := config.Load() if err != nil { - log.Fatalf("load config: %v", err) + log.Fatal(err) } - - db, err := database.New(cfg) + db, err := database.Open(cfg.Postgres, cfg.Server.Mode == "release") if err != nil { - log.Fatalf("connect mysql: %v", err) + log.Fatal(err) } if err := database.Migrate(db); err != nil { - log.Fatalf("migrate: %v", err) + log.Fatal(err) } - if err := database.Seed(db, cfg); err != nil { - log.Fatalf("seed: %v", err) - } - st, err := storage.New(cfg.MinIO) if err != nil { - log.Fatalf("connect minio: %v", err) + log.Fatal(err) } - - engine := ai.New(cfg.AI) - log.Printf("AI provider: %s", engine.ProviderName()) - - svc := service.New(db, st, cfg, engine) - if err := svc.SeedCommunityBot(); err != nil { - log.Fatalf("seed community bot: %v", err) + api := httpapi.New(db, st, cfg) + if err := api.SeedAdmin(); err != nil { + log.Fatal(err) } - svc.StartCommunityBot() - jm := appjwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours) - h := handler.New(svc, jm, cfg) - - r := router.New(h, jm, cfg.Server.Mode) - + gin.SetMode(cfg.Server.Mode) + r := gin.New() + api.Register(r) + admin.Register(r) addr := fmt.Sprintf(":%d", cfg.Server.Port) - log.Printf("pets-be listening on %s (admin: http://localhost%s/admin)", addr, addr) - if err := r.Run(addr); err != nil { - log.Fatalf("server run: %v", err) - } + log.Printf("肉垫计划 listening on %s", addr) + log.Fatal(r.Run(addr)) } diff --git a/pets-be/configs/config.example.yaml b/pets-be/configs/config.example.yaml index 4fe2865..e563072 100644 --- a/pets-be/configs/config.example.yaml +++ b/pets-be/configs/config.example.yaml @@ -1,43 +1,33 @@ server: port: 9090 - mode: debug # debug | release + mode: release -mysql: - host: 127.0.0.1 - port: 3306 +postgres: + host: 192.168.100.127 + port: 5432 user: root password: root - database: pets - charset: utf8mb4 + database: sundynix-pets + ssl_mode: disable minio: - endpoint: 127.0.0.1:9000 + endpoint: https://s3.sundynix.cn access_key: sundynix secret_key: sundynix - bucket: pets - use_ssl: false - public_base_url: http://127.0.0.1:9000 # 拼接可访问的对象 URL + bucket: sundynix-pet + use_ssl: true jwt: - secret: change-me-to-a-long-random-string # 生产用 PETS_JWT_SECRET 覆盖 + secret: "" # 用 PETS_JWT_SECRET 注入 + expire_hours: 168 wechat: - app_id: your-wechat-app-id - app_secret: your-wechat-app-secret # 敏感凭证:生产用 PETS_WECHAT_APP_SECRET 环境变量覆盖 + app_id: "" + app_secret: "" auth: - dev_login: true # 仍保留 Mock 登录便于本地 curl 调试;上线可置 false 只留微信登录 + dev_login: false admin: username: sundynix - password: change-me # 启动时若无同名管理员则 seed 此账号 - -ai: - enabled: false # false 时全部走规则化文案(不烧 token);接大模型改 true - provider: openai # openai 兼容协议,适配 DeepSeek/Qwen(兼容模式)/Gemini(兼容端点)等 - base_url: https://api.deepseek.com # 换供应商只改这里 + model - api_key: your-ai-api-key # 敏感:生产用 PETS_AI_API_KEY 环境变量覆盖 - model: deepseek-chat - temperature: 0.6 - max_tokens: 1024 - timeout_sec: 30 + password: sundynix diff --git a/pets-be/configs/config.yaml b/pets-be/configs/config.yaml new file mode 100644 index 0000000..361ab1d --- /dev/null +++ b/pets-be/configs/config.yaml @@ -0,0 +1,32 @@ +server: + port: 9090 + mode: debug # debug | release + +postgres: + host: 192.168.100.127 + port: 5432 + user: root + password: root + database: sundynix-pets + ssl_mode: disable + +minio: + endpoint: https://s3.sundynix.cn + access_key: sundynix + secret_key: sundynix + bucket: sundynix-pet + use_ssl: true + +jwt: + secret: kFdKDNCXpyJO2W+htdxobhHNzUIYuwcaZV94aJbIGHQ= # 生产用 PETS_JWT_SECRET 覆盖 + +wechat: + app_id: wx52dfc635739a9c19 + app_secret: b3c78abf7bcdd5b4e3c444f9e6caad3f # 敏感凭证:生产用 PETS_WECHAT_APP_SECRET 环境变量覆盖 + +auth: + dev_login: true # 仍保留 Mock 登录便于本地 curl 调试;上线可置 false 只留微信登录 + +admin: + username: sundynix + password: sundynix diff --git a/pets-be/deployments/docker-compose.yml b/pets-be/deployments/docker-compose.yml deleted file mode 100644 index 2719fa3..0000000 --- a/pets-be/deployments/docker-compose.yml +++ /dev/null @@ -1,45 +0,0 @@ -services: - mysql: - image: mysql:8.0 - container_name: pets-mysql - restart: unless-stopped - environment: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: pets - TZ: Asia/Shanghai - command: - - --character-set-server=utf8mb4 - - --collation-server=utf8mb4_unicode_ci - - --default-authentication-plugin=mysql_native_password - ports: - - "3306:3306" - volumes: - - mysql_data:/var/lib/mysql - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"] - interval: 5s - timeout: 5s - retries: 20 - - minio: - image: minio/minio:latest - container_name: pets-minio - restart: unless-stopped - environment: - MINIO_ROOT_USER: sundynix - MINIO_ROOT_PASSWORD: sundynix - command: server /data --console-address ":9001" - ports: - - "9000:9000" - - "9001:9001" - volumes: - - minio_data:/data - healthcheck: - test: ["CMD", "mc", "ready", "local"] - interval: 5s - timeout: 5s - retries: 20 - -volumes: - mysql_data: - minio_data: diff --git a/pets-be/go.mod b/pets-be/go.mod index 62975bc..8ac2fc2 100644 --- a/pets-be/go.mod +++ b/pets-be/go.mod @@ -1,20 +1,19 @@ module github.com/sundynix/pets-be -go 1.26.4 +go 1.26.0 require ( github.com/gin-gonic/gin v1.12.0 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/spf13/viper v1.21.0 golang.org/x/crypto v0.53.0 - gorm.io/datatypes v1.2.7 - gorm.io/driver/mysql v1.6.0 + gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.2 ) require ( - filippo.io/edwards25519 v1.1.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect @@ -27,11 +26,13 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect - github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -63,6 +64,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect google.golang.org/protobuf v1.36.10 // indirect diff --git a/pets-be/go.sum b/pets-be/go.sum index e3879c0..e8b48d7 100644 --- a/pets-be/go.sum +++ b/pets-be/go.sum @@ -1,5 +1,3 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -33,8 +31,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= -github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= -github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= @@ -43,10 +39,6 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= -github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= -github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= -github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -54,12 +46,12 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= -github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= -github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -83,8 +75,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= -github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= @@ -127,6 +117,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -174,15 +165,9 @@ gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk= -gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY= -gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= -gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= -gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U= -gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= -gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc= -gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/pets-be/internal/admin/dist/assets/index-BQA5NViF.css b/pets-be/internal/admin/dist/assets/index-BQA5NViF.css new file mode 100644 index 0000000..90d4029 --- /dev/null +++ b/pets-be/internal/admin/dist/assets/index-BQA5NViF.css @@ -0,0 +1 @@ +*,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border:0 solid #e5e7eb}:before,:after{--tw-content:""}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5}body{line-height:inherit;margin:0}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-feature-settings:inherit;font-variation-settings:inherit;font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:#0000;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{margin:0;padding:0;list-style:none}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder{opacity:1;color:#9ca3af}textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.left-1\/2{left:50%}.right-4{right:1rem}.top-1\/2{top:50%}.top-4{top:1rem}.z-50{z-index:50}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-10{height:2.5rem}.h-4{height:1rem}.h-8{height:2rem}.h-9{height:2.25rem}.w-4{width:1rem}.w-9{width:2.25rem}.w-full{width:100%}.max-w-lg{max-width:32rem}.caption-bottom{caption-side:bottom}.-translate-x-1\/2{--tw-translate-x:-50%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.gap-2{gap:.5rem}.gap-4{gap:1rem}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.whitespace-nowrap{white-space:nowrap}.rounded-full{border-radius:9999px}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-input{border-color:hsl(var(--input))}.border-transparent{border-color:#0000}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-black\/40{background-color:#0006}.bg-card{background-color:hsl(var(--card))}.bg-destructive{background-color:hsl(var(--destructive))}.bg-destructive\/15{background-color:hsl(var(--destructive) / .15)}.bg-emerald-100{--tw-bg-opacity:1;background-color:rgb(209 250 229/var(--tw-bg-opacity,1))}.bg-muted{background-color:hsl(var(--muted))}.bg-primary{background-color:hsl(var(--primary))}.bg-primary\/15{background-color:hsl(var(--primary) / .15)}.bg-secondary{background-color:hsl(var(--secondary))}.p-3{padding:.75rem}.p-6{padding:1.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.pt-0{padding-top:0}.text-left{text-align:left}.align-middle{vertical-align:middle}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.leading-none{line-height:1}.tracking-tight{letter-spacing:-.025em}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-card-foreground{color:hsl(var(--card-foreground))}.text-destructive{color:hsl(var(--destructive))}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-foreground{color:hsl(var(--foreground))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.underline-offset-4{text-underline-offset:4px}.opacity-70{opacity:.7}.shadow{--tw-shadow:0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a;--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.outline{outline-style:solid}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}:root{--background:150 18% 97%;--foreground:166 48% 18%;--card:0 0% 100%;--card-foreground:166 48% 18%;--primary:166 58% 21%;--primary-foreground:0 0% 100%;--secondary:145 22% 92%;--secondary-foreground:166 48% 18%;--muted:145 12% 94%;--muted-foreground:150 9% 51%;--accent:145 22% 92%;--accent-foreground:166 48% 18%;--destructive:5 70% 56%;--destructive-foreground:0 0% 100%;--border:145 16% 86%;--input:145 16% 86%;--ring:166 58% 21%;--radius:.8rem}*{border-color:hsl(var(--border))}body{color:#193f37;background:#f6f8f5;margin:0;font-family:-apple-system,BlinkMacSystemFont,PingFang SC,Microsoft YaHei,sans-serif}.app-shell{min-height:100vh;display:flex}.sidebar{color:#fff;background:#194d41;flex-direction:column;flex:none;width:242px;padding:30px 18px;display:flex}.brand{align-items:center;gap:12px;padding:5px 12px 38px;display:flex}.brand>span{background:#e9aa6d;border-radius:16px;padding:8px;font-size:28px}.brand strong{font-size:19px;display:block}.brand small{letter-spacing:.22em;opacity:.56;margin-top:2px;font-size:10px;display:block}.side-caption{color:#9abcb0;letter-spacing:.2em;padding:0 14px;font-size:11px}.sidebar nav{gap:7px;display:grid}.nav-item{color:#c2d9cf;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:13px;align-items:center;gap:13px;padding:13px 15px;display:flex}.nav-item:hover,.nav-item.active{color:#fff;background:#336a5c}.side-bottom{color:#8ab5a4;margin-top:auto;padding:12px;font-size:12px}.side-bottom button{color:#d4e6dc;cursor:pointer;background:0 0;border:0;align-items:center;gap:8px;margin-top:18px;display:flex}.content{width:100%;max-width:1500px;padding:40px 48px}.topbar{justify-content:space-between;align-items:center;margin-bottom:28px;display:flex}.eyebrow{letter-spacing:.18em;color:#74a28b;margin:0 0 8px;font-size:11px;font-weight:700}.topbar h1{margin:0;font-size:30px}.hero{color:#fff;background:#194d41;border-radius:27px;justify-content:space-between;align-items:center;min-height:196px;padding:34px 40px;display:flex;overflow:hidden}.hero p{color:#a9d7bc;margin:0 0 10px}.hero h2{margin:0 0 12px;font-size:27px}.hero span{color:#c9e0d3}.hero-paw{text-align:center;background:#edbe8b;border-radius:50%;width:152px;height:152px;font-size:90px;line-height:152px;transform:rotate(-9deg)}.stat-grid{grid-template-columns:repeat(4,1fr);gap:16px;margin-top:22px;display:grid}.stat-card,.table-card{background:#fff;border:1px solid #e7ede7;border-radius:23px;box-shadow:0 12px 40px #214d3f0a}.stat-card{padding:23px}.stat-icon{margin-bottom:16px;font-size:27px}.stat-card span{color:#82988e;font-size:13px;display:block}.stat-card strong{font-size:34px}.table-head{padding:26px 28px 16px}.table-head h2{margin:0;font-size:20px}.table-head p{color:#87998f;font-size:13px}.table-scroll{overflow:auto}table{border-collapse:collapse;width:100%;min-width:800px}th,td{text-align:left;white-space:nowrap;border-top:1px solid #edf0ec;padding:16px 24px;font-size:13px}th{color:#81978b;background:#fbfcfa;font-weight:600}td b{display:block}td small{color:#9aada2;margin-top:3px;font-size:11px;display:block}.mono{color:#71877d;font-size:11px}.pill{border-radius:99px;padding:5px 9px;font-size:11px}.pill.green{color:#258052;background:#e8f5ec}.pill.red{color:#ba6b51;background:#fff0eb}.empty{text-align:center;color:#91a397;padding:55px}.error-banner,.error{color:#a64d37;background:#fff0eb;border-radius:12px;padding:11px}.login-page{background:radial-gradient(circle at 75% 10%,#e9f1e7,#0000 35%),#f6f8f5;place-items:center;min-height:100vh;display:grid}.login-card{background:#fff;border-radius:28px;width:min(420px,100% - 40px);padding:40px;box-shadow:0 25px 70px #204a3a18}.login-mark{background:#e9aa6d;border-radius:19px;place-items:center;width:60px;height:60px;margin-bottom:22px;font-size:30px;display:grid}.login-card h1{margin:0;font-size:30px}.muted{color:#90a198;margin:8px 0 30px}.login-card label{color:#456a5b;gap:7px;margin-bottom:18px;font-size:13px;display:grid}.login-card input{margin-top:2px}.login-card button{margin-top:8px}@media (width<=800px){.sidebar{width:70px;padding:20px 8px}.brand div,.side-caption,.nav-item:not(.active),.nav-item.active{font-size:0}.brand{padding:5px 5px 30px}.nav-item{justify-content:center}.side-bottom{display:none}.content{padding:28px 18px}.stat-grid{grid-template-columns:repeat(2,1fr)}.hero-paw{display:none}}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-destructive\/90:hover{background-color:hsl(var(--destructive) / .9)}.hover\:bg-muted\/50:hover{background-color:hsl(var(--muted) / .5)}.hover\:bg-primary\/90:hover{background-color:hsl(var(--primary) / .9)}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary) / .8)}.hover\:text-accent-foreground:hover{color:hsl(var(--accent-foreground))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.focus-visible\:outline-none:focus-visible{outline-offset:2px;outline:2px solid #0000}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:hsl(var(--ring))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-width:0}.\[\&_tr\]\:border-b tr{border-bottom-width:1px} diff --git a/pets-be/internal/admin/dist/assets/index-DmUsbLpt.js b/pets-be/internal/admin/dist/assets/index-DmUsbLpt.js new file mode 100644 index 0000000..ea2733d --- /dev/null +++ b/pets-be/internal/admin/dist/assets/index-DmUsbLpt.js @@ -0,0 +1,19 @@ +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var u=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var S=Array.isArray;function C(){}var w={H:null,A:null,T:null,S:null},T=Object.prototype.hasOwnProperty;function ee(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function te(e,t){return ee(e.type,t,e.props)}function ne(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function E(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var D=/\/+/g;function O(e,t){return typeof e==`object`&&e&&e.key!=null?E(``+e.key):t.toString(36)}function re(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(C,C):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function ie(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,ie(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+O(e,0):a,S(o)?(i=``,c!=null&&(i=c.replace(D,`$&/`)+`/`),ie(o,r,i,``,function(e){return e})):o!=null&&(ne(o)&&(o=te(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(D,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(S(e))for(var u=0;u{t.exports=u()})),f=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,S||(S=!0,ne());else{var t=n(l);t!==null&&O(x,t.startTime-e)}}var S=!1,C=-1,w=5,T=-1;function ee(){return g?!0:!(e.unstable_now()-Tt&&ee());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&O(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?ne():S=!1}}}var ne;if(typeof y==`function`)ne=function(){y(te)};else if(typeof MessageChannel<`u`){var E=new MessageChannel,D=E.port2;E.port1.onmessage=te,ne=function(){D.postMessage(null)}}else ne=function(){_(te,0)};function O(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,O(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,ne()))),r},e.unstable_shouldYield=ee,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),p=o(((e,t)=>{t.exports=f()})),m=o((e=>{var t=d();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=m()})),g=o((e=>{var t=p(),n=d(),r=h();function i(e){var t=`https://react.dev/errors/`+e;if(1le||(e.current=ce[le],ce[le]=null,le--)}function N(e,t){le++,ce[le]=e.current,e.current=t}var ue=j(null),de=j(null),fe=j(null),pe=j(null);function me(e,t){switch(N(fe,t),N(de,e),N(ue,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Vd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Vd(t),e=Hd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}M(ue),N(ue,e)}function he(){M(ue),M(de),M(fe)}function ge(e){e.memoizedState!==null&&N(pe,e);var t=ue.current,n=Hd(t,e.type);t!==n&&(N(de,e),N(ue,n))}function _e(e){de.current===e&&(M(ue),M(de)),pe.current===e&&(M(pe),Qf._currentValue=se)}var ve,ye;function be(e){if(ve===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);ve=t&&t[1]||``,ye=-1)`:-1i||c[r]!==l[i]){var u=` +`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{xe=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?be(n):``}function Ce(e,t){switch(e.tag){case 26:case 27:case 5:return be(e.type);case 16:return be(`Lazy`);case 13:return e.child!==t&&t!==null?be(`Suspense Fallback`):be(`Suspense`);case 19:return be(`SuspenseList`);case 0:case 15:return Se(e.type,!1);case 11:return Se(e.type.render,!1);case 1:return Se(e.type,!0);case 31:return be(`Activity`);default:return``}}function we(e){try{var t=``,n=null;do t+=Ce(e,n),n=e,e=e.return;while(e);return t}catch(e){return` +Error generating stack: `+e.message+` +`+e.stack}}var Te=Object.prototype.hasOwnProperty,Ee=t.unstable_scheduleCallback,De=t.unstable_cancelCallback,Oe=t.unstable_shouldYield,ke=t.unstable_requestPaint,Ae=t.unstable_now,je=t.unstable_getCurrentPriorityLevel,Me=t.unstable_ImmediatePriority,Ne=t.unstable_UserBlockingPriority,Pe=t.unstable_NormalPriority,Fe=t.unstable_LowPriority,Ie=t.unstable_IdlePriority,Le=t.log,Re=t.unstable_setDisableYieldValue,ze=null,Be=null;function Ve(e){if(typeof Le==`function`&&Re(e),Be&&typeof Be.setStrictMode==`function`)try{Be.setStrictMode(ze,e)}catch{}}var He=Math.clz32?Math.clz32:Ge,Ue=Math.log,We=Math.LN2;function Ge(e){return e>>>=0,e===0?32:31-(Ue(e)/We|0)|0}var Ke=256,qe=262144,Je=4194304;function Ye(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Xe(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Ye(n))):i=Ye(o):i=Ye(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Ye(n))):i=Ye(o)):i=Ye(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function Ze(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Qe(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function $e(){var e=Je;return Je<<=1,!(Je&62914560)&&(Je=4194304),e}function et(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function tt(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function nt(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),mn=!1;if(pn)try{var hn={};Object.defineProperty(hn,"passive",{get:function(){mn=!0}}),window.addEventListener(`test`,hn,hn),window.removeEventListener(`test`,hn,hn)}catch{mn=!1}var gn=null,_n=null,vn=null;function yn(){if(vn)return vn;var e,t=_n,n=t.length,r,i=`value`in gn?gn.value:gn.textContent,a=i.length;for(e=0;e=Zn),er=` `,tr=!1;function nr(e,t){switch(e){case`keyup`:return Yn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function rr(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var ir=!1;function ar(e,t){switch(e){case`compositionend`:return rr(t);case`keypress`:return t.which===32?(tr=!0,er):null;case`textInput`:return e=t.data,e===er&&tr?null:e;default:return null}}function or(e,t){if(ir)return e===`compositionend`||!Xn&&nr(e,t)?(e=yn(),vn=_n=gn=null,ir=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Or(n)}}function Ar(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ar(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function jr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Bt(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Bt(e.document)}return t}function Mr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Nr=pn&&`documentMode`in document&&11>=document.documentMode,Pr=null,Fr=null,Ir=null,Lr=!1;function Rr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Lr||Pr==null||Pr!==Bt(r)||(r=Pr,`selectionStart`in r&&Mr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Ir&&Dr(Ir,r)||(Ir=r,r=Ed(Fr,`onSelect`),0>=o,i-=o,Di=1<<32-He(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),B&&ki(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),B&&ki(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return B&&ki(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),B&&ki(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===y&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case _:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===y){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===ne&&Ea(l)===r.type){n(e,r.sibling),c=a(r,o.props),Na(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===y?(c=I(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=hi(o.type,o.key,o.props,null,e.mode,c),Na(c,o),c.return=e,e=c)}return s(e);case v:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=vi(o,e.mode,c),c.return=e,e=c}return s(e);case ne:return o=Ea(o),b(e,r,o,c)}if(oe(o))return h(e,r,o,c);if(re(o)){if(l=re(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Ma(o),c);if(o.$$typeof===C)return b(e,r,ea(e,o),c);Pa(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=gi(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{ja=0;var i=b(e,t,n,r);return Aa=null,i}catch(t){if(t===ba||t===Sa)throw t;var a=di(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Ia=Fa(!0),La=Fa(!1),Ra=!1;function za(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ba(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Va(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Ha(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,K&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=ci(e),si(e,null,n),t}return ai(e,r,t,n),ci(e)}function Ua(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,it(e,n)}}function Wa(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var Ga=!1;function Ka(){if(Ga){var e=da;if(e!==null)throw e}}function qa(e,t,n,r){Ga=!1;var i=e.updateQueue;Ra=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(Y&f)===f:(r&f)===f){f!==0&&f===ua&&(Ga=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var h=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(h=g.payload,typeof h==`function`){d=h.call(_,d,f);break a}d=h;break a;case 3:h.flags=h.flags&-65537|128;case 0:if(h=g.payload,f=typeof h==`function`?h.call(_,d,f):h,f==null)break a;d=m({},d,f);break a;case 2:Ra=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Gl|=o,e.lanes=o,e.memoizedState=d}}function Ja(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function Ya(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=k.T,s={};k.T=s,Ms(e,!1,t,n);try{var c=i(),l=k.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?js(e,t,ma(c,r),pu(e)):js(e,t,r,pu(e))}catch(n){js(e,t,{then:function(){},status:`rejected`,reason:n},pu())}finally{A.p=a,o!==null&&s.types!==null&&(o.types=s.types),k.T=o}}function xs(){}function Ss(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=Cs(e).queue;bs(e,a,t,se,n===null?xs:function(){return ws(e),n(r)})}function Cs(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:se,baseState:se,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Po,lastRenderedState:se},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Po,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function ws(e){var t=Cs(e);t.next===null&&(t=e.alternate.memoizedState),js(e,t.next.queue,{},pu())}function Ts(){return $i(Qf)}function Es(){return ko().memoizedState}function Ds(){return ko().memoizedState}function Os(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=pu();e=Va(n);var r=Ha(t,e,n);r!==null&&(hu(r,t,n),Ua(r,t,n)),t={cache:oa()},e.payload=t;return}t=t.return}}function ks(e,t,n){var r=pu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Ns(e)?Ps(t,n):(n=oi(e,t,n,r),n!==null&&(hu(n,e,r),Fs(n,t,r)))}function As(e,t,n){js(e,t,n,pu())}function js(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Ns(e))Ps(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Er(s,o))return ai(e,t,i,0),q===null&&ii(),!1}catch{}if(n=oi(e,t,i,r),n!==null)return hu(n,e,r),Fs(n,t,r),!0}return!1}function Ms(e,t,n,r){if(r={lane:2,revertLane:dd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},Ns(e)){if(t)throw Error(i(479))}else t=oi(e,n,r,2),t!==null&&hu(t,e,2)}function Ns(e){var t=e.alternate;return e===H||t!==null&&t===H}function Ps(e,t){mo=po=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Fs(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,it(e,n)}}var Is={readContext:$i,use:Mo,useCallback:yo,useContext:yo,useEffect:yo,useImperativeHandle:yo,useLayoutEffect:yo,useInsertionEffect:yo,useMemo:yo,useReducer:yo,useRef:yo,useState:yo,useDebugValue:yo,useDeferredValue:yo,useTransition:yo,useSyncExternalStore:yo,useId:yo,useHostTransitionStatus:yo,useFormState:yo,useActionState:yo,useOptimistic:yo,useMemoCache:yo,useCacheRefresh:yo};Is.useEffectEvent=yo;var Ls={readContext:$i,use:Mo,useCallback:function(e,t){return Oo().memoizedState=[e,t===void 0?null:t],e},useContext:$i,useEffect:ss,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),as(4194308,4,ps.bind(null,t,e),n)},useLayoutEffect:function(e,t){return as(4194308,4,e,t)},useInsertionEffect:function(e,t){as(4,2,e,t)},useMemo:function(e,t){var n=Oo();t=t===void 0?null:t;var r=e();if(ho){Ve(!0);try{e()}finally{Ve(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Oo();if(n!==void 0){var i=n(t);if(ho){Ve(!0);try{n(t)}finally{Ve(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=ks.bind(null,H,e),[r.memoizedState,e]},useRef:function(e){var t=Oo();return e={current:e},t.memoizedState=e},useState:function(e){e=Uo(e);var t=e.queue,n=As.bind(null,H,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:hs,useDeferredValue:function(e,t){return vs(Oo(),e,t)},useTransition:function(){var e=Uo(!1);return e=bs.bind(null,H,e.queue,!0,!1),Oo().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=H,a=Oo();if(B){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),q===null)throw Error(i(349));Y&127||Ro(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,ss(Bo.bind(null,r,o,e),[e]),r.flags|=2048,rs(9,{destroy:void 0},zo.bind(null,r,o,n,t),null),n},useId:function(){var e=Oo(),t=q.identifierPrefix;if(B){var n=Oi,r=Di;n=(r&~(1<<32-He(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=W++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[P]=t,o[dt]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Pd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&jc(t)}}return Ic(t),Mc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&jc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=fe.current,Bi(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=R,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[P]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||Md(e.nodeValue,n)),e||Li(t,!0)}else e=Bd(e).createTextNode(r),e[P]=t,t.stateNode=e}return Ic(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Bi(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[P]=t}else Vi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Ic(t),e=!1}else n=Hi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(so(t),t):(so(t),null);if(t.flags&128)throw Error(i(558))}return Ic(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Bi(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[P]=t}else Vi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Ic(t),a=!1}else a=Hi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(so(t),t):(so(t),null)}return so(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Pc(t,t.updateQueue),Ic(t),null);case 4:return he(),e===null&&Sd(t.stateNode.containerInfo),Ic(t),null;case 10:return Ji(t.type),Ic(t),null;case 19:if(M(co),r=t.memoizedState,r===null)return Ic(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Fc(r,!1);else{if(Wl!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=lo(e),o!==null){for(t.flags|=128,Fc(r,!1),e=o.updateQueue,t.updateQueue=e,Pc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)mi(n,e),n=n.sibling;return N(co,co.current&1|2),B&&ki(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Ae()>tu&&(t.flags|=128,a=!0,Fc(r,!1),t.lanes=4194304)}else{if(!a)if(e=lo(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,Pc(t,e),Fc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!B)return Ic(t),null}else 2*Ae()-r.renderingStartTime>tu&&n!==536870912&&(t.flags|=128,a=!0,Fc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(Ic(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Ae(),e.sibling=null,n=co.current,N(co,a?n&1|2:n&1),B&&ki(t,r.treeForkCount),e);case 22:case 23:return so(t),eo(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(Ic(t),t.subtreeFlags&6&&(t.flags|=8192)):Ic(t),n=t.updateQueue,n!==null&&Pc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&M(ga),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Ji(aa),Ic(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Rc(e,t){switch(Mi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Ji(aa),he(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return _e(t),null;case 31:if(t.memoizedState!==null){if(so(t),t.alternate===null)throw Error(i(340));Vi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(so(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Vi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return M(co),null;case 4:return he(),null;case 10:return Ji(t.type),null;case 22:case 23:return so(t),eo(),e!==null&&M(ga),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Ji(aa),null;case 25:return null;default:return null}}function zc(e,t){switch(Mi(t),t.tag){case 3:Ji(aa),he();break;case 26:case 27:case 5:_e(t);break;case 4:he();break;case 31:t.memoizedState!==null&&so(t);break;case 13:so(t);break;case 19:M(co);break;case 10:Ji(t.type);break;case 22:case 23:so(t),eo(),e!==null&&M(ga);break;case 24:Ji(aa)}}function Bc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Z(t,t.return,e)}}function Vc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Z(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Z(t,t.return,e)}}function Hc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Ya(t,n)}catch(t){Z(e,e.return,t)}}}function Uc(e,t,n){n.props=Ws(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Z(e,t,n)}}function Wc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Z(e,t,n)}}function Gc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Z(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Z(e,t,n)}else n.current=null}function Kc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Z(e,e.return,t)}}function qc(e,t,n){try{var r=e.stateNode;Fd(r,e.type,n,t),r[dt]=t}catch(t){Z(e,e.return,t)}}function Jc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Zd(e.type)||e.tag===4}function Yc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Jc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Zd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Xc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=rn));else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(Xc(e,t,n),e=e.sibling;e!==null;)Xc(e,t,n),e=e.sibling}function Zc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(Zc(e,t,n),e=e.sibling;e!==null;)Zc(e,t,n),e=e.sibling}function Qc(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Pd(t,r,n),t[P]=e,t[dt]=n}catch(t){Z(e,e.return,t)}}var $c=!1,el=!1,tl=!1,nl=typeof WeakSet==`function`?WeakSet:Set,rl=null;function il(e,t){if(e=e.containerInfo,Rd=sp,e=jr(e),Mr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(zd={focusedElem:e,selectionRange:n},sp=!1,rl=t;rl!==null;)if(t=rl,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,rl=e;else for(;rl!==null;){switch(t=rl,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Pd(o,r,n),o[P]=e,Ct(o),r=o;break a;case`link`:var s=Vf(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=kr(s,h),v=kr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,k.T=null,n=lu,lu=null;var o=au,s=su;if(iu=0,ou=au=null,su=0,K&6)throw Error(i(331));var c=K;if(K|=4,Pl(o.current),El(o,o.current,s,n),K=c,id(0,!1),Be&&typeof Be.onPostCommitFiberRoot==`function`)try{Be.onPostCommitFiberRoot(ze,o)}catch{}return!0}finally{A.p=a,k.T=r,Vu(e,t)}}function Wu(e,t,n){t=bi(n,t),t=Xs(e.stateNode,t,2),e=Ha(e,t,2),e!==null&&(tt(e,2),rd(e))}function Z(e,t,n){if(e.tag===3)Wu(e,e,n);else for(;t!==null;){if(t.tag===3){Wu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(ru===null||!ru.has(r))){e=bi(n,e),n=Zs(2),r=Ha(t,n,2),r!==null&&(Qs(n,r,t,e),tt(r,2),rd(r));break}}t=t.return}}function Gu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Rl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Hl=!0,i.add(n),e=Ku.bind(null,e,t,n),t.then(e,e))}function Ku(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,q===e&&(Y&n)===n&&(Wl===4||Wl===3&&(Y&62914560)===Y&&300>Ae()-$l?!(K&2)&&Su(e,0):ql|=n,Yl===Y&&(Yl=0)),rd(e)}function qu(e,t){t===0&&(t=$e()),e=F(e,t),e!==null&&(tt(e,t),rd(e))}function Ju(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),qu(e,n)}function Yu(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),qu(e,n)}function Xu(e,t){return Ee(e,t)}var Zu=null,Qu=null,$u=!1,ed=!1,td=!1,nd=0;function rd(e){e!==Qu&&e.next===null&&(Qu===null?Zu=Qu=e:Qu=Qu.next=e),ed=!0,$u||($u=!0,ud())}function id(e,t){if(!td&&ed){td=!0;do for(var n=!1,r=Zu;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-He(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,ld(r,a))}else a=Y,a=Xe(r,r===q?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||Ze(r,a)||(n=!0,ld(r,a));r=r.next}while(n);td=!1}}function ad(){od()}function od(){ed=$u=!1;var e=0;nd!==0&&Gd()&&(e=nd);for(var t=Ae(),n=null,r=Zu;r!==null;){var i=r.next,a=sd(r,t);a===0?(r.next=null,n===null?Zu=i:n.next=i,i===null&&(Qu=n)):(n=r,(e!==0||a&3)&&(ed=!0)),r=i}iu!==0&&iu!==5||id(e,!1),nd!==0&&(nd=0)}function sd(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Id(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function xf(e,t,n){var r=bf;if(r&&typeof t==`string`&&t){var i=Ht(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),hf.has(i)||(hf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Pd(t,`link`,e),Ct(t),r.head.appendChild(t)))}}function Sf(e){_f.D(e),xf(`dns-prefetch`,e,null)}function Cf(e,t){_f.C(e,t),xf(`preconnect`,e,t)}function wf(e,t,n){_f.L(e,t,n);var r=bf;if(r&&e&&t){var i=`link[rel="preload"][as="`+Ht(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+Ht(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+Ht(n.imageSizes)+`"]`)):i+=`[href="`+Ht(e)+`"]`;var a=i;switch(t){case`style`:a=Af(e);break;case`script`:a=Pf(e)}mf.has(a)||(e=m({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),mf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(jf(a))||t===`script`&&r.querySelector(Ff(a))||(t=r.createElement(`link`),Pd(t,`link`,e),Ct(t),r.head.appendChild(t)))}}function Tf(e,t){_f.m(e,t);var n=bf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+Ht(r)+`"][href="`+Ht(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Pf(e)}if(!mf.has(a)&&(e=m({rel:`modulepreload`,href:e},t),mf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Ff(a)))return}r=n.createElement(`link`),Pd(r,`link`,e),Ct(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=St(r).hoistableStyles,a=Af(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(jf(a)))s.loading=5;else{e=m({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=mf.get(a))&&Rf(e,n);var c=o=r.createElement(`link`);Ct(c),Pd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Lf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Df(e,t){_f.X(e,t);var n=bf;if(n&&e){var r=St(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=m({src:e,async:!0},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),Ct(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Of(e,t){_f.M(e,t);var n=bf;if(n&&e){var r=St(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=m({src:e,async:!0,type:`module`},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),Ct(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t,n,r){var a=(a=fe.current)?gf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Af(n.href),n=St(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Af(n.href);var o=St(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(jf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),mf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},mf.set(e,n),o||Nf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Pf(n),n=St(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function Af(e){return`href="`+Ht(e)+`"`}function jf(e){return`link[rel="stylesheet"][`+e+`]`}function Mf(e){return m({},e,{"data-precedence":e.precedence,precedence:null})}function Nf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Pd(t,`link`,n),Ct(t),e.head.appendChild(t))}function Pf(e){return`[src="`+Ht(e)+`"]`}function Ff(e){return`script[async]`+e}function If(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+Ht(n.href)+`"]`);if(r)return t.instance=r,Ct(r),r;var a=m({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),Ct(r),Pd(r,`style`,a),Lf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=Af(n.href);var o=e.querySelector(jf(a));if(o)return t.state.loading|=4,t.instance=o,Ct(o),o;r=Mf(n),(a=mf.get(a))&&Rf(r,a),o=(e.ownerDocument||e).createElement(`link`),Ct(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Pd(o,`link`,r),t.state.loading|=4,Lf(o,n.precedence,e),t.instance=o;case`script`:return o=Pf(n.src),(a=e.querySelector(Ff(o)))?(t.instance=a,Ct(a),a):(r=n,(a=mf.get(o))&&(r=m({},n),zf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),Ct(a),Pd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Lf(r,n.precedence,e));return t.instance}function Lf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Uf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Wf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Gf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Af(r.href),a=t.querySelector(jf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Jf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,Ct(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),Ct(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Jf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Kf=0;function qf(e,t){return e.stylesheets&&e.count===0&&Xf(e,e.stylesheets),0Kf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Jf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Xf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Yf=null;function Xf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Yf=new Map,t.forEach(Zf,e),Yf=null,Jf.call(e))}function Zf(e,t){if(!(t.state.loading&4)){var n=Yf.get(e);if(n)var r=n.get(null);else{n=new Map,Yf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=g()})),v=`modulepreload`,y=function(e){return`/admin/`+e},b={},x=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,new URL(`../../../src/node/plugins/importAnalysisBuild.ts`,import.meta.url)).href}r=o(t.map(t=>{if(t=y(t,n),t=s(t),t in b)return;b[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:v,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},S=l(d(),1),C=/^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i,w=/^[\\/]{2}/;function T(e,t){return t+e.replace(/\\/g,`/`)}var ee=`popstate`;function te(e){return typeof e==`object`&&!!e&&`pathname`in e&&`search`in e&&`hash`in e&&`state`in e&&`key`in e}function ne(e={}){function t(e,t){let n=t.state?.masked,{pathname:r,search:i,hash:a}=n||e.location;return ie(``,{pathname:r,search:i,hash:a},t.state&&t.state.usr||null,t.state&&t.state.key||`default`,n?{pathname:e.location.pathname,search:e.location.search,hash:e.location.hash}:void 0)}function n(e,t){return typeof t==`string`?t:ae(t)}return k(t,n,null,e)}function E(e,t){if(e===!1||e==null)throw Error(t)}function D(e,t){if(!e){typeof console<`u`&&console.warn(t);try{throw Error(t)}catch{}}}function O(){return Math.random().toString(36).substring(2,10)}function re(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function ie(e,t,n=null,r,i){return{pathname:typeof e==`string`?e:e.pathname,search:``,hash:``,...typeof t==`string`?oe(t):t,state:n,key:t&&t.key||r||O(),mask:i}}function ae({pathname:e=`/`,search:t=``,hash:n=``}){return t&&t!==`?`&&(e+=t.charAt(0)===`?`?t:`?`+t),n&&n!==`#`&&(e+=n.charAt(0)===`#`?n:`#`+n),e}function oe(e){let t={};if(e){let n=e.indexOf(`#`);n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf(`?`);r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function k(e,t,n,r={}){let{window:i=document.defaultView,v5Compat:a=!1}=r,o=i.history,s=`POP`,c=null,l=u();l??(l=0,o.replaceState({...o.state,idx:l},``));function u(){return(o.state||{idx:null}).idx}function d(){s=`POP`;let e=u(),t=e==null?null:e-l;l=e,c&&c({action:s,location:h.location,delta:t})}function f(e,t){s=`PUSH`;let r=te(e)?e:ie(h.location,e,t);n&&n(r,e),l=u()+1;let d=re(r,l),f=h.createHref(r.mask||r);try{o.pushState(d,``,f)}catch(e){if(e instanceof DOMException&&e.name===`DataCloneError`)throw e;i.location.assign(f)}a&&c&&c({action:s,location:h.location,delta:1})}function p(e,t){s=`REPLACE`;let r=te(e)?e:ie(h.location,e,t);n&&n(r,e),l=u();let i=re(r,l),d=h.createHref(r.mask||r);o.replaceState(i,``,d),a&&c&&c({action:s,location:h.location,delta:0})}function m(e){return A(i,e)}let h={get action(){return s},get location(){return e(i,o)},listen(e){if(c)throw Error(`A history only accepts one active listener`);return i.addEventListener(ee,d),c=e,()=>{i.removeEventListener(ee,d),c=null}},createHref(e){return t(i,e)},createURL:m,encodeLocation(e){let t=m(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:f,replace:p,go(e){return o.go(e)}};return h}function A(e,t,n=!1){let r=`http://localhost`;e&&(r=e.location.origin===`null`?e.location.href:e.location.origin),E(r,`No window.location.(origin|href) available to create URL`);let i=typeof t==`string`?t:ae(t);return i=i.replace(/ $/,`%20`),!n&&w.test(i)&&(i=r+i),new URL(i,r)}function se(e,t,n=`/`){return ce(e,t,n,!1)}function ce(e,t,n,r,i){let a=Te((typeof t==`string`?oe(t):t).pathname||`/`,n);if(a==null)return null;let o=i??j(e),s=null,c=we(a);for(let e=0;s==null&&e{let c={relativePath:s===void 0?e.path||``:s,caseSensitive:e.caseSensitive===!0,childrenIndex:a,route:e};if(c.relativePath.startsWith(`/`)){if(!c.relativePath.startsWith(r)&&o)return;E(c.relativePath.startsWith(r),`Absolute route path "${c.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),c.relativePath=c.relativePath.slice(r.length)}let l=Ne([r,c.relativePath]),u=n.concat(c);e.children&&e.children.length>0&&(E(e.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${l}".`),M(e.children,t,u,l,o)),!(e.path==null&&!e.index)&&t.push({path:l,score:ve(l,e.index),routesMeta:u.map((e,t)=>{let[n,r]=Ce(e.relativePath,e.caseSensitive,t===u.length-1);return{...e,matcher:n,compiledParams:r}})})};return e.forEach((e,t)=>{if(e.path===``||!e.path?.includes(`?`))a(e,t);else for(let n of N(e.path))a(e,t,!0,n)}),t}function N(e){let t=e.split(`/`);if(t.length===0)return[];let[n,...r]=t,i=n.endsWith(`?`),a=n.replace(/\?$/,``);if(r.length===0)return i?[a,``]:[a];let o=N(r.join(`/`)),s=[];return s.push(...o.map(e=>e===``?a:[a,e].join(`/`))),i&&s.push(...o),s.map(t=>e.startsWith(`/`)&&t===``?`/`:t)}function ue(e){e.sort((e,t)=>e.score===t.score?ye(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)):t.score-e.score)}var de=/^:[\w-]+$/,fe=3,pe=2,me=1,he=10,ge=-2,_e=e=>e===`*`;function ve(e,t){let n=e.split(`/`),r=n.length;return n.some(_e)&&(r+=ge),t&&(r+=pe),n.filter(e=>!_e(e)).reduce((e,t)=>e+(de.test(t)?fe:t===``?me:he),r)}function ye(e,t){return e.length===t.length&&e.slice(0,-1).every((e,n)=>e===t[n])?e[e.length-1]-t[t.length-1]:0}function be(e,t,n=!1){let{routesMeta:r}=e,i={},a=`/`,o=[];for(let e=0;e{if(t===`*`){let e=s[r]||``;o=a.slice(0,a.length-e.length).replace(/(.)\/+$/,`$1`)}let i=s[r];return n&&!i?e[t]=void 0:e[t]=(i||``).replace(/%2F/g,`/`),e},{}),pathname:a,pathnameBase:o,pattern:e}}function Ce(e,t=!1,n=!0){D(e===`*`||!e.endsWith(`*`)||e.endsWith(`/*`),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,`/*`)}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,`/*`)}".`);let r=[],i=`^`+e.replace(/\/*\*?$/,``).replace(/^\/*/,`/`).replace(/[\\.*+^${}|()[\]]/g,`\\$&`).replace(/\/:([\w-]+)(\?)?/g,(e,t,n,i,a)=>{if(r.push({paramName:t,isOptional:n!=null}),n){let t=a.charAt(i+e.length);return t&&t!==`/`?`/([^\\/]*)`:`(?:/([^\\/]*))?`}return`/([^\\/]+)`}).replace(/\/([\w-]+)\?(\/|$)/g,`(/$1)?$2`);return e.endsWith(`*`)?(r.push({paramName:`*`}),i+=e===`*`||e===`/*`?`(.*)$`:`(?:\\/(.+)|\\/*)$`):n?i+=`\\/*$`:e!==``&&e!==`/`&&(i+=`(?:(?=\\/|$))`),[new RegExp(i,t?void 0:`i`),r]}function we(e){try{return e.split(`/`).map(e=>decodeURIComponent(e).replace(/\//g,`%2F`)).join(`/`)}catch(t){return D(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function Te(e,t){if(t===`/`)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith(`/`)?t.length-1:t.length,r=e.charAt(n);return r&&r!==`/`?null:e.slice(n)||`/`}function Ee(e,t=`/`){let{pathname:n,search:r=``,hash:i=``}=typeof e==`string`?oe(e):e,a;return n?(n=Me(n),a=n.startsWith(`/`)?De(n.substring(1),`/`):De(n,t)):a=t,{pathname:a,search:Ie(r),hash:Le(i)}}function De(e,t){let n=Pe(t).split(`/`);return e.split(`/`).forEach(e=>{e===`..`?n.length>1&&n.pop():e!==`.`&&n.push(e)}),n.length>1?n.join(`/`):`/`}function Oe(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function ke(e){return e.filter((e,t)=>t===0||e.route.path&&e.route.path.length>0)}function Ae(e){let t=ke(e);return t.map((e,n)=>n===t.length-1?e.pathname:e.pathnameBase)}function je(e,t,n,r=!1){let i;typeof e==`string`?i=oe(e):(i={...e},E(!i.pathname||!i.pathname.includes(`?`),Oe(`?`,`pathname`,`search`,i)),E(!i.pathname||!i.pathname.includes(`#`),Oe(`#`,`pathname`,`hash`,i)),E(!i.search||!i.search.includes(`#`),Oe(`#`,`search`,`hash`,i)));let a=e===``||i.pathname===``,o=a?`/`:i.pathname,s;if(o==null)s=n;else{let e=t.length-1;if(!r&&o.startsWith(`..`)){let t=o.split(`/`);for(;t[0]===`..`;)t.shift(),--e;i.pathname=t.join(`/`)}s=e>=0?t[e]:`/`}let c=Ee(i,s),l=o&&o!==`/`&&o.endsWith(`/`),u=(a||o===`.`)&&n.endsWith(`/`);return!c.pathname.endsWith(`/`)&&(l||u)&&(c.pathname+=`/`),c}var Me=e=>e.replace(/[\\/]{2,}/g,`/`),Ne=e=>Me(e.join(`/`)),Pe=e=>e.replace(/\/+$/,``),Fe=e=>Pe(e).replace(/^\/*/,`/`),Ie=e=>!e||e===`?`?``:e.startsWith(`?`)?e:`?`+e,Le=e=>!e||e===`#`?``:e.startsWith(`#`)?e:`#`+e,Re=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||``,this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function ze(e){return e!=null&&typeof e.status==`number`&&typeof e.statusText==`string`&&typeof e.internal==`boolean`&&`data`in e}function Be(e){return Ne(e.map(e=>e.route.path).filter(Boolean))||`/`}var Ve=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0;function He(e,t){let n=e;if(typeof n!=`string`||!C.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,i=!1;if(Ve)try{let e=new URL(window.location.href),r=w.test(n)?new URL(T(n,e.protocol)):new URL(n),a=Te(r.pathname,t);r.origin===e.origin&&a!=null?n=a+r.search+r.hash:i=!0}catch{D(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join(`\0`);var Ue=[`POST`,`PUT`,`PATCH`,`DELETE`];new Set(Ue);var We=[`GET`,...Ue];new Set(We);var Ge=[`about:`,`blob:`,`chrome:`,`chrome-untrusted:`,`content:`,`data:`,`devtools:`,`file:`,`filesystem:`,`javascript:`];function Ke(e){try{return Ge.includes(new URL(e).protocol)}catch{return!1}}var qe=S.createContext(null);qe.displayName=`DataRouter`;var Je=S.createContext(null);Je.displayName=`DataRouterState`;var Ye=S.createContext(!1);function Xe(){return S.useContext(Ye)}var Ze=S.createContext({isTransitioning:!1});Ze.displayName=`ViewTransition`;var Qe=S.createContext(new Map);Qe.displayName=`Fetchers`;var $e=S.createContext(null);$e.displayName=`Await`;var et=S.createContext(null);et.displayName=`Navigation`;var tt=S.createContext(null);tt.displayName=`Location`;var nt=S.createContext({outlet:null,matches:[],isDataRoute:!1});nt.displayName=`Route`;var rt=S.createContext(null);rt.displayName=`RouteError`;var it=`REACT_ROUTER_ERROR`,at=`REDIRECT`,ot=`ROUTE_ERROR_RESPONSE`;function st(e){if(e.startsWith(`${it}:${at}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`&&typeof t.location==`string`&&typeof t.reloadDocument==`boolean`&&typeof t.replace==`boolean`)return t}catch{}}function ct(e){if(e.startsWith(`${it}:${ot}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`)return new Re(t.status,t.statusText,t.data)}catch{}}function lt(e,{relative:t}={}){E(ut(),`useHref() may be used only in the context of a component.`);let{basename:n,navigator:r}=S.useContext(et),{hash:i,pathname:a,search:o}=ht(e,{relative:t}),s=a;return n!==`/`&&(s=a===`/`?n:Ne([n,a])),r.createHref({pathname:s,search:o,hash:i})}function ut(){return S.useContext(tt)!=null}function P(){return E(ut(),`useLocation() may be used only in the context of a component.`),S.useContext(tt).location}var dt=`You should call navigate() in a React.useEffect(), not when your component is first rendered.`;function ft(e){S.useContext(et).static||S.useLayoutEffect(e)}function pt(){let{isDataRoute:e}=S.useContext(nt);return e?Nt():mt()}function mt(){E(ut(),`useNavigate() may be used only in the context of a component.`);let e=S.useContext(qe),{basename:t,navigator:n}=S.useContext(et),{matches:r}=S.useContext(nt),{pathname:i}=P(),a=JSON.stringify(Ae(r)),o=S.useRef(!1);return ft(()=>{o.current=!0}),S.useCallback((r,s={})=>{if(D(o.current,dt),!o.current)return;if(typeof r==`number`){n.go(r);return}let c=je(r,JSON.parse(a),i,s.relative===`path`);e==null&&t!==`/`&&(c.pathname=c.pathname===`/`?t:Ne([t,c.pathname])),(s.replace?n.replace:n.push)(c,s.state,s)},[t,n,a,i,e])}S.createContext(null);function ht(e,{relative:t}={}){let{matches:n}=S.useContext(nt),{pathname:r}=P(),i=JSON.stringify(Ae(n));return S.useMemo(()=>je(e,JSON.parse(i),r,t===`path`),[e,i,r,t])}function gt(e,t,n){E(ut(),`useRoutes() may be used only in the context of a component.`);let{navigator:r}=S.useContext(et),{matches:i}=S.useContext(nt),a=i[i.length-1],o=a?a.params:{},s=a?a.pathname:`/`,c=a?a.pathnameBase:`/`,l=a&&a.route;{let e=l&&l.path||``;Ft(s,!l||e.endsWith(`*`)||e.endsWith(`*?`),`You rendered descendant (or called \`useRoutes()\`) at "${s}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. + +Please change the parent to .`)}let u=P(),d;if(t){let e=typeof t==`string`?oe(t):t;E(c===`/`||e.pathname?.startsWith(c),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${c}" but pathname "${e.pathname}" was given in the \`location\` prop.`),d=e}else d=u;let f=d.pathname||`/`,p=f;if(c!==`/`){let e=c.replace(/^\//,``).split(`/`);p=`/`+f.replace(/^\//,``).split(`/`).slice(e.length).join(`/`)}let m=n&&n.state.matches.length?n.state.matches.map(e=>Object.assign(e,{route:n.manifest[e.route.id]||e.route})):se(e,{pathname:p});D(l||m!=null,`No routes matched location "${d.pathname}${d.search}${d.hash}" `),D(m==null||m[m.length-1].route.element!==void 0||m[m.length-1].route.Component!==void 0||m[m.length-1].route.lazy!==void 0,`Matched leaf route at location "${d.pathname}${d.search}${d.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let h=Ct(m&&m.map(e=>Object.assign({},e,{params:Object.assign({},o,e.params),pathname:Ne([c,r.encodeLocation?r.encodeLocation(e.pathname.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathname]),pathnameBase:e.pathnameBase===`/`?c:Ne([c,r.encodeLocation?r.encodeLocation(e.pathnameBase.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathnameBase])})),i,n);return t&&h?S.createElement(tt.Provider,{value:{location:{pathname:`/`,search:``,hash:``,state:null,key:`default`,mask:void 0,...d},navigationType:`POP`}},h):h}function _t(){let e=Mt(),t=ze(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r=`rgba(200,200,200, 0.5)`,i={padding:`0.5rem`,backgroundColor:r},a={padding:`2px 4px`,backgroundColor:r},o=null;return console.error(`Error handled by React Router default ErrorBoundary:`,e),o=S.createElement(S.Fragment,null,S.createElement(`p`,null,`💿 Hey developer 👋`),S.createElement(`p`,null,`You can provide a way better UX than this when your app throws errors by providing your own `,S.createElement(`code`,{style:a},`ErrorBoundary`),` or`,` `,S.createElement(`code`,{style:a},`errorElement`),` prop on your route.`)),S.createElement(S.Fragment,null,S.createElement(`h2`,null,`Unexpected Application Error!`),S.createElement(`h3`,{style:{fontStyle:`italic`}},t),n?S.createElement(`pre`,{style:i},n):null,o)}var vt=S.createElement(_t,null),yt=class extends S.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!==`idle`&&e.revalidation===`idle`?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error===void 0?t.error:e.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error(`React Router caught the following error during render`,e)}render(){let e=this.state.error;if(this.context&&typeof e==`object`&&e&&`digest`in e&&typeof e.digest==`string`){let t=ct(e.digest);t&&(e=t)}let t=e===void 0?this.props.children:S.createElement(nt.Provider,{value:this.props.routeContext},S.createElement(rt.Provider,{value:e,children:this.props.component}));return this.context?S.createElement(xt,{error:e},t):t}};yt.contextType=Ye;var bt=new WeakMap;function xt({children:e,error:t}){let{basename:n}=S.useContext(et);if(typeof t==`object`&&t&&`digest`in t&&typeof t.digest==`string`){let e=st(t.digest);if(e){let r=bt.get(t);if(r)throw r;let i=He(e.location,n),a=i.absoluteURL||i.to;if(Ke(a))throw Error(`Invalid redirect location`);if(Ve&&!bt.get(t))if(i.isExternal||e.reloadDocument)window.location.href=a;else{let n=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(i.to,{replace:e.replace}));throw bt.set(t,n),n}return S.createElement(`meta`,{httpEquiv:`refresh`,content:`0;url=${a}`})}}return e}function St({routeContext:e,match:t,children:n}){let r=S.useContext(qe);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),S.createElement(nt.Provider,{value:e},n)}function Ct(e,t=[],n){let r=n?.state;if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(t.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let i=e,a=r?.errors;if(a!=null){let e=i.findIndex(e=>e.route.id&&a?.[e.route.id]!==void 0);E(e>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(a).join(`,`)}`),i=i.slice(0,Math.min(i.length,e+1))}let o=!1,s=-1;if(n&&r){o=r.renderFallback;for(let e=0;e=0?i.slice(0,s+1):[i[0]];break}}}}let c=n?.onError,l=r&&c?(e,t)=>{c(e,{location:r.location,params:r.matches?.[0]?.params??{},pattern:Be(r.matches),errorInfo:t})}:void 0;return i.reduceRight((e,n,c)=>{let u,d=!1,f=null,p=null;r&&(u=a&&n.route.id?a[n.route.id]:void 0,f=n.route.errorElement||vt,o&&(s<0&&c===0?(Ft(`route-fallback`,!1,"No `HydrateFallback` element provided to render during initial hydration"),d=!0,p=null):s===c&&(d=!0,p=n.route.hydrateFallbackElement||null)));let m=t.concat(i.slice(0,c+1)),h=()=>{let t;return t=u?f:d?p:n.route.Component?S.createElement(n.route.Component,null):n.route.element?n.route.element:e,S.createElement(St,{match:n,routeContext:{outlet:e,matches:m,isDataRoute:r!=null},children:t})};return r&&(n.route.ErrorBoundary||n.route.errorElement||c===0)?S.createElement(yt,{location:r.location,revalidation:r.revalidation,component:f,error:u,children:h(),routeContext:{outlet:null,matches:m,isDataRoute:!0},onError:l}):h()},null)}function wt(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function Tt(e){let t=S.useContext(qe);return E(t,wt(e)),t}function Et(e){let t=S.useContext(Je);return E(t,wt(e)),t}function Dt(e){let t=S.useContext(nt);return E(t,wt(e)),t}function Ot(e){let t=Dt(e),n=t.matches[t.matches.length-1];return E(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function kt(){return Ot(`useRouteId`)}function At(){let e=Et(`useNavigation`);return S.useMemo(()=>{let{matches:t,historyAction:n,...r}=e.navigation;return r},[e.navigation])}function jt(){let{matches:e,loaderData:t}=Et(`useMatches`);return S.useMemo(()=>e.map(e=>le(e,t)),[e,t])}function Mt(){let e=S.useContext(rt),t=Et(`useRouteError`),n=Ot(`useRouteError`);return e===void 0?t.errors?.[n]:e}function Nt(){let{router:e}=Tt(`useNavigate`),t=Ot(`useNavigate`),n=S.useRef(!1);return ft(()=>{n.current=!0}),S.useCallback(async(r,i={})=>{D(n.current,dt),n.current&&(typeof r==`number`?await e.navigate(r):await e.navigate(r,{fromRouteId:t,...i}))},[e,t])}var Pt={};function Ft(e,t,n){!t&&!Pt[e]&&(Pt[e]=!0,D(!1,n))}S.memo(It);function It({routes:e,manifest:t,future:n,state:r,isStatic:i,onError:a}){return gt(e,void 0,{manifest:t,state:r,isStatic:i,onError:a,future:n})}function Lt({basename:e=`/`,children:t=null,location:n,navigationType:r=`POP`,navigator:i,static:a=!1,useTransitions:o}){E(!ut(),`You cannot render a inside another . You should never have more than one in your app.`);let s=e.replace(/^\/*/,`/`),c=S.useMemo(()=>({basename:s,navigator:i,static:a,useTransitions:o,future:{}}),[s,i,a,o]);typeof n==`string`&&(n=oe(n));let{pathname:l=`/`,search:u=``,hash:d=``,state:f=null,key:p=`default`,mask:m}=n,h=S.useMemo(()=>{let e=Te(l,s);return e==null?null:{location:{pathname:e,search:u,hash:d,state:f,key:p,mask:m},navigationType:r}},[s,l,u,d,f,p,r,m]);return D(h!=null,` is not able to match the URL "${l}${u}${d}" because it does not start with the basename, so the won't render anything.`),h==null?null:S.createElement(et.Provider,{value:c},S.createElement(tt.Provider,{children:t,value:h}))}S.Component;var Rt=`get`,zt=`application/x-www-form-urlencoded`;function Bt(e){return typeof HTMLElement<`u`&&e instanceof HTMLElement}function Vt(e){return Bt(e)&&e.tagName.toLowerCase()===`button`}function Ht(e){return Bt(e)&&e.tagName.toLowerCase()===`form`}function Ut(e){return Bt(e)&&e.tagName.toLowerCase()===`input`}function Wt(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function Gt(e,t){return e.button===0&&(!t||t===`_self`)&&!Wt(e)}var Kt=null;function qt(){if(Kt===null)try{new FormData(document.createElement(`form`),0),Kt=!1}catch{Kt=!0}return Kt}var Jt=new Set([`application/x-www-form-urlencoded`,`multipart/form-data`,`text/plain`]);function Yt(e){return e!=null&&!Jt.has(e)?(D(!1,`"${e}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${zt}"`),null):e}function Xt(e,t){let n,r,i,a,o;if(Ht(e)){let o=e.getAttribute(`action`);r=o?Te(o,t):null,n=e.getAttribute(`method`)||Rt,i=Yt(e.getAttribute(`enctype`))||zt,a=new FormData(e)}else if(Vt(e)||Ut(e)&&(e.type===`submit`||e.type===`image`)){let o=e.form;if(o==null)throw Error(`Cannot submit a + + + ); return ( - - } /> - - - - } - > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - - ) +
+ +
+
+
+

PAWPRINT DASHBOARD

+

{labels[tab]}

+
+ +
+ {error &&
{error}
} + {tab === "overview" ? ( + <> +
+
+

每一段陪伴,都有迹可循

+

你好,铲屎官管理者 👋

+ 这里汇总肉垫计划的日常活动。 +
+
🐕
+
+
+ {( + [ + ["users", "注册用户", "👥"], + ["pets", "宠物档案", "🐶"], + ["walks", "遛狗记录", "🐾"], + ["meetups", "约遛活动", "🌿"], + ] as const + ).map(([key, label, icon]) => ( +
+
{icon}
+ {label} + {stats[key] ?? "—"} +
+ ))} +
+ + ) : ( +
+
+

{labels[tab]}

+

最近 {items.length} 条记录

+
+
+ + + + {tab === "users" ? ( + <> + + + + + + ) : tab === "pets" ? ( + <> + + + + + + ) : tab === "walks" ? ( + <> + + + + + + + ) : ( + <> + + + + + + + + )} + + + + {items.map((row) => ( + + {tab === "users" ? ( + <> + + + + + + ) : tab === "pets" ? ( + <> + + + + + + ) : tab === "walks" ? ( + <> + + + + + + + ) : ( + <> + + + + + + + + )} + + ))} + +
用户注册时间状态操作宠物品种主人 ID状态记录 ID用户 ID开始时间距离状态活动地点时间人数状态操作
+ {row.nickname} + {row.id} + {fmt(row.created_at)} + + {row.disabled ? "已停用" : "正常"} + + + + + {row.name} + {row.id} + {row.breed || row.species}{row.user_id} + + {row.archived ? "已归档" : "在用"} + + {row.id}{row.user_id}{fmt(row.started_at)}{(row.distance_meters / 1000).toFixed(2)} km + {row.status} + + {row.title} + {row.id} + {row.place_name}{fmt(row.starts_at)} + {row.participants}/{row.max_people} + + + {row.status} + + + {row.status === "open" && ( + + )} +
+ {items.length === 0 && ( +
还没有记录,新的足迹会出现在这里 🐾
+ )} +
+
+ )} +
+
+ ); } diff --git a/pets-be/web/admin/src/assets/hero.png b/pets-be/web/admin/src/assets/hero.png deleted file mode 100644 index 02251f4..0000000 Binary files a/pets-be/web/admin/src/assets/hero.png and /dev/null differ diff --git a/pets-be/web/admin/src/assets/react.svg b/pets-be/web/admin/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/pets-be/web/admin/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/pets-be/web/admin/src/assets/vite.svg b/pets-be/web/admin/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/pets-be/web/admin/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/pets-be/web/admin/src/components/Layout.tsx b/pets-be/web/admin/src/components/Layout.tsx deleted file mode 100644 index 838ac9d..0000000 --- a/pets-be/web/admin/src/components/Layout.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { NavLink, Outlet, useNavigate } from 'react-router-dom' -import { LayoutDashboard, Users, PawPrint, MessageSquare, MessagesSquare, FileText, ClipboardList, ListChecks, Dna, Sparkles, MessageCircle, Crown, LogOut } from 'lucide-react' -import { api, clearToken } from '@/lib/api' -import { cn } from '@/lib/utils' -import { Button } from '@/components/ui/button' - -const nav = [ - { to: '/', label: '概览', icon: LayoutDashboard, end: true }, - { to: '/users', label: '用户管理', icon: Users }, - { to: '/pets', label: '宠物管理', icon: PawPrint }, - { to: '/posts', label: '帖子审核', icon: MessageSquare }, - { to: '/comments', label: '评论管理', icon: MessagesSquare }, - { to: '/articles', label: '文章管理', icon: FileText }, - { to: '/care-templates', label: '养护模板', icon: ClipboardList }, - { to: '/record-types', label: '记录事项', icon: ListChecks }, - { to: '/breeds', label: '品种管理', icon: Dna }, - { to: '/community-bot', label: '社区运营', icon: Sparkles }, - { to: '/feedback', label: '意见反馈', icon: MessageCircle }, - { to: '/members', label: '会员管理', icon: Crown }, -] - -export default function Layout() { - const navigate = useNavigate() - function logout() { - // 服务端作废刷新令牌,成不成都要本地清掉 - api.logout().catch(() => {}) - clearToken() - navigate('/login') - } - return ( -
- -
- -
-
- ) -} diff --git a/pets-be/web/admin/src/components/Pager.tsx b/pets-be/web/admin/src/components/Pager.tsx deleted file mode 100644 index e3b868f..0000000 --- a/pets-be/web/admin/src/components/Pager.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Button } from '@/components/ui/button' - -export function Pager({ - page, - totalPages, - total, - onChange, -}: { - page: number - totalPages: number - total: number - onChange: (p: number) => void -}) { - return ( -
- 共 {total} 条 -
- - - {page} / {totalPages} - - -
-
- ) -} diff --git a/pets-be/web/admin/src/index.css b/pets-be/web/admin/src/index.css index 703d023..51edc4d 100644 --- a/pets-be/web/admin/src/index.css +++ b/pets-be/web/admin/src/index.css @@ -1,37 +1,340 @@ @tailwind base; @tailwind components; @tailwind utilities; - -@layer base { - :root { - --background: 40 33% 98%; - --foreground: 24 10% 15%; - --card: 0 0% 100%; - --card-foreground: 24 10% 15%; - --primary: 33 90% 62%; - --primary-foreground: 0 0% 100%; - --secondary: 40 30% 94%; - --secondary-foreground: 24 10% 25%; - --muted: 40 20% 94%; - --muted-foreground: 25 8% 45%; - --accent: 40 40% 92%; - --accent-foreground: 24 10% 20%; - --destructive: 5 78% 57%; - --destructive-foreground: 0 0% 100%; - --border: 33 25% 88%; - --input: 33 25% 88%; - --ring: 33 90% 62%; - --radius: 0.6rem; +:root { + --background: 150 18% 97%; + --foreground: 166 48% 18%; + --card: 0 0% 100%; + --card-foreground: 166 48% 18%; + --primary: 166 58% 21%; + --primary-foreground: 0 0% 100%; + --secondary: 145 22% 92%; + --secondary-foreground: 166 48% 18%; + --muted: 145 12% 94%; + --muted-foreground: 150 9% 51%; + --accent: 145 22% 92%; + --accent-foreground: 166 48% 18%; + --destructive: 5 70% 56%; + --destructive-foreground: 0 0% 100%; + --border: 145 16% 86%; + --input: 145 16% 86%; + --ring: 166 58% 21%; + --radius: 0.8rem; +} +* { + border-color: hsl(var(--border)); +} +body { + margin: 0; + background: #f6f8f5; + color: #193f37; + font-family: + -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", + sans-serif; +} +.app-shell { + min-height: 100vh; + display: flex; +} +.sidebar { + width: 242px; + flex: none; + background: #194d41; + color: #fff; + padding: 30px 18px; + display: flex; + flex-direction: column; +} +.brand { + display: flex; + gap: 12px; + align-items: center; + padding: 5px 12px 38px; +} +.brand > span { + font-size: 28px; + background: #e9aa6d; + border-radius: 16px; + padding: 8px; +} +.brand strong { + display: block; + font-size: 19px; +} +.brand small { + display: block; + font-size: 10px; + letter-spacing: 0.22em; + opacity: 0.56; + margin-top: 2px; +} +.side-caption { + font-size: 11px; + color: #9abcb0; + padding: 0 14px; + letter-spacing: 0.2em; +} +.sidebar nav { + display: grid; + gap: 7px; +} +.nav-item { + border: 0; + background: transparent; + color: #c2d9cf; + display: flex; + align-items: center; + gap: 13px; + text-align: left; + padding: 13px 15px; + border-radius: 13px; + cursor: pointer; +} +.nav-item:hover, +.nav-item.active { + background: #336a5c; + color: #fff; +} +.side-bottom { + margin-top: auto; + color: #8ab5a4; + font-size: 12px; + padding: 12px; +} +.side-bottom button { + display: flex; + align-items: center; + gap: 8px; + border: 0; + background: transparent; + color: #d4e6dc; + margin-top: 18px; + cursor: pointer; +} +.content { + width: 100%; + max-width: 1500px; + padding: 40px 48px; +} +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 28px; +} +.eyebrow { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.18em; + color: #74a28b; + margin: 0 0 8px; +} +.topbar h1 { + font-size: 30px; + margin: 0; +} +.hero { + background: #194d41; + border-radius: 27px; + color: white; + min-height: 196px; + padding: 34px 40px; + display: flex; + align-items: center; + justify-content: space-between; + overflow: hidden; +} +.hero p { + color: #a9d7bc; + margin: 0 0 10px; +} +.hero h2 { + font-size: 27px; + margin: 0 0 12px; +} +.hero span { + color: #c9e0d3; +} +.hero-paw { + font-size: 90px; + background: #edbe8b; + border-radius: 50%; + width: 152px; + height: 152px; + text-align: center; + line-height: 152px; + transform: rotate(-9deg); +} +.stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-top: 22px; +} +.stat-card, +.table-card { + background: #fff; + border: 1px solid #e7ede7; + box-shadow: 0 12px 40px #214d3f0a; + border-radius: 23px; +} +.stat-card { + padding: 23px; +} +.stat-icon { + font-size: 27px; + margin-bottom: 16px; +} +.stat-card span { + display: block; + color: #82988e; + font-size: 13px; +} +.stat-card strong { + font-size: 34px; +} +.table-head { + padding: 26px 28px 16px; +} +.table-head h2 { + margin: 0; + font-size: 20px; +} +.table-head p { + color: #87998f; + font-size: 13px; +} +.table-scroll { + overflow: auto; +} +table { + border-collapse: collapse; + width: 100%; + min-width: 800px; +} +th, +td { + text-align: left; + padding: 16px 24px; + border-top: 1px solid #edf0ec; + white-space: nowrap; + font-size: 13px; +} +th { + color: #81978b; + font-weight: 600; + background: #fbfcfa; +} +td b { + display: block; +} +td small { + display: block; + color: #9aada2; + font-size: 11px; + margin-top: 3px; +} +.mono { + font-size: 11px; + color: #71877d; +} +.pill { + padding: 5px 9px; + border-radius: 99px; + font-size: 11px; +} +.pill.green { + background: #e8f5ec; + color: #258052; +} +.pill.red { + background: #fff0eb; + color: #ba6b51; +} +.empty { + text-align: center; + padding: 55px; + color: #91a397; +} +.error-banner, +.error { + color: #a64d37; + background: #fff0eb; + border-radius: 12px; + padding: 11px; +} +.login-page { + min-height: 100vh; + display: grid; + place-items: center; + background: + radial-gradient(circle at 75% 10%, #e9f1e7, transparent 35%), #f6f8f5; +} +.login-card { + width: min(420px, calc(100% - 40px)); + background: white; + border-radius: 28px; + padding: 40px; + box-shadow: 0 25px 70px #204a3a18; +} +.login-mark { + width: 60px; + height: 60px; + background: #e9aa6d; + border-radius: 19px; + display: grid; + place-items: center; + font-size: 30px; + margin-bottom: 22px; +} +.login-card h1 { + font-size: 30px; + margin: 0; +} +.muted { + color: #90a198; + margin: 8px 0 30px; +} +.login-card label { + display: grid; + gap: 7px; + margin-bottom: 18px; + color: #456a5b; + font-size: 13px; +} +.login-card input { + margin-top: 2px; +} +.login-card button { + margin-top: 8px; +} +@media (max-width: 800px) { + .sidebar { + width: 70px; + padding: 20px 8px; } - - * { - border-color: hsl(var(--border)); + .brand div, + .side-caption, + .nav-item:not(.active), + .nav-item.active { + font-size: 0; } - body { - margin: 0; - background: hsl(var(--background)); - color: hsl(var(--foreground)); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', - 'Microsoft YaHei', sans-serif; + .brand { + padding: 5px 5px 30px; + } + .nav-item { + justify-content: center; + } + .side-bottom { + display: none; + } + .content { + padding: 28px 18px; + } + .stat-grid { + grid-template-columns: repeat(2, 1fr); + } + .hero-paw { + display: none; } } diff --git a/pets-be/web/admin/src/lib/api.ts b/pets-be/web/admin/src/lib/api.ts index f45f7e6..1e0d654 100644 --- a/pets-be/web/admin/src/lib/api.ts +++ b/pets-be/web/admin/src/lib/api.ts @@ -1,167 +1,35 @@ -import axios from 'axios' - -const TOKEN_KEY = 'pets_admin_token' -const REFRESH_KEY = 'pets_admin_refresh' - -export function getToken() { - return localStorage.getItem(TOKEN_KEY) || '' -} -export function getRefreshToken() { - return localStorage.getItem(REFRESH_KEY) || '' -} -export function setToken(t: string, refresh?: string) { - localStorage.setItem(TOKEN_KEY, t) - if (refresh) localStorage.setItem(REFRESH_KEY, refresh) -} -export function clearToken() { - localStorage.removeItem(TOKEN_KEY) - localStorage.removeItem(REFRESH_KEY) -} - -const http = axios.create({ baseURL: '/api' }) - -http.interceptors.request.use((cfg) => { - const t = getToken() - if (t) cfg.headers.Authorization = `Bearer ${t}` - return cfg -}) - -// access token 只有 2 小时,过期后拿 refresh token 换新的。 -// 用单飞(single-flight)保证并发请求只触发一次刷新,否则刷新令牌会被并发轮换掉。 -let refreshing: Promise | null = null - -function refreshAccessToken(): Promise { - if (!refreshing) { - const rt = getRefreshToken() - refreshing = ( - rt - ? axios.post('/api/admin/refresh', { refresh_token: rt }).then((r) => { - const body = r.data - if (!body || body.code !== 0) throw new Error(body?.message || '续期失败') - setToken(body.data.token, body.data.refresh_token) - return body.data.token as string - }) - : Promise.reject(new Error('没有刷新令牌')) - ).finally(() => { - refreshing = null - }) - } - return refreshing -} - -function logout() { - clearToken() - if (location.pathname !== '/admin/login') location.href = '/admin/login' -} - -// 这几个接口本身就可能返回 40100(密码错、刷新令牌失效),不能再触发刷新,否则会绕圈 -const NO_REFRESH = ['/admin/login', '/admin/refresh', '/admin/logout'] - -// 统一响应 {code,message,data}:成功返回 data,失败抛错 +import axios from "axios"; +const key = "pets_admin_token"; +export const getToken = () => localStorage.getItem(key) || ""; +export const setToken = (value: string) => localStorage.setItem(key, value); +export const clearToken = () => localStorage.removeItem(key); +const http = axios.create({ baseURL: "/api" }); +http.interceptors.request.use((config) => { + const token = getToken(); + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); http.interceptors.response.use( - async (resp) => { - const body = resp.data - if (body && typeof body === 'object' && 'code' in body) { - if (body.code === 0) return body.data - if (body.code === 40100) { - const cfg = resp.config as typeof resp.config & { _retried?: boolean } - const skip = NO_REFRESH.some((p) => (cfg.url || '').startsWith(p)) - if (!skip && !cfg._retried) { - cfg._retried = true - try { - const t = await refreshAccessToken() - cfg.headers.Authorization = `Bearer ${t}` - return await http.request(cfg) - } catch { - /* 续期失败,落到下面退出登录 */ - } - } - if (!skip) logout() - } - return Promise.reject(new Error(body.message || '请求失败')) - } - return body + (response) => { + const body = response.data; + if (body?.code === 0) return body.data; + throw new Error(body?.message || "请求失败"); }, - (err) => Promise.reject(err), -) - -export interface Page { - list: T[] - total: number - page: number - page_size: number -} - + (error) => { + if (error.response?.status === 401) clearToken(); + throw new Error(error.response?.data?.message || error.message); + }, +); export const api = { login: (username: string, password: string) => - http.post( - '/admin/login', - { username, password }, - ), - logout: () => http.post('/admin/logout', { refresh_token: getRefreshToken() }), - me: () => http.get('/admin/me'), - stats: () => http.get('/admin/stats'), - analytics: (days = 30) => http.get('/admin/analytics', { params: { days } }), - - users: (params: any) => http.get>('/admin/users', { params }), - setUserDisabled: (id: number, disabled: boolean) => + http.post("/admin/login", { username, password }), + stats: () => http.get>("/admin/stats"), + users: () => http.get("/admin/users"), + pets: () => http.get("/admin/pets"), + walks: () => http.get("/admin/walks"), + meetups: () => http.get("/admin/meetups"), + setUserDisabled: (id: string, disabled: boolean) => http.put(`/admin/users/${id}/disabled`, { disabled }), - - pets: (params: any) => http.get>('/admin/pets', { params }), - - posts: (params: any) => http.get>('/admin/posts', { params }), - setPostStatus: (id: number, status: string) => http.put(`/admin/posts/${id}/status`, { status }), - - comments: (params: any) => http.get>('/admin/comments', { params }), - setCommentStatus: (id: number, status: string) => http.put(`/admin/comments/${id}/status`, { status }), - deleteComment: (id: number) => http.delete(`/admin/comments/${id}`), - - articles: (params: any) => http.get>('/admin/articles', { params }), - saveArticle: (a: any) => http.post('/admin/articles', a), - deleteArticle: (id: number) => http.delete(`/admin/articles/${id}`), - - memberships: (params: any) => http.get>('/admin/memberships', { params }), - - careTemplateMeta: () => http.get('/admin/care-templates/meta'), - // 方案改成「同一个物种阶段可以多套」,所以按 id 存取,不再按 species+stage 覆盖 - careTemplates: () => http.get('/admin/care-templates'), - careTemplate: (id: string) => http.get(`/admin/care-templates/${id}`), - saveCareTemplate: (t: any) => http.post('/admin/care-templates', t), - deleteCareTemplate: (id: string) => http.delete(`/admin/care-templates/${id}`), - genCareTemplate: (species: string, stage: string) => - http.post('/admin/care-templates/generate', { - species, - stage, - }), - - feedback: (params: any) => http.get>('/admin/feedback', { params }), - setFeedbackHandled: (id: string, handled: boolean) => - http.put(`/admin/feedback/${id}/handled`, { handled }), - - aiQuota: () => - http.get( - '/admin/ai-quota', - ), - saveAIQuota: (c: any) => http.put('/admin/ai-quota', c), - - // 记录类型(小程序「记一笔」的那些事项) - recordTypes: () => http.get('/admin/record-types'), - saveRecordType: (t: any) => http.post('/admin/record-types', t), - deleteRecordType: (id: string) => http.delete(`/admin/record-types/${id}`), - - // 品种 - breeds: (species?: string) => - http.get('/admin/breeds', { params: species ? { species } : {} }), - saveBreed: (b: any) => http.post('/admin/breeds', b), - deleteBreed: (id: string) => http.delete(`/admin/breeds/${id}`), - - communityBot: () => - http.get( - '/admin/community-bot', - ), - saveCommunityBot: (c: any) => http.put('/admin/community-bot', c), - genCommunityPosts: (count: number) => - http.post('/admin/community-bot/generate', { count }), -} - -export default http + setMeetupStatus: (id: string, status: string) => + http.put(`/admin/meetups/${id}/status`, { status }), +}; diff --git a/pets-be/web/admin/src/lib/usePaged.ts b/pets-be/web/admin/src/lib/usePaged.ts deleted file mode 100644 index 95c7fec..0000000 --- a/pets-be/web/admin/src/lib/usePaged.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import type { Page } from '@/lib/api' - -export function usePaged( - fetcher: (params: Record) => Promise>, - extra: Record = {}, -) { - const [page, setPage] = useState(1) - const [pageSize] = useState(10) - const [data, setData] = useState | null>(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') - - const extraKey = JSON.stringify(extra) - - const load = useCallback(() => { - setLoading(true) - setError('') - fetcher({ page, page_size: pageSize, ...extra }) - .then(setData) - .catch((e) => setError(e.message || '加载失败')) - .finally(() => setLoading(false)) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [page, pageSize, extraKey]) - - useEffect(() => { - load() - }, [load]) - - const totalPages = data ? Math.max(1, Math.ceil(data.total / pageSize)) : 1 - - return { page, setPage, pageSize, data, loading, error, reload: load, totalPages } -} diff --git a/pets-be/web/admin/src/pages/Articles.tsx b/pets-be/web/admin/src/pages/Articles.tsx deleted file mode 100644 index c0f9259..0000000 --- a/pets-be/web/admin/src/pages/Articles.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useState } from 'react' -import { api } from '@/lib/api' -import { usePaged } from '@/lib/usePaged' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Card, CardContent } from '@/components/ui/card' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Pager } from '@/components/Pager' - -const empty = { - id: 0, icon: '📘', title: '', description: '', content: '', - category: '', related_sheet_type: '', published: true, -} - -export default function Articles() { - const { page, setPage, data, totalPages, reload } = usePaged(api.articles) - const [open, setOpen] = useState(false) - const [form, setForm] = useState(empty) - - function edit(a: any) { - setForm({ ...a }) - setOpen(true) - } - function create() { - setForm({ ...empty }) - setOpen(true) - } - async function save() { - await api.saveArticle(form) - setOpen(false) - reload() - } - async function del(id: number) { - await api.deleteArticle(id) - reload() - } - - const set = (k: string) => (e: any) => setForm({ ...form, [k]: e.target.value }) - - return ( -
-
-

文章管理

- -
- - - - - - ID - 标题 - 分类 - 关联弹层 - 状态 - 操作 - - - - {data?.list?.map((a) => ( - - {a.id} - - {a.icon} - {a.title} - - {a.category || '—'} - {a.related_sheet_type || '—'} - - {a.published ? 已发布 : 草稿} - - - - - - - ))} - -
- -
-
- - - - - {form.id ? '编辑文章' : '新建文章'} - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - - - - - - - - - {{commentText.length}}/500 - 发送 - - - - - - - - - - 就医前摘要 - 宠物:{{pet.name}} -阶段:{{pet.stage}} -当前体重:{{pet.weight}} -近期重点:疫苗期、换粮观察、排便记录 -建议携带:排便/呕吐照片、饮食变化、疫苗驱虫记录。 - - - - - - - 提醒中心 - 疫苗、驱虫这些别靠记,设好日期到时候提醒你。 - - - - {{item.title}} - {{item.when}} - - 改 - 删 - - 还没有提醒,下面加一条吧。 - - - - - - - {{remTypeLabels[remForm.typeIdx]}} - - - {{remForm.date || '选择到期日'}} - - - - - - - - - - - - - - {{pet.name}} 的成长报告 - - - {{pet.emoji}} - - {{pet.age}}|{{pet.weight}}|{{pet.stage}} - 近 7 天 · {{poster.period}} - - 完成任务{{poster ? poster.tasks_completed : 0}} 项 - 体重记录{{poster ? poster.weight_records : 0}} 次 - 疫苗记录{{poster ? poster.vaccine_records : 0}} 次 - 高风险异常{{poster ? poster.high_risk_count : 0}} 次 - - 健康状态{{poster ? poster.headline : '稳定成长'}} - 生成自 · 肉垫计划 - - - - - - - - - - - - 本周成长报告 - {{reportDetail ? reportDetail.summary : '正在汇总本周数据…'}} - 下周重点:{{reportDetail.next_week_focus}} - - - - - - 肉垫计划 Pro - 解锁 365 天计划、PDF 健康档案、月度报告、多宠物管理与年度账单。 - - {{item}} - - - - - 会员功能还在准备中,暂未开放购买。 - - - - - - - 发布图文 - 用你的昵称分享养宠日常。 - - - - - 晒宠 - 新手求助 - 经验 - - - - - - - - - - - - - - - - 多宠物管理 - 点一只切换为当前宠物,或编辑它的档案。 - - - {{item.emoji}} {{item.name}}当前 - {{item.meta}} - - 编辑 - - 还没有毛孩子。 - - - - - - 导出健康档案 - 整理成一段文字,可复制后发给医生或存到备忘录。 - {{exportText || '正在整理…'}} - - - - - - 意见反馈 - - 用着哪儿别扭、想要什么功能,都可以告诉我们。 - - - - - - - - 已收到,谢谢你的反馈 🐾 - - - - - - - 关于肉垫计划 - 肉垫计划是给新手养宠人做的照护记录工具。 - -按猫狗的品类和成长阶段生成每日照护任务和养护计划,随手记录体重、饮食、排便、用药、疫苗和开支,疫苗驱虫到期前提醒你,并把这些整理成成长报告。 - -版本:v0.2 -反馈:我的 → 意见反馈 - 本小程序提供的是记录与观察建议,不能替代医生诊断。宠物出现持续异常请及时就医。 - - - - - - - diff --git a/pets-fe/components/bottom-sheet/bottom-sheet.wxss b/pets-fe/components/bottom-sheet/bottom-sheet.wxss deleted file mode 100644 index 547e849..0000000 --- a/pets-fe/components/bottom-sheet/bottom-sheet.wxss +++ /dev/null @@ -1,170 +0,0 @@ -.overlay{ - position:fixed;inset:0;z-index:200; - background:rgba(26,18,12,.30); - opacity:0;pointer-events:none;transition:.2s ease; - backdrop-filter:blur(6rpx);-webkit-backdrop-filter:blur(6rpx); -} -.overlay.show{opacity:1;pointer-events:auto} -.sheet{ - position:absolute;left:0;right:0;bottom:0; - background:#fff;border-radius:48rpx 48rpx 0 0; - padding:var(--sp-5) 0 0; - transform:translateY(106%);transition:.24s ease; - box-shadow:var(--sd-3); -} -.overlay.show .sheet{transform:translateY(0)} -.sheet-scroll{ - box-sizing:border-box; - /* exportData / manageTasks / comments 是最长的三型,圆角间距变化后靠这个上限内滚 */ - max-height:80vh; - padding:0 var(--sp-5) calc(120rpx + env(safe-area-inset-bottom)); -} -.sheet-scroll::-webkit-scrollbar,.plan-chat::-webkit-scrollbar{width:0;height:0;display:none} -.sheetbar{width:80rpx;height:8rpx;border-radius:var(--r-full);background:#E1D8CF;margin:0 auto var(--sp-4)} - -.sheet-h3{font-size:var(--fs-xl);font-weight:var(--fw-b);margin-bottom:var(--sp-3)} -.sheet-p{font-size:var(--fs-md);color:var(--text-2);line-height:1.65;margin-bottom:var(--sp-4)} -.sheet-p.pre,.poster-list.pre{white-space:pre-line} -.poster-h3{font-size:var(--fs-xl);font-weight:var(--fw-b);margin-bottom:var(--sp-3)} - -.ai-input-row{display:flex;gap:var(--sp-3);align-items:center} -.ai-input-row .input{flex:1} -.ai-send{height:88rpx;line-height:88rpx;padding:0 var(--sp-4)} - -/* 弹层内的日期选择器展示 */ -.picker-box{color:var(--text)} -.del-btn{background:var(--red-soft);color:var(--red-ink)} - -/* 管理任务 / 管理宠物 */ -.mtask-row{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) 0;border-bottom:1rpx solid var(--line)} -.mtask-body{flex:1;min-width:0} -.mtask-b{font-size:var(--fs-md);font-weight:var(--fw-b)} -.mtask-pri{ - margin-left:var(--sp-2);font-size:var(--fs-cap);font-weight:var(--fw-b); - color:var(--primary-ink);background:var(--primary-soft);padding:2rpx var(--sp-2);border-radius:var(--r-full); -} -.mtask-p{margin-top:6rpx;font-size:var(--fs-sm);color:var(--muted)} -.mtask-op{ - flex:none;font-size:var(--fs-sm);font-weight:var(--fw-b);color:var(--primary-dark); - padding:var(--sp-1) var(--sp-3);background:var(--surface-2);border-radius:var(--r-xs); -} -.mtask-op.del{color:var(--red-ink)} -.mtask-form-row{display:flex;align-items:center;gap:var(--sp-3)} -/* 这条覆写了全局 .seg-btn 的高度,全局值变化时要一起看 */ -.mtask-form-row .seg-btn{flex:none;padding:0 var(--sp-4);height:var(--h-sm);line-height:var(--h-sm)} -.mtask-form-row .picker-box{flex:1} - -/* 图片选择器 */ -.img-picker{display:flex;flex-wrap:wrap;gap:var(--sp-3)} -.img-thumb{position:relative;width:160rpx;height:160rpx;border-radius:var(--r-sm);overflow:hidden} -.img-thumb image{width:100%;height:100%} -.img-del{ - position:absolute;top:0;right:0;width:44rpx;height:44rpx; - display:flex;align-items:center;justify-content:center; - background:rgba(0,0,0,.55);color:#fff;border-radius:0 var(--r-sm) 0 var(--r-sm); -} -.img-add{ - width:160rpx;height:160rpx;border-radius:var(--r-sm);border:2rpx dashed #D8CFC4; - display:flex;align-items:center;justify-content:center;color:var(--muted2);background:var(--surface-2); -} - -/* 导出健康档案 */ -.export-box{ - max-height:560rpx;overflow-y:auto;background:var(--surface-2);border-radius:var(--r-md); - padding:var(--sp-4);font-size:var(--fs-sm);line-height:1.7;color:var(--text-2);white-space:pre-line; - border:1rpx solid var(--line);margin-bottom:var(--sp-4); -} -.export-box::-webkit-scrollbar{width:0;height:0;display:none} -.mine-tag{ - margin-left:var(--sp-1);font-size:var(--fs-cap);font-weight:var(--fw-b); - color:var(--primary-ink);background:var(--primary-soft);padding:2rpx var(--sp-1);border-radius:var(--r-full); -} - -/* 离屏画布:挪到可视区外而不是 display:none,否则 selectorQuery 取不到节点 */ -.poster-canvas{position:fixed;left:-9999rpx;top:0;width:600rpx;height:840rpx} - -/* ── 评论:头 + 滚动列表 + 常驻输入栏 ── */ -.sheet.sheet-cmp{display:flex;flex-direction:column;height:82vh;padding-bottom:0} -.cmp-head{ - flex:none;text-align:center;padding:0 0 var(--sp-3); - font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text);border-bottom:1rpx solid var(--line); -} -/* 空态:图标 + 两行字,居中在列表区,不再是一行字飘在大片留白里 */ -.cmp-empty{ - display:flex;flex-direction:column;align-items:center;justify-content:center; - padding:120rpx 0;color:var(--muted); -} -.cmp-empty-ic{ - width:140rpx;height:140rpx;border-radius:50%;margin-bottom:var(--sp-4); - display:flex;align-items:center;justify-content:center; - background:var(--primary-soft);color:var(--primary); -} -.cmp-empty-t{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text-2)} -.cmp-empty-p{margin-top:var(--sp-1);font-size:var(--fs-sm);color:var(--muted2)} -.cmp-list{flex:1;min-height:0;padding:var(--sp-3) var(--sp-5) 0} -.cmp-dock{ - flex:none;background:#fff;border-top:1rpx solid var(--line); - padding:var(--sp-3) var(--sp-5) calc(20rpx + env(safe-area-inset-bottom)); - transition:padding-bottom .2s ease; -} - -.cm{display:flex;gap:var(--sp-3);padding:var(--sp-3) 0} -.cm-av{ - width:64rpx;height:64rpx;border-radius:50%;flex:none; - display:flex;align-items:center;justify-content:center; - background:var(--primary-soft);color:var(--primary-ink); - font-size:var(--fs-md);font-weight:var(--fw-b); -} -.cm-av-sm{width:48rpx;height:48rpx;font-size:var(--fs-sm)} -.cm-body{flex:1;min-width:0} -.cm-name{font-size:var(--fs-sm);color:var(--muted)} -.cm-at{color:var(--muted2)} -.cm-text{margin-top:6rpx;font-size:var(--fs-md);line-height:1.55;word-break:break-word} -.cm-imgs{display:flex;flex-wrap:wrap;gap:var(--sp-1);margin-top:var(--sp-2)} -.cm-img{width:150rpx;height:150rpx;border-radius:var(--r-sm)} -.cm-meta{display:flex;align-items:center;gap:var(--sp-4);margin-top:var(--sp-2);font-size:var(--fs-cap);color:var(--muted2)} -.cm-reply-btn{color:var(--muted)} -.cm-del{color:var(--red-ink)} -.cm-replies{margin-top:var(--sp-2)} -.cm-reply{display:flex;gap:var(--sp-2);padding:var(--sp-2) 0} -.cm-more{margin-top:4rpx;font-size:var(--fs-cap);color:var(--muted)} - -/* 「回复 @xxx」的提示条 */ -.cm-replying{ - display:inline-flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-2); - padding:4rpx var(--sp-3);border-radius:var(--r-full); - background:var(--primary-soft);color:var(--primary-ink);font-size:var(--fs-cap); -} -.cm-cancel{display:flex;align-items:center} -/* 收起态:底部一条细条,点了才展开成大面板(小红书那套) */ -.cm-collapsed{ - flex:none;display:flex;align-items:center;gap:var(--sp-3); - background:#fff;border-top:1rpx solid var(--line); - padding:var(--sp-3) var(--sp-5) calc(20rpx + env(safe-area-inset-bottom)); -} -.cm-collapsed-pill{ - flex:1;min-width:0;height:76rpx;line-height:76rpx;padding:0 var(--sp-5); - border-radius:var(--r-full);background:var(--surface-2);color:var(--muted2); - font-size:var(--fs-md);overflow:hidden;white-space:nowrap;text-overflow:ellipsis; -} -.cm-collapsed-pill.has{color:var(--text)} -.cm-collapsed-ic{flex:none;display:flex;align-items:center;color:var(--muted)} - -/* 展开态:大写评论面板 */ -.cm-dock-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--sp-2)} -.cm-dock-title{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} -.cm-dock-close{display:flex;align-items:center;color:var(--muted);padding:var(--sp-1)} -.cm-ta{ - width:100%;box-sizing:border-box;min-height:200rpx;max-height:520rpx;height:auto; - padding:var(--sp-4);background:var(--surface-2);border-radius:var(--r-md); - font-size:var(--fs-lg);line-height:1.5; -} -.cm-toolbar{display:flex;align-items:center;gap:var(--sp-3);margin-top:var(--sp-3)} -.cm-pic{flex:none;display:flex;align-items:center;color:var(--muted)} -.cm-count{margin-left:auto;color:var(--muted2);font-size:var(--fs-cap)} -.cm-send{ - flex:none;height:76rpx;padding:0 var(--sp-6);border-radius:var(--r-full); - display:flex;align-items:center;justify-content:center; - background:var(--line);color:#fff;font-size:var(--fs-md);font-weight:var(--fw-b);transition:.16s ease; -} -.cm-send.on{background:var(--cta)} diff --git a/pets-fe/components/fab/fab.js b/pets-fe/components/fab/fab.js deleted file mode 100644 index 0a094d5..0000000 --- a/pets-fe/components/fab/fab.js +++ /dev/null @@ -1,15 +0,0 @@ -Component({ - options: { addGlobalClass: true }, - properties: { - // 之前漏声明,icon 一直没传进来,pt-icon 收到空值退到 note 兜底字形, - // 首页那个「文档」图标就是这么来的 - icon: { type: String, value: 'plus' }, - // 首页快速记录用肉垫造型(呼应「肉垫计划」),社区发帖仍用普通图标 - paw: { type: Boolean, value: false }, - }, - methods: { - onTap() { - this.triggerEvent('tap'); - } - } -}); diff --git a/pets-fe/components/fab/fab.json b/pets-fe/components/fab/fab.json deleted file mode 100644 index a88211e..0000000 --- a/pets-fe/components/fab/fab.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "component": true, - "usingComponents": { - "pt-icon": "/components/pt-icon/index" - } -} diff --git a/pets-fe/components/fab/fab.wxml b/pets-fe/components/fab/fab.wxml deleted file mode 100644 index 2b85ff4..0000000 --- a/pets-fe/components/fab/fab.wxml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/pets-fe/components/fab/fab.wxss b/pets-fe/components/fab/fab.wxss deleted file mode 100644 index c3bb974..0000000 --- a/pets-fe/components/fab/fab.wxss +++ /dev/null @@ -1,28 +0,0 @@ -/* 统一暖橘圆钮:首页肉垫和社区发帖用同一套主题色 */ -.fab{ - position:fixed;right:36rpx;bottom:calc(190rpx + env(safe-area-inset-bottom)); - width:112rpx;height:112rpx;border-radius:50%; - background:linear-gradient(150deg,#FFB865,#F5943E); - color:#fff; - display:flex;align-items:center;justify-content:center; - box-shadow:0 12rpx 28rpx rgba(214,138,52,.42),0 2rpx 6rpx rgba(214,138,52,.3); - z-index:60; -} -.fab:active{transform:scale(.94)} - -/* 肉垫造型:和基础一致的暖橘圆钮,中间一只奶白肉垫 */ -.fab-paw{width:116rpx;height:116rpx} - -.paw{position:relative;width:66rpx;height:60rpx} -/* 掌垫:上窄下宽的圆角心形块,坐在下方 */ -.paw .pad{ - position:absolute;left:50%;bottom:0;transform:translateX(-50%); - width:44rpx;height:36rpx;background:#FFF7EC; - border-radius:48% 48% 44% 44%/62% 62% 40% 40%; -} -/* 四个脚趾垫,沿上方弧线排开,外侧两个更低、内侧两个更高 */ -.paw .bean{position:absolute;background:#FFF7EC;border-radius:50%} -.paw .bean-1{width:15rpx;height:20rpx;left:0;top:20rpx;transform:rotate(-20deg)} -.paw .bean-2{width:16rpx;height:22rpx;left:18rpx;top:5rpx;transform:rotate(-7deg)} -.paw .bean-3{width:16rpx;height:22rpx;right:18rpx;top:5rpx;transform:rotate(7deg)} -.paw .bean-4{width:15rpx;height:20rpx;right:0;top:20rpx;transform:rotate(20deg)} diff --git a/pets-fe/components/nav-bar/nav-bar.js b/pets-fe/components/nav-bar/nav-bar.js deleted file mode 100644 index 54a8f6c..0000000 --- a/pets-fe/components/nav-bar/nav-bar.js +++ /dev/null @@ -1,31 +0,0 @@ -const app = getApp(); - -Component({ - options: { addGlobalClass: true }, - properties: { - title: { type: String, value: '肉垫计划' }, - showBack: { type: Boolean, value: false } - }, - data: { - statusBarHeight: 20, - navBarHeight: 44 - }, - lifetimes: { - attached() { - this.setData({ - statusBarHeight: app.globalData.statusBarHeight, - navBarHeight: app.globalData.navBarHeight - }); - } - }, - methods: { - onBack() { - const pages = getCurrentPages(); - if (pages.length > 1) { - wx.navigateBack(); - } else { - wx.switchTab({ url: '/pages/home/home' }); - } - } - } -}); diff --git a/pets-fe/components/nav-bar/nav-bar.json b/pets-fe/components/nav-bar/nav-bar.json deleted file mode 100644 index a88211e..0000000 --- a/pets-fe/components/nav-bar/nav-bar.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "component": true, - "usingComponents": { - "pt-icon": "/components/pt-icon/index" - } -} diff --git a/pets-fe/components/nav-bar/nav-bar.wxml b/pets-fe/components/nav-bar/nav-bar.wxml deleted file mode 100644 index 230b1ca..0000000 --- a/pets-fe/components/nav-bar/nav-bar.wxml +++ /dev/null @@ -1,8 +0,0 @@ - - - - {{title}} - - - - diff --git a/pets-fe/components/nav-bar/nav-bar.wxss b/pets-fe/components/nav-bar/nav-bar.wxss deleted file mode 100644 index ca96ab0..0000000 --- a/pets-fe/components/nav-bar/nav-bar.wxss +++ /dev/null @@ -1,26 +0,0 @@ -:host{display:block} - -/* 整页暖橘渐变后,顶部本身就是暖色,nav 不再铺奶白/毛玻璃,透明即可, - 文字用深棕压在暖底上。内容在 .page-scroll 里滚,不会跑到 nav 背后, - 所以不需要毛玻璃遮挡。 */ -.nav-wrap{ - position:fixed;top:0;left:0;right:0;z-index:100; - background:none; -} -.nav-inner{ - position:relative;display:flex;align-items:center;justify-content:center; -} -.nav-title{ - font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--band-ink); - letter-spacing:.5rpx; - max-width:56%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; -} -/* 返回键做成有边界的圆形,和右上角胶囊在视觉上对称 */ -.nav-back{ - position:absolute;left:var(--sp-3);top:50%;transform:translateY(-50%); - width:60rpx;height:60rpx;border-radius:50%; - display:flex;align-items:center;justify-content:center; - color:var(--band-ink);background:rgba(255,255,255,.6); - box-shadow:0 4rpx 12rpx rgba(70,48,25,.06); -} -.nav-back:active{background:rgba(255,255,255,.92)} diff --git a/pets-fe/components/pet-picker/index.js b/pets-fe/components/pet-picker/index.js new file mode 100644 index 0000000..a065da5 --- /dev/null +++ b/pets-fe/components/pet-picker/index.js @@ -0,0 +1,26 @@ +Component({ + properties: { + pets: { type: Array, value: [] }, + selected: { type: Array, value: [] }, + }, + data: { viewPets: [] }, + observers: { + "pets,selected": function (pets, selected) { + this.setData({ + viewPets: (pets || []).map((p) => ({ + ...p, + chosen: (selected || []).includes(p.id), + })), + }); + }, + }, + methods: { + toggle(e) { + const id = e.currentTarget.dataset.id; + const selected = this.data.selected.includes(id) + ? this.data.selected.filter((x) => x !== id) + : [...this.data.selected, id]; + this.triggerEvent("change", { selected }); + }, + }, +}); diff --git a/pets-fe/components/pet-picker/index.json b/pets-fe/components/pet-picker/index.json new file mode 100644 index 0000000..67f8e03 --- /dev/null +++ b/pets-fe/components/pet-picker/index.json @@ -0,0 +1 @@ +{ "component": true } diff --git a/pets-fe/components/pet-picker/index.wxml b/pets-fe/components/pet-picker/index.wxml new file mode 100644 index 0000000..90c68bf --- /dev/null +++ b/pets-fe/components/pet-picker/index.wxml @@ -0,0 +1,14 @@ + + + + 🐕 + {{item.name}} + {{item.chosen?'✓':'+'}} + + diff --git a/pets-fe/components/pet-picker/index.wxss b/pets-fe/components/pet-picker/index.wxss new file mode 100644 index 0000000..aca487f --- /dev/null +++ b/pets-fe/components/pet-picker/index.wxss @@ -0,0 +1,31 @@ +.picker { + display: flex; + gap: 15rpx; + flex-wrap: wrap; +} +.chip { + display: flex; + align-items: center; + gap: 8rpx; + background: white; + border: 2rpx solid #e0eae2; + color: #527164; + padding: 11rpx 18rpx; + border-radius: 99rpx; + font-size: 24rpx; +} +.chip.chosen { + background: #dff0e1; + border-color: #55a273; + color: #145441; + font-weight: 700; +} +.chip image { + width: 36rpx; + height: 36rpx; + border-radius: 50%; +} +.check { + margin-left: 3rpx; + font-weight: 800; +} diff --git a/pets-fe/components/pet-switch/pet-switch.js b/pets-fe/components/pet-switch/pet-switch.js deleted file mode 100644 index 8005212..0000000 --- a/pets-fe/components/pet-switch/pet-switch.js +++ /dev/null @@ -1,39 +0,0 @@ -const store = require('../../utils/store.js'); - -Component({ - options: { addGlobalClass: true }, - properties: { - // 「添加」这一格不是每个页面都该有:计划页/记录页只是在既有宠物之间切, - // 建档入口在首页和「我的」就够了,到处摆一个会让这条 chip 变得很长 - showAdd: { type: Boolean, value: true }, - }, - data: { - pets: [], - currentId: 0, - }, - lifetimes: { - attached() { - this.refresh(); - this._unsub = store.subscribe(() => this.refresh()); - }, - detached() { - if (this._unsub) this._unsub(); - }, - }, - methods: { - refresh() { - this.setData({ - pets: store.getPets(), - currentId: store.currentPetId() || 0, - }); - }, - onPick(e) { - const id = e.currentTarget.dataset.id; - store.switchPet(id); - this.triggerEvent('change', { id }); - }, - onAdd() { - this.triggerEvent('add'); - }, - }, -}); diff --git a/pets-fe/components/pet-switch/pet-switch.json b/pets-fe/components/pet-switch/pet-switch.json deleted file mode 100644 index a88211e..0000000 --- a/pets-fe/components/pet-switch/pet-switch.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "component": true, - "usingComponents": { - "pt-icon": "/components/pt-icon/index" - } -} diff --git a/pets-fe/components/pet-switch/pet-switch.wxml b/pets-fe/components/pet-switch/pet-switch.wxml deleted file mode 100644 index 5ddbe39..0000000 --- a/pets-fe/components/pet-switch/pet-switch.wxml +++ /dev/null @@ -1,7 +0,0 @@ - - - {{item.name}} - + 添加 - diff --git a/pets-fe/components/pet-switch/pet-switch.wxss b/pets-fe/components/pet-switch/pet-switch.wxss deleted file mode 100644 index ea5678c..0000000 --- a/pets-fe/components/pet-switch/pet-switch.wxss +++ /dev/null @@ -1,11 +0,0 @@ -.pet-switch{ - display:block;white-space:nowrap;width:100%;font-size:0; -} -/* chip 是 inline-flex,行内盒默认按 baseline 对齐,不同内容会高低不齐; - 统一固定高度 + vertical-align:middle,彻底对齐。父级 font-size:0 消掉 chip 间的空白基线 */ -.pet-switch .pet-chip{ - margin-right:20rpx;vertical-align:middle; - height:64rpx;padding-top:0;padding-bottom:0;font-size:var(--fs-md); -} -.pet-switch .pet-chip:last-child{margin-right:0} -.pet-chip-add{color:var(--muted)} diff --git a/pets-fe/components/profile-head/index.js b/pets-fe/components/profile-head/index.js deleted file mode 100644 index 9b32445..0000000 --- a/pets-fe/components/profile-head/index.js +++ /dev/null @@ -1,37 +0,0 @@ -// 个人主页头部。「我的」和「宠友主页」共用同一份渲染——自己看和别人看 -// 长得不一样的话,两边会慢慢长歪,装扮功能也就失去意义了。 -// 差别只有一处:自己看是「装扮主页」,别人看是「关注 TA」。 -Component({ - options: { addGlobalClass: true }, - properties: { - card: { type: Object, value: null }, - // 装扮页的实时预览:只看效果,不要底部那排按钮 - preview: { type: Boolean, value: false }, - hideWall: { type: Boolean, value: false }, // 「我的」页藏掉社交宠物墙,避免和「我的毛孩子」卡重复 - }, - data: { initial: '', theme: 'warm' }, - observers: { - card(c) { - if (!c) return; - this.setData({ - // WXML 取不了字符串下标,首字得在 JS 里算 - initial: (c.nickname || '?').slice(0, 1), - theme: c.theme || 'warm', - }); - }, - }, - methods: { - onFollow() { - this.triggerEvent('follow'); - }, - onDecorate() { - this.triggerEvent('decorate'); - }, - onRelations(e) { - this.triggerEvent('relations', { kind: e.currentTarget.dataset.kind }); - }, - onPet(e) { - this.triggerEvent('pet', { id: e.currentTarget.dataset.id }); - }, - }, -}); diff --git a/pets-fe/components/profile-head/index.json b/pets-fe/components/profile-head/index.json deleted file mode 100644 index a88211e..0000000 --- a/pets-fe/components/profile-head/index.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "component": true, - "usingComponents": { - "pt-icon": "/components/pt-icon/index" - } -} diff --git a/pets-fe/components/profile-head/index.wxml b/pets-fe/components/profile-head/index.wxml deleted file mode 100644 index 8028bc3..0000000 --- a/pets-fe/components/profile-head/index.wxml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - {{initial}} - - - {{card.nickname || '毛孩子家长'}}AI - {{card.bio || '这个人很懒,还没写签名'}} - - - - - - {{card.post_count || 0}}帖子 - {{card.following_count || 0}}关注 - {{card.follower_count || 0}}粉丝 - - - - - {{card.stats.days || 0}}养宠天数 - {{card.stats.records || 0}}累计记录 - {{card.stats.streak || 0}}连续打卡 - - - - - - - - {{item.emoji}} - - {{item.name}} - {{item.stage}} - - - - - - {{card.followed ? '已关注' : '关注 TA'}} - - - diff --git a/pets-fe/components/profile-head/index.wxss b/pets-fe/components/profile-head/index.wxss deleted file mode 100644 index 33e24ff..0000000 --- a/pets-fe/components/profile-head/index.wxss +++ /dev/null @@ -1,65 +0,0 @@ -/* 主题只在这里定义两个变量,下面所有着色都走变量。 - 后端存的是 theme key 不是色值,就是为了这套配色能随时在前端改。 */ -.pf{--pf-a:#F5A947;--pf-b:#FFE6BA;--pf-ink:#8A5A18} -.th-warm{--pf-a:#F5A947;--pf-b:#FFE6BA;--pf-ink:#8A5A18} -.th-mint{--pf-a:#3FB98A;--pf-b:#D7F3E7;--pf-ink:#1F6B4E} -.th-sky{--pf-a:#4A9BE0;--pf-b:#D8ECFB;--pf-ink:#245F94} -.th-violet{--pf-a:#9B7BE0;--pf-b:#E8DFFA;--pf-ink:#5B3F9B} -.th-ink{--pf-a:#5A5550;--pf-b:#E4DED7;--pf-ink:#2D2925} - -.pf{ - background:#fff;border-radius:var(--r-lg);overflow:hidden; - box-shadow:var(--sd-1);margin-bottom:var(--sp-5); -} -/* 头图:设了才显示 */ -.pf-bg-img{width:100%;height:200rpx;display:block} - -.pf-body{padding:var(--sp-5)} - -/* 身份行:头像 + 昵称/签名 左对齐 */ -.pf-top{display:flex;align-items:center;gap:var(--sp-4)} -.pf-av{ - flex:none;width:120rpx;height:120rpx;border-radius:50%; - background:var(--pf-b);color:var(--pf-ink);overflow:hidden; - display:flex;align-items:center;justify-content:center; - font-size:var(--fs-xl);font-weight:var(--fw-b); -} -.pf-av-img{width:100%;height:100%;display:block} -.pf-id{flex:1;min-width:0} -.pf-name{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--text)} -.pf-bio{ - font-size:var(--fs-sm);color:var(--muted);margin-top:8rpx; - line-height:1.5;word-break:break-all; -} - -/* 社交数:小一行,数字+标签内联,左对齐 */ -.pf-social{display:flex;gap:var(--sp-6);margin-top:var(--sp-4)} -.pf-stat{display:flex;align-items:baseline;gap:6rpx} -.pf-n{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} -.pf-l{font-size:var(--fs-cap);color:var(--muted)} - -/* 养宠数据高亮带 */ -.pf-keep{ - display:flex;background:var(--pf-b);border-radius:var(--r-md); - padding:var(--sp-4) 0;margin-top:var(--sp-4); -} -.pf-keep-i{flex:1;display:flex;flex-direction:column;align-items:center;gap:2rpx} -.pf-kn{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--pf-ink)} -.pf-kl{font-size:var(--fs-cap);color:var(--pf-ink);opacity:.75} - -.pf-wall{white-space:nowrap;margin-top:var(--sp-4)} -.pf-pet{ - display:inline-block;width:150rpx;vertical-align:top; - margin-right:var(--sp-3);white-space:normal; -} -.pf-pet:last-child{margin-right:0} -.pf-pet-av{ - width:112rpx;height:112rpx;margin:0 auto 8rpx;border-radius:var(--r-md); - background:var(--surface-2);display:flex;align-items:center;justify-content:center; - font-size:52rpx;overflow:hidden; -} -.pf-pet-img{width:100%;height:100%;display:block} -.pf-pet-n{font-size:var(--fs-cap);font-weight:var(--fw-b);color:var(--text);text-align:center} -.pf-pet-s{font-size:var(--fs-cap);color:var(--muted);text-align:center} - -.pf-follow{margin-top:var(--sp-4)} diff --git a/pets-fe/components/pt-icon/index.js b/pets-fe/components/pt-icon/index.js deleted file mode 100644 index ef9649c..0000000 --- a/pets-fe/components/pt-icon/index.js +++ /dev/null @@ -1,59 +0,0 @@ -// 线性图标。图标资源在 styles/iconfont.wxss(mask-image + currentColor)。 -// -// name 可以直接传图标名,也可以传服务端下发的值: -// - 记录类型(HealthRecord.Type,9 值枚举,可靠) -// - 文章分类(Article.Category) -// - 历史 emoji(老数据里 icon 字段存的就是 emoji,只能兜底映射) -// 三层都命不中时退到 fallback(默认 note),绝不留空白。 - -// 记录类型 / 文章分类 → 图标名 -const BY_TYPE = { - weight: 'weight', - poop: 'poop', - food: 'food', - symptom: 'symptom', - medicine: 'medicine', - vaccine: 'vaccine', - deworm: 'deworm', - cost: 'cost', - photo: 'photo', - note: 'doc', -}; - -// 老数据里存的 emoji → 图标名。新数据不再写 emoji,但历史记录得能正常显示 -const BY_EMOJI = { - '⚖️': 'weight', '⚖': 'weight', - '💩': 'poop', - '🍽️': 'food', '🍽': 'food', '🍚': 'food', - '🤒': 'symptom', '🌡️': 'symptom', - '💊': 'medicine', - '💰': 'cost', '💸': 'cost', - '📷': 'photo', '📸': 'photo', - '💉': 'vaccine', - '🛡️': 'deworm', '🛡': 'deworm', - '📄': 'doc', '📋': 'doc', - '📝': 'note', '✍️': 'note', '✍': 'note', - '🐱': 'cat', '🐶': 'dog', '🐾': 'community', - '🔔': 'bell', '⚙️': 'settings', '⚙': 'settings', - '📅': 'plan', '📊': 'report', '🏠': 'home', - '⚠️': 'warn', '📖': 'book', '📚': 'book', -}; - -Component({ - options: { addGlobalClass: true, virtualHost: true }, - properties: { - name: { type: String, value: '' }, - size: { type: Number, value: 32 }, // rpx - color: { type: String, value: '' }, // 留空则跟随父级文字色 - fallback: { type: String, value: 'note' }, - }, - data: { cls: '', style: '' }, - observers: { - 'name,size,color,fallback': function (name, size, color, fallback) { - const n = BY_TYPE[name] || BY_EMOJI[name] || name || fallback; - const style = - 'width:' + size + 'rpx;height:' + size + 'rpx' + (color ? ';background-color:' + color : ''); - this.setData({ cls: 'pt-i pt-i-' + n, style }); - }, - }, -}); diff --git a/pets-fe/components/pt-icon/index.json b/pets-fe/components/pt-icon/index.json deleted file mode 100644 index a89ef4d..0000000 --- a/pets-fe/components/pt-icon/index.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "component": true, - "usingComponents": {} -} diff --git a/pets-fe/components/pt-icon/index.wxml b/pets-fe/components/pt-icon/index.wxml deleted file mode 100644 index eaf7f4e..0000000 --- a/pets-fe/components/pt-icon/index.wxml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/pets-fe/components/pt-icon/index.wxss b/pets-fe/components/pt-icon/index.wxss deleted file mode 100644 index cab0df5..0000000 --- a/pets-fe/components/pt-icon/index.wxss +++ /dev/null @@ -1,17 +0,0 @@ -/* 图标资源在组件内 import:这样 mask 定义和用它的节点在同一个样式作用域, - 不受组件样式隔离影响,custom-tab-bar 这种没开 addGlobalClass 的地方也能直接用。 */ -@import "../../styles/iconfont.wxss"; - -.pt-i { - display: inline-block; - flex: none; - background-color: currentColor; - -webkit-mask-image: var(--m); - mask-image: var(--m); - -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; - -webkit-mask-position: center; - mask-position: center; - -webkit-mask-size: 100% 100%; - mask-size: 100% 100%; -} diff --git a/pets-fe/components/seg-tabs/index.js b/pets-fe/components/seg-tabs/index.js deleted file mode 100644 index 207862d..0000000 --- a/pets-fe/components/seg-tabs/index.js +++ /dev/null @@ -1,38 +0,0 @@ -// 横向切换控件。此前有三套长得像但尺寸各不同的实现: -// plan 的 .inline-tabs(灰槽 + 白块)、community 的 .feed-tab(白药丸 + 橙选中 + 阴影)、 -// record 的 .rf-chip(白药丸 + 橙选中 + 无阴影,高度还差 10rpx) -// 这里合成一个:variant="solid" 对应灰槽白块,variant="pill" 对应横滑药丸。 -// -// items 支持两种写法: -// ['推荐','关注','经验'] —— current 传下标 -// [{key:'',label:'全部'},{key:'weight',label:'体重'}] —— current 传 key -Component({ - options: { addGlobalClass: true }, - properties: { - items: { type: Array, value: [] }, - current: { type: null, value: 0 }, - variant: { type: String, value: 'pill' }, - }, - data: { list: [] }, - observers: { - 'items,current': function (items, current) { - const byKey = typeof current === 'string'; - const list = (items || []).map((it, i) => { - const obj = it && typeof it === 'object'; - const key = obj ? it.key : i; - return { - key, - label: obj ? it.label : it, - active: byKey ? key === current : i === Number(current), - }; - }); - this.setData({ list }); - }, - }, - methods: { - onTap(e) { - const { index, key } = e.currentTarget.dataset; - this.triggerEvent('change', { index: Number(index), key }); - }, - }, -}); diff --git a/pets-fe/components/seg-tabs/index.json b/pets-fe/components/seg-tabs/index.json deleted file mode 100644 index a89ef4d..0000000 --- a/pets-fe/components/seg-tabs/index.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "component": true, - "usingComponents": {} -} diff --git a/pets-fe/components/seg-tabs/index.wxml b/pets-fe/components/seg-tabs/index.wxml deleted file mode 100644 index 0b303f1..0000000 --- a/pets-fe/components/seg-tabs/index.wxml +++ /dev/null @@ -1,9 +0,0 @@ - - {{item.label}} - - - - {{item.label}} - diff --git a/pets-fe/components/seg-tabs/index.wxss b/pets-fe/components/seg-tabs/index.wxss deleted file mode 100644 index 102f21c..0000000 --- a/pets-fe/components/seg-tabs/index.wxss +++ /dev/null @@ -1,21 +0,0 @@ -.st{white-space:nowrap} - -/* 横滑药丸:数量不定、可滚动(社区分类、记录类型筛选) */ -.st-pill{padding:0 0 var(--sp-4)} -.st-pill .st-item{ - display:inline-block;height:var(--h-sm);line-height:var(--h-sm);padding:0 var(--sp-4); - margin-right:var(--sp-2);border-radius:var(--r-full);border:1rpx solid var(--line); - background:#fff;color:var(--muted);font-size:var(--fs-sm);font-weight:var(--fw-b); -} -.st-pill .st-item.on{color:var(--primary-dark);background:var(--primary-soft);border-color:var(--primary-line)} - -/* 灰槽白块:数量固定的互斥视图(计划页三段) */ -.st-solid{ - display:flex;gap:var(--sp-1);padding:var(--sp-1);border-radius:var(--r-md); - background:#EFE8DE;margin-bottom:var(--sp-4); -} -.st-solid .st-item{ - flex:1;height:var(--h-sm);line-height:var(--h-sm);text-align:center;border-radius:var(--r-sm); - color:var(--muted);font-size:var(--fs-md);font-weight:var(--fw-b); -} -.st-solid .st-item.on{background:#fff;color:var(--primary-dark)} diff --git a/pets-fe/components/stat-ring/index.js b/pets-fe/components/stat-ring/index.js deleted file mode 100644 index b62c511..0000000 --- a/pets-fe/components/stat-ring/index.js +++ /dev/null @@ -1,41 +0,0 @@ -// 完成度环。用 SVG data-uri 画(和 record 页的体重折线图同一套路),不用 canvas: -// canvas 在 scroll-view 里的层级、滚动跟随和截图行为全是坑。 -const TAU = 2 * Math.PI; -const R = 42; // viewBox 100 下的半径,留出描边宽度 - -function ringSvg(percent, stroke, track, fill) { - const c = TAU * R; - const p = Math.max(0, Math.min(100, Number(percent) || 0)); - const off = c * (1 - p / 100); - const circle = (color, dash) => - ''; - return ( - '' + - circle(track, '') + - '' + - circle(fill, ' stroke-dasharray="' + c.toFixed(1) + '" stroke-dashoffset="' + off.toFixed(1) + '"') + - '' - ); -} - -Component({ - options: { addGlobalClass: true }, - properties: { - percent: { type: Number, value: 0 }, - size: { type: Number, value: 180 }, // rpx - stroke: { type: Number, value: 8 }, // viewBox 单位,即直径的百分比 - track: { type: String, value: '#FFE6BA' }, - fill: { type: String, value: '#F5A947' }, - }, - data: { src: '', boxStyle: '' }, - observers: { - 'percent,size,stroke,track,fill': function (percent, size, stroke, track, fill) { - const svg = ringSvg(percent, stroke, track, fill); - this.setData({ - src: 'data:image/svg+xml,' + encodeURIComponent(svg), - boxStyle: 'width:' + size + 'rpx;height:' + size + 'rpx', - }); - }, - }, -}); diff --git a/pets-fe/components/stat-ring/index.json b/pets-fe/components/stat-ring/index.json deleted file mode 100644 index a89ef4d..0000000 --- a/pets-fe/components/stat-ring/index.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "component": true, - "usingComponents": {} -} diff --git a/pets-fe/components/stat-ring/index.wxml b/pets-fe/components/stat-ring/index.wxml deleted file mode 100644 index 3864936..0000000 --- a/pets-fe/components/stat-ring/index.wxml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/pets-fe/components/stat-ring/index.wxss b/pets-fe/components/stat-ring/index.wxss deleted file mode 100644 index b3d6b91..0000000 --- a/pets-fe/components/stat-ring/index.wxss +++ /dev/null @@ -1,6 +0,0 @@ -.ring{position:relative;flex:none} -.ring-img{width:100%;height:100%;display:block} -.ring-center{ - position:absolute;left:0;top:0;right:0;bottom:0; - display:flex;flex-direction:column;align-items:center;justify-content:center; -} diff --git a/pets-fe/components/tab-bar/index.js b/pets-fe/components/tab-bar/index.js new file mode 100644 index 0000000..972f7fa --- /dev/null +++ b/pets-fe/components/tab-bar/index.js @@ -0,0 +1,17 @@ +const tabs = [ + { path: "/pages/home/home", label: "首页", icon: "⌂" }, + { path: "/pages/meetups/meetups", label: "约遛", icon: "♡" }, + { path: "/pages/walk/walk", label: "遛狗", icon: "🐾" }, + { path: "/pages/tasks/tasks", label: "任务", icon: "☑" }, + { path: "/pages/pets/pets", label: "宠物", icon: "♙" }, +]; +Component({ + properties: { current: String }, + data: { tabs }, + methods: { + go(e) { + const path = e.currentTarget.dataset.path; + if (path !== this.data.current) wx.redirectTo({ url: path }); + }, + }, +}); diff --git a/pets-fe/components/tab-bar/index.json b/pets-fe/components/tab-bar/index.json new file mode 100644 index 0000000..67f8e03 --- /dev/null +++ b/pets-fe/components/tab-bar/index.json @@ -0,0 +1 @@ +{ "component": true } diff --git a/pets-fe/components/tab-bar/index.wxml b/pets-fe/components/tab-bar/index.wxml new file mode 100644 index 0000000..d60379b --- /dev/null +++ b/pets-fe/components/tab-bar/index.wxml @@ -0,0 +1,12 @@ + + + {{item.icon}} + {{item.label}} + + diff --git a/pets-fe/components/tab-bar/index.wxss b/pets-fe/components/tab-bar/index.wxss new file mode 100644 index 0000000..6dc91e2 --- /dev/null +++ b/pets-fe/components/tab-bar/index.wxss @@ -0,0 +1,47 @@ +.bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: calc(124rpx + env(safe-area-inset-bottom)); + padding-bottom: env(safe-area-inset-bottom); + background: #fff; + display: flex; + justify-content: space-around; + align-items: center; + box-shadow: 0 -8rpx 35rpx rgba(32, 68, 50, 0.06); + z-index: 20; +} +.item { + width: 20%; + display: flex; + flex-direction: column; + align-items: center; + gap: 4rpx; + color: #9eafa4; + font-size: 21rpx; + font-weight: 600; +} +.item.active { + color: #174d40; +} +.icon { + font-size: 46rpx; + height: 52rpx; + line-height: 52rpx; +} +.walk .icon { + width: 82rpx; + height: 82rpx; + line-height: 82rpx; + text-align: center; + background: #174d40; + color: #fff; + border-radius: 27rpx; + box-shadow: 0 10rpx 23rpx rgba(24, 74, 59, 0.23); + margin-top: -30rpx; + font-size: 39rpx; +} +.walk.active .icon { + background: #eaaa69; +} diff --git a/pets-fe/custom-tab-bar/index.js b/pets-fe/custom-tab-bar/index.js deleted file mode 100644 index eb0883b..0000000 --- a/pets-fe/custom-tab-bar/index.js +++ /dev/null @@ -1,33 +0,0 @@ -const LIST = [ - { pagePath: '/pages/home/home', text: '首页', icon: 'home' }, - { pagePath: '/pages/report/report', text: '报告', icon: 'report' }, - { pagePath: '/pages/community/community', text: '社区', icon: 'community' }, - { pagePath: '/pages/mine/mine', text: '我的', icon: 'user' }, -]; - -Component({ - data: { selected: 0, hidden: false, list: LIST }, - lifetimes: { - attached() { - this.syncSelected(); - }, - }, - methods: { - // 高亮由当前页面路径算出来,页面那边不写死 0/1/2/3/4, - // 调 tab 顺序只改上面的 LIST 就够了。 - // - // 触发时机必须由页面 onShow 推(见 utils/tabbar.js):自定义 tabBar 不在 - // 页面节点树里,组件自己的 pageLifetimes.show 拿不到可靠时机,实测切到 - // 「我的」时高亮还留在「首页」。 - syncSelected() { - const pages = getCurrentPages(); - const cur = pages[pages.length - 1]; - if (!cur) return; - const i = LIST.findIndex((t) => t.pagePath === '/' + cur.route); - if (i >= 0) this.setData({ selected: i }); - }, - switchTab(e) { - wx.switchTab({ url: LIST[e.currentTarget.dataset.index].pagePath }); - }, - }, -}); diff --git a/pets-fe/custom-tab-bar/index.json b/pets-fe/custom-tab-bar/index.json deleted file mode 100644 index f1339ee..0000000 --- a/pets-fe/custom-tab-bar/index.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "component": true, - "usingComponents": { - "pt-icon": "../components/pt-icon/index" - } -} diff --git a/pets-fe/custom-tab-bar/index.wxml b/pets-fe/custom-tab-bar/index.wxml deleted file mode 100644 index a28608b..0000000 --- a/pets-fe/custom-tab-bar/index.wxml +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/pets-fe/custom-tab-bar/index.wxss b/pets-fe/custom-tab-bar/index.wxss deleted file mode 100644 index dffc2b9..0000000 --- a/pets-fe/custom-tab-bar/index.wxss +++ /dev/null @@ -1,16 +0,0 @@ -.tabbar{ - position:fixed;left:0;right:0;bottom:0;z-index:50; - height:calc(108rpx + env(safe-area-inset-bottom)); - padding-bottom:calc(6rpx + env(safe-area-inset-bottom)); - background:rgba(255,255,255,.94); - /* CSS 变量是继承属性,样式隔离只挡选择器匹配、不挡继承,所以这里能直接吃 page 上的 token */ - border-top:1rpx solid var(--line,#EEE6DC); - display:flex; - backdrop-filter:blur(32rpx); - -webkit-backdrop-filter:blur(32rpx); -} -.tab{ - flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8rpx; - color:var(--muted,#8D8277);font-size:24rpx; -} -.tab.active{color:var(--primary-dark,#DD8420);font-weight:600} diff --git a/pets-fe/pages/addrecord/addrecord.js b/pets-fe/pages/addrecord/addrecord.js deleted file mode 100644 index bd7d4f6..0000000 --- a/pets-fe/pages/addrecord/addrecord.js +++ /dev/null @@ -1,170 +0,0 @@ -const store = require('../../utils/store.js'); -const api = require('../../utils/api.js'); -const upload = require('../../utils/upload.js'); -const recordTypes = require('../../utils/recordTypes.js'); -const { toastErr } = require('../../utils/ui.js'); - -function pad2(n) { - return n < 10 ? '0' + n : '' + n; -} -function today() { - const d = new Date(); - return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); -} -// "2026-07-30" → "2026.07.30" -function fmtDate(s) { - return (s || '').replace(/-/g, '.'); -} -// 后端用 time.Parse(time.RFC3339) 解 occurred_at,只给日期它解不出来会静默回退成 -// time.Now()——选「昨天」会存成今天且不报错。时区要带真实偏移:转成 UTC 的 Z 形式 -// 落库再转回本地时会把边界日期挪一天。取正午同样是为了远离日界。 -function dayToISO(day) { - if (!day) return ''; - const off = -new Date().getTimezoneOffset(); - const sign = off >= 0 ? '+' : '-'; - const a = Math.abs(off); - return day + 'T12:00:00' + sign + pad2(Math.floor(a / 60)) + ':' + pad2(a % 60); -} - -Page({ - data: { - code: '', - type: {}, - fields: [], - values: [], // 和 fields 同下标 - pet: {}, - date: '', - dateLabel: '', - note: '', - images: [], - saving: false, - }, - onLoad(q) { - const code = (q && q.type) || ''; - // 从首页「今日任务」勾选进来会带 taskId:这一笔记录保存后要把那条任务标完成 - this.taskId = (q && q.taskId) || ''; - const d = today(); - this.setData({ code, date: d, dateLabel: fmtDate(d) }); - store - .ready() - .then(() => recordTypes.load()) - .then(() => this.applyType()) - .catch((e) => toastErr(e)); - }, - applyType() { - const t = recordTypes.get(this.data.code); - if (!t) { - wx.showToast({ title: '这个记录类型不存在了', icon: 'none' }); - return setTimeout(() => wx.navigateBack(), 800); - } - const fields = t.fields || []; - // 单选默认不预选:预选了用户不点也会存下一个他没确认过的值 - this.setData({ type: t, fields, values: fields.map(() => ''), pet: store.getPet() || {} }); - }, - // 多宠时切换记到哪只身上。用 actionSheet 而不是自己画一个选择器 - pickPet() { - const pets = store.getPets() || []; - if (pets.length < 2) return; - wx.showActionSheet({ - itemList: pets.map((p) => p.name), - success: (r) => { - const p = pets[r.tapIndex]; - store.switchPet(p.id); - this.setData({ pet: p }); - }, - fail: () => {}, - }); - }, - onDate(e) { - this.setData({ date: e.detail.value, dateLabel: fmtDate(e.detail.value) }); - }, - onField(e) { - this.setData({ ['values[' + e.currentTarget.dataset.index + ']']: e.detail.value }); - }, - onPickOpt(e) { - const { index, val } = e.currentTarget.dataset; - // 再点一下取消选择——单选组里没有「不选」按钮,只能靠反选 - this.setData({ ['values[' + index + ']']: this.data.values[index] === val ? '' : val }); - }, - onNote(e) { - this.setData({ note: e.detail.value }); - }, - onPickImage() { - upload - .chooseAndUploadImage() - .then((f) => this.setData({ images: this.data.images.concat([f]) })) - .catch((e) => { - if (e && e.canceled) return; - toastErr(e, '上传失败'); - }); - }, - onRemoveImage(e) { - const imgs = this.data.images.slice(); - imgs.splice(e.currentTarget.dataset.index, 1); - this.setData({ images: imgs }); - }, - - // 按字段配置组装请求体。哪个字段落到 num_value / category 完全由后端的 - // to_category / kind 决定,前端不认识「体重」「金额」这些业务词 - buildBody() { - const { fields, values } = this.data; - const body = { type: this.data.code, occurred_at: dayToISO(this.data.date) }; - const parts = []; - fields.forEach((f, i) => { - const v = (values[i] || '').toString().trim(); - if (!v) return; - if (f.kind === 'number') { - const n = parseFloat(v); - if (!isNaN(n)) { - body.num_value = n; - parts.push(v + (f.unit || '')); - } - return; - } - if (f.to_category) body.category = v; - parts.push(v); - }); - body.title = parts.length ? this.data.type.label + ':' + parts.join(' ') : this.data.type.label; - body.description = (this.data.note || '').trim(); - const img = this.data.images[0]; - if (img) { - body.image_file_id = img.id; - body.image_url = img.url; - } - return body; - }, - // 必填校验:数值字段空着存下来是一条没有数值的体重记录,趋势图上是个洞 - missingField() { - const { fields, values } = this.data; - for (let i = 0; i < fields.length; i++) { - if (fields[i].kind === 'number' && !(values[i] || '').toString().trim()) { - return fields[i].label; - } - } - return ''; - }, - onSave() { - if (this.data.saving) return; - const id = store.currentPetId(); - if (!id) return wx.showToast({ title: '先建一份宠物档案', icon: 'none' }); - const miss = this.missingField(); - if (miss) return wx.showToast({ title: '请填写' + miss, icon: 'none' }); - - this.setData({ saving: true }); - api - .createRecord(id, this.buildBody()) - .then(() => { - // 从任务勾选进来的:记录存好后把那条任务标完成(失败不影响记录已保存) - if (this.taskId) return api.toggleTask(this.taskId).catch(() => {}); - }) - .then(() => { - this.setData({ saving: false }); - wx.showToast({ title: this.taskId ? '已完成' : '已记录', icon: 'success' }); - setTimeout(() => wx.navigateBack(), 600); - }) - .catch((e) => { - this.setData({ saving: false }); - toastErr(e, '保存失败'); - }); - }, -}); diff --git a/pets-fe/pages/addrecord/addrecord.json b/pets-fe/pages/addrecord/addrecord.json deleted file mode 100644 index 6594cd8..0000000 --- a/pets-fe/pages/addrecord/addrecord.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar", - "pt-icon": "/components/pt-icon/index" - } -} diff --git a/pets-fe/pages/addrecord/addrecord.wxml b/pets-fe/pages/addrecord/addrecord.wxml deleted file mode 100644 index 0d3c982..0000000 --- a/pets-fe/pages/addrecord/addrecord.wxml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - 记录宠物 - {{pet.name || '选择'}} - - - - - 记录时间 - {{dateLabel}} - - - - - - - - - - - {{item.label}} - - {{item.unit}} - - - - {{item.label}} - - - - - {{item.label}} - - {{o}} - - - - - - - 描述 - - - - - - - - - - - - - - - - - {{saving ? '保存中…' : '保存'}} - diff --git a/pets-fe/pages/addrecord/addrecord.wxss b/pets-fe/pages/addrecord/addrecord.wxss deleted file mode 100644 index 9b4511f..0000000 --- a/pets-fe/pages/addrecord/addrecord.wxss +++ /dev/null @@ -1,50 +0,0 @@ -/* 底部固定保存键,内容要让开它 */ -.ar-body{padding-bottom:calc(180rpx + env(safe-area-inset-bottom))} -.ar-card{padding:0 var(--pad-x)} - -.ar-row{ - display:flex;align-items:center;gap:var(--sp-3); - min-height:112rpx;border-bottom:1rpx solid var(--line); -} -.ar-last,.ar-row:last-child{border-bottom:0} -.ar-k{flex:none;font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} -.ar-v{flex:1;text-align:right;font-size:var(--fs-md);color:var(--text-2)} -.ar-in{flex:1;text-align:right;font-size:var(--fs-md);color:var(--text)} -.ar-unit{flex:none;font-size:var(--fs-sm);color:var(--muted)} - -/* 单选独占一段:选项多的时候挤在一行右侧会换行成一团 */ -.ar-opt-block{padding:var(--sp-4) 0;border-bottom:1rpx solid var(--line)} -.ar-opts{display:flex;flex-wrap:wrap;gap:var(--sp-2);margin-top:var(--sp-3)} -.ar-opt{ - padding:var(--sp-2) var(--sp-4);border-radius:var(--r-full); - background:var(--surface-2);border:1rpx solid var(--line); - font-size:var(--fs-sm);color:var(--text-2); -} -.ar-opt.on{ - background:var(--primary-soft);border-color:var(--primary); - color:var(--primary-ink);font-weight:var(--fw-b); -} - -.ar-h{font-size:var(--fs-md);font-weight:var(--fw-b);padding:var(--sp-4) 0 var(--sp-2)} -.ar-ta{width:100%;min-height:200rpx;font-size:var(--fs-md);line-height:1.6} - -.ar-media{display:flex;gap:var(--sp-3);padding:var(--sp-3) 0 var(--sp-4)} -.ar-thumb{position:relative;width:132rpx;height:132rpx;border-radius:var(--r-sm);overflow:hidden} -.ar-thumb image{width:100%;height:100%} -.ar-del{ - position:absolute;top:0;right:0;width:40rpx;height:40rpx; - display:flex;align-items:center;justify-content:center; - background:rgba(0,0,0,.55);color:#fff;border-radius:0 var(--r-sm) 0 var(--r-sm); -} -.ar-add{ - width:132rpx;height:132rpx;border-radius:var(--r-sm); - background:var(--surface-2);color:var(--muted2); - display:flex;align-items:center;justify-content:center; -} - -.ar-foot{ - position:fixed;left:0;right:0;bottom:0;z-index:50; - padding:var(--sp-4) var(--pad-x) calc(var(--sp-4) + env(safe-area-inset-bottom)); - background:#fff;box-shadow:0 -12rpx 32rpx rgba(70,48,25,.06); -} -.ar-busy{opacity:.6} diff --git a/pets-fe/pages/article/article.js b/pets-fe/pages/article/article.js deleted file mode 100644 index 8fcc26e..0000000 --- a/pets-fe/pages/article/article.js +++ /dev/null @@ -1,31 +0,0 @@ -const api = require('../../utils/api.js'); - -Page({ - data: { - article: null, - loading: true, - error: '', - }, - onLoad(options) { - const id = options.id; - if (!id) { - this.setData({ loading: false, error: '文章不存在' }); - return; - } - api - .getArticle(id) - .then((a) => this.setData({ article: a, loading: false })) - .catch((e) => this.setData({ loading: false, error: e.message || '加载失败' })); - }, - // 从文章跳到对应的记录入口 - goRecord() { - wx.navigateTo({ url: '/pages/record/record' }); - }, - onShareAppMessage() { - const a = this.data.article; - return { - title: a ? a.title : '肉垫计划 · 新手养宠知识', - path: a ? `/pages/article/article?id=${a.id}` : '/pages/home/home', - }; - }, -}); diff --git a/pets-fe/pages/article/article.json b/pets-fe/pages/article/article.json deleted file mode 100644 index e8b68a8..0000000 --- a/pets-fe/pages/article/article.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar", - "pt-icon": "/components/pt-icon/index" - }, - "navigationStyle": "custom", - "navigationBarTitleText": "新手知识" -} diff --git a/pets-fe/pages/article/article.wxml b/pets-fe/pages/article/article.wxml deleted file mode 100644 index c1a7ec1..0000000 --- a/pets-fe/pages/article/article.wxml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - 加载中… - {{error}} - - - - - {{article.title}} - {{article.description}} - - - - {{article.content}} - 这篇文章还没有正文。 - - - - 内容仅供日常照护参考,不能替代医生诊断。 - - - diff --git a/pets-fe/pages/article/article.wxss b/pets-fe/pages/article/article.wxss deleted file mode 100644 index ff5f6af..0000000 --- a/pets-fe/pages/article/article.wxss +++ /dev/null @@ -1,15 +0,0 @@ -.art-head{padding:var(--sp-2) 0 var(--sp-4)} -.art-icon{ - width:96rpx;height:96rpx;border-radius:var(--r-md);margin-bottom:var(--sp-3); - display:flex;align-items:center;justify-content:center; - background:var(--primary-soft);color:var(--primary-ink); -} -.art-title{font-size:var(--fs-2xl);font-weight:var(--fw-b);line-height:1.35;letter-spacing:-.5rpx} -.art-desc{margin-top:var(--sp-2);color:var(--muted);font-size:var(--fs-md);line-height:1.6} - -.art-content{ - font-size:var(--fs-md);line-height:1.85;color:var(--text-2); - white-space:pre-line; /* 正文用真实换行排版 */ -} - -.art-foot{margin-top:var(--sp-4);text-align:center;color:var(--muted);font-size:var(--fs-cap)} diff --git a/pets-fe/pages/community/community.js b/pets-fe/pages/community/community.js deleted file mode 100644 index c487a37..0000000 --- a/pets-fe/pages/community/community.js +++ /dev/null @@ -1,187 +0,0 @@ -const store = require('../../utils/store.js'); -const api = require('../../utils/api.js'); -const { toastErr } = require('../../utils/ui.js'); -const { syncTabBar } = require('../../utils/tabbar.js'); - -// 相对时间。社区里关心的是「多久以前发的」,精确到秒没意义 -function fmtAgo(iso) { - if (!iso) return ''; - const d = new Date(iso); - if (isNaN(d.getTime())) return ''; - const min = Math.floor((Date.now() - d.getTime()) / 60000); - if (min < 1) return '刚刚'; - if (min < 60) return min + ' 分钟前'; - if (min < 60 * 24) return Math.floor(min / 60) + ' 小时前'; - if (min < 60 * 24 * 7) return Math.floor(min / 1440) + ' 天前'; - const p = (n) => (n < 10 ? '0' + n : '' + n); - const sameYear = d.getFullYear() === new Date().getFullYear(); - return (sameYear ? '' : d.getFullYear() + '-') + p(d.getMonth() + 1) + '-' + p(d.getDate()); -} - -const TAG_CLASS = { 求助: 'warn', 经验: 'blue', 精选: 'purple', 避坑: 'red', 晒宠: '' }; - -function mapPost(p) { - const tags = p.tags || []; - const tag = tags[0] || ''; - return { - id: p.id, - author_name: p.author_name, - author_emoji: p.author_emoji || '🐾', - content: p.content, - images: p.images || [], - like_count: p.like_count, - comment_count: p.comment_count, - liked: false, - tag, - tagClass: TAG_CLASS[tag] || '', - is_ai: !!p.is_ai, - user_id: p.user_id, - followed: !!p.followed, - is_self: !!p.is_self, - timeText: fmtAgo(p.created_at), - }; -} - -Page({ - data: { - pet: {}, - feedTabs: ['推荐', '关注', '新手求助', '晒宠', '经验'], - feedIdx: 0, - posts: [], - sheetShow: false, - sheetType: '', - sheetPostId: '', - loaded: false, - loadErr: '', - intoView: '', - }, - onLoad() { - this._unsub = store.subscribe((pet) => this.setData({ pet })); - }, - onUnload() { - if (this._unsub) this._unsub(); - }, - onShow() { - syncTabBar(this); - store - .ready() - .then(() => { - this.setData({ pet: store.getPet() }); - this.loadPosts(); - }) - .catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' })); - }, - loadPosts() { - const tab = this.data.feedTabs[this.data.feedIdx]; - this.setData({ loadErr: '' }); - api - .listPosts(tab, 1) - .then((page) => this.setData({ posts: (page.list || []).map(mapPost), loaded: true })) - .catch((e) => { - // 不能让「加载失败」显示成「还没有帖子」,那是在骗用户 - this.setData({ loadErr: e.message || '加载失败', loaded: true }); - toastErr(e); - }); - }, - // 来自 seg-tabs 的 change 事件 - switchFeedTab(e) { - // 切分类必须回到顶部:不然从「推荐」滚到一半切到「关注」, - // 看到的是新列表的中间,会以为内容错乱了 - this.setData({ feedIdx: Number(e.detail.index), intoView: 'feed-top' }); - this.loadPosts(); - }, - // 滚动时清掉锚点,否则下次再设同一个值不会触发 - onFeedScroll() { - if (this.data.intoView) this.setData({ intoView: '' }); - }, - likePost(e) { - const i = e.currentTarget.dataset.index; - const post = this.data.posts[i]; - api - .likePost(post.id) - .then((res) => { - this.setData({ [`posts[${i}].like_count`]: res.like_count, [`posts[${i}].liked`]: true }); - }) - .catch((e) => toastErr(e, '点赞失败')); - }, - openComments(e) { - this.setData({ sheetPostId: e.currentTarget.dataset.id, sheetType: 'comments', sheetShow: true }); - }, - openSheet(e) { - this.setData({ sheetType: e.currentTarget.dataset.type, sheetPostId: '', sheetShow: true }); - }, - closeSheet() { - this.setData({ sheetShow: false }); - }, - onPosted() { - this.setData({ sheetShow: false }); - this.loadPosts(); - }, - onCommented() { - this.loadPosts(); - }, - // 点头像或昵称进 TA 的主页。关注做了却没有主页,关注完就石沉大海 - goUser(e) { - const id = e.currentTarget.dataset.id; - if (id) wx.navigateTo({ url: `/pages/user/user?id=${id}` }); - }, - goLearn() { - wx.navigateTo({ url: '/pages/learn/learn' }); - }, - // 社区的悬浮键本来就该是发帖,不是记录(原来它跳 AI 聊天页) - onCreatePost() { - this.setData({ sheetType: 'createPost', sheetShow: true }); - }, - // 点缩略图放大预览。只取 http 图(机器人帖的 emoji 占位不参与) - onPreviewImage(e) { - const { urls, cur } = e.currentTarget.dataset; - const list = (urls || []).filter((u) => typeof u === 'string' && u.indexOf('http') === 0); - if (!list.length) return; - wx.previewImage({ current: cur, urls: list }); - }, - // 长按自己的帖子可删除 - onLongPressPost(e) { - const p = this.data.posts[e.currentTarget.dataset.index]; - if (!p || !p.is_self) return; - wx.showModal({ - title: '删除帖子', - content: '确定删除这条帖子?删除后其他人将看不到。', - confirmColor: '#D9534F', - success: (res) => { - if (!res.confirm) return; - api.deletePost(p.id) - .then(() => { wx.showToast({ title: '已删除', icon: 'success' }); this.loadPosts(); }) - .catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' })); - }, - }); - }, - // 关注 / 取关帖子作者 - toggleFollow(e) { - const i = e.currentTarget.dataset.index; - const p = this.data.posts[i]; - if (!p || !p.user_id || p.is_self) return; - const fn = p.followed ? api.unfollowUser : api.followUser; - fn(p.user_id) - .then(() => { - // 同一作者的所有帖子一起更新状态 - const posts = this.data.posts.map((x) => - x.user_id === p.user_id ? { ...x, followed: !p.followed } : x, - ); - this.setData({ posts }); - wx.showToast({ title: p.followed ? '已取消关注' : '已关注', icon: 'none' }); - if (this.data.feedTabs[this.data.feedIdx] === '关注') this.loadPosts(); - }) - .catch((err) => wx.showToast({ title: err.message || '操作失败', icon: 'none' })); - }, - onShareAppMessage(e) { - // 从帖子上的分享按钮转发:带上该帖内容做标题 - if (e && e.from === 'button') { - const c = (e.target.dataset.content || '').slice(0, 40); - return { title: c ? '宠友圈:' + c : '来宠友圈看看大家的毛孩子', path: '/pages/community/community' }; - } - return { title: '宠友圈 · 和铲屎官们交流养宠经验', path: '/pages/community/community' }; - }, - onShareTimeline() { - return { title: '宠友圈 · 和铲屎官们交流养宠经验' }; - }, -}); diff --git a/pets-fe/pages/community/community.json b/pets-fe/pages/community/community.json deleted file mode 100644 index 7f17b0c..0000000 --- a/pets-fe/pages/community/community.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar", - "bottom-sheet": "/components/bottom-sheet/bottom-sheet", - "fab": "/components/fab/fab", - "pt-icon": "/components/pt-icon/index", - "seg-tabs": "/components/seg-tabs/index" - } -} diff --git a/pets-fe/pages/community/community.wxml b/pets-fe/pages/community/community.wxml deleted file mode 100644 index aaf8166..0000000 --- a/pets-fe/pages/community/community.wxml +++ /dev/null @@ -1,61 +0,0 @@ - -module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; } - - - - - - - - - - - - - - {{item.author_emoji}} - - {{item.author_name}}AI - {{item.timeText}} - - - {{item.tag}} - - {{item.content}} - - - - {{img}} - - - - - - {{item.like_count || '点赞'}} - - - - {{item.comment_count || '评论'}} - - - - - - - {{loadErr}} - 点击重试 - - - {{feedTabs[feedIdx] === '关注' ? '还没有关注的人,去「推荐」里关注几个宠友吧 🐾' : '还没有帖子,来发第一条吧 🐾'}} - - - - - - diff --git a/pets-fe/pages/community/community.wxss b/pets-fe/pages/community/community.wxss deleted file mode 100644 index 23a07b2..0000000 --- a/pets-fe/pages/community/community.wxss +++ /dev/null @@ -1,19 +0,0 @@ -.compose-card{margin-bottom:var(--sp-4)} - - -/* 关注按钮 */ - -.retry-btn{ - margin-top:var(--sp-3);display:inline-block;padding:var(--sp-2) var(--sp-6);border-radius:var(--r-full); - background:var(--primary-soft);color:var(--primary-dark);font-weight:var(--fw-b);font-size:var(--fs-sm); -} - -/* 分类标签吸顶,发布按钮收进同一条:原来那个大黑按钮独占 124rpx, - 而它是低频操作,不该在每屏都占着首屏 */ -.feed-bar{display:flex;align-items:flex-start;gap:var(--sp-3)} -.feed-bar .st{flex:1;min-width:0} -.feed-post{ - flex:none;display:flex;align-items:center;gap:4rpx; - height:var(--h-sm);padding:0 var(--sp-3);border-radius:var(--r-full); - background:var(--cta);color:#fff;font-size:var(--fs-sm);font-weight:var(--fw-b); -} diff --git a/pets-fe/pages/decorate/decorate.js b/pets-fe/pages/decorate/decorate.js deleted file mode 100644 index c2680a8..0000000 --- a/pets-fe/pages/decorate/decorate.js +++ /dev/null @@ -1,129 +0,0 @@ -const store = require('../../utils/store.js'); -const api = require('../../utils/api.js'); -const { toastErr } = require('../../utils/ui.js'); - -// key 要和后端 service.UserThemes 一致,色值只在 profile-head 的 wxss 里 -const THEMES = [ - { key: 'warm', label: '暖橙' }, - { key: 'mint', label: '薄荷' }, - { key: 'sky', label: '天青' }, - { key: 'violet', label: '藕紫' }, - { key: 'ink', label: '墨灰' }, -]; - -Page({ - data: { - themes: THEMES, - form: { bio: '', bg_url: '', bg_file_id: '', theme: 'warm', show_pets: true }, - bioLen: 0, - preview: null, - saving: false, - }, - onLoad() { - store - .ready() - .then(() => this.loadCard()) - .catch((e) => toastErr(e)); - }, - loadCard() { - const uid = (store.getUser() || {}).id; - if (!uid) return; - api - .userCard(uid) - .then((card) => { - this.setData({ - form: { - bio: card.bio || '', - bg_url: card.bg_url || '', - bg_file_id: '', // 没重新上传就不带这个字段,避免把已有头图覆盖成空 - theme: card.theme || 'warm', - show_pets: card.show_pets !== false, - }, - bioLen: (card.bio || '').length, - }); - this._card = card; - this.syncPreview(); - }) - .catch((e) => toastErr(e)); - }, - // 预览喂给 profile-head 的就是一份改过的 card,组件那边不用知道自己在被预览 - syncPreview() { - const f = this.data.form; - this.setData({ - preview: Object.assign({}, this._card || {}, { - bio: f.bio, - bg_url: f.bg_url, - theme: f.theme, - show_pets: f.show_pets, - }), - }); - }, - pickBg() { - wx.chooseMedia({ - count: 1, - mediaType: ['image'], - sizeType: ['compressed'], - success: (res) => { - const path = res.tempFiles[0].tempFilePath; - // 先用本地路径顶上,别让用户对着转圈等上传 - this.setData({ 'form.bg_url': path }); - this.syncPreview(); - wx.showLoading({ title: '上传中…' }); - api - .uploadFile(path) - .then((file) => { - wx.hideLoading(); - this.setData({ 'form.bg_file_id': file.id, 'form.bg_url': file.url }); - this.syncPreview(); - }) - .catch((e) => { - wx.hideLoading(); - this.setData({ 'form.bg_url': (this._card && this._card.bg_url) || '' }); - this.syncPreview(); - toastErr(e, '上传失败'); - }); - }, - }); - }, - clearBg() { - // 空字符串是「清掉头图」的显式信号,和「没传」不是一回事 - this.setData({ 'form.bg_url': '', 'form.bg_file_id': '' }); - this.syncPreview(); - }, - pickTheme(e) { - this.setData({ 'form.theme': e.currentTarget.dataset.key }); - this.syncPreview(); - }, - onBio(e) { - this.setData({ 'form.bio': e.detail.value, bioLen: e.detail.value.length }); - this.syncPreview(); - }, - onShowPets(e) { - this.setData({ 'form.show_pets': e.detail.value }); - this.syncPreview(); - }, - save() { - if (this.data.saving) return; - const f = this.data.form; - const body = { bio: f.bio, theme: f.theme, show_pets: f.show_pets }; - // 只有真的动过头图才带 bg_file_id:新上传带新 id,点了「恢复默认渐变」带空串, - // 两者都没有就不传,让后端保持原样 - const had = (this._card && this._card.bg_url) || ''; - if (f.bg_file_id) body.bg_file_id = f.bg_file_id; - else if (had && !f.bg_url) body.bg_file_id = ''; - - this.setData({ saving: true }); - api - .updateDecoration(body) - .then((user) => { - this.setData({ saving: false }); - store.setUser(user); // 返回的就是改完的 user,别再多请求一次 - wx.showToast({ title: '已保存', icon: 'success' }); - setTimeout(() => wx.navigateBack(), 600); - }) - .catch((e) => { - this.setData({ saving: false }); - toastErr(e, '保存失败'); - }); - }, -}); diff --git a/pets-fe/pages/decorate/decorate.json b/pets-fe/pages/decorate/decorate.json deleted file mode 100644 index 3151e63..0000000 --- a/pets-fe/pages/decorate/decorate.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar", - "pt-icon": "/components/pt-icon/index", - "profile-head": "/components/profile-head/index" - }, - "navigationBarTitleText": "装扮主页" -} diff --git a/pets-fe/pages/decorate/decorate.wxml b/pets-fe/pages/decorate/decorate.wxml deleted file mode 100644 index d2c9529..0000000 --- a/pets-fe/pages/decorate/decorate.wxml +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - 头图 - - - - - 点这里换一张头图 - - - 恢复默认渐变 - - - - 主题色 - - - - {{item.label}} - - - - - - - 个性签名 - {{bioLen}}/60 - - - - - - - - 展示我的毛孩子 - 关掉后别人访问你的主页看不到宠物名片墙 - - - - - - {{saving ? '保存中…' : '保存'}} - - diff --git a/pets-fe/pages/decorate/decorate.wxss b/pets-fe/pages/decorate/decorate.wxss deleted file mode 100644 index 65ef28e..0000000 --- a/pets-fe/pages/decorate/decorate.wxss +++ /dev/null @@ -1,30 +0,0 @@ -.dc-bg{ - height:240rpx;border-radius:var(--r-md);overflow:hidden; - background:var(--surface-2);display:flex;align-items:center;justify-content:center; -} -.dc-bg-img{width:100%;height:100%;display:block} -.dc-bg-empty{display:flex;flex-direction:column;align-items:center;gap:var(--sp-2);color:var(--muted)} -.dc-hint{font-size:var(--fs-cap)} -.dc-clear{margin-top:var(--sp-3);text-align:center} - -.dc-themes{display:flex;gap:var(--sp-3)} -.dc-theme{ - flex:1;display:flex;flex-direction:column;align-items:center;gap:8rpx; - padding:var(--sp-3) 0;border-radius:var(--r-sm);border:2rpx solid transparent; -} -.dc-theme.on{border-color:var(--primary);background:var(--primary-soft)} -.dc-swatch{width:64rpx;height:64rpx;border-radius:50%;box-shadow:var(--sd-1)} -.dc-tname{font-size:var(--fs-cap);color:var(--muted)} -.dc-theme.on .dc-tname{color:var(--primary-dark);font-weight:var(--fw-b)} - -/* 色值和 profile-head 的主题保持一致,改那边这里也要跟着改 */ -.sw-warm{background:linear-gradient(135deg,#F5A947,#FFE6BA)} -.sw-mint{background:linear-gradient(135deg,#3FB98A,#D7F3E7)} -.sw-sky{background:linear-gradient(135deg,#4A9BE0,#D8ECFB)} -.sw-violet{background:linear-gradient(135deg,#9B7BE0,#E8DFFA)} -.sw-ink{background:linear-gradient(135deg,#5A5550,#E4DED7)} - -.dc-count{font-size:var(--fs-cap);color:var(--muted)} - -.dc-row{display:flex;align-items:center;gap:var(--sp-4)} -.dc-row-main{flex:1;min-width:0} diff --git a/pets-fe/pages/expense/expense.js b/pets-fe/pages/expense/expense.js deleted file mode 100644 index a4a44ab..0000000 --- a/pets-fe/pages/expense/expense.js +++ /dev/null @@ -1,158 +0,0 @@ -const store = require('../../utils/store.js'); -const api = require('../../utils/api.js'); -const recordTypes = require('../../utils/recordTypes.js'); -const { toastErr } = require('../../utils/ui.js'); - -function pad2(n) { - return n < 10 ? '0' + n : '' + n; -} -function today() { - const d = new Date(); - return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); -} -// 同 addrecord:后端用 time.Parse(time.RFC3339) 解 occurred_at,只给日期解不出来会 -// 静默回退成 now;时区要带真实偏移,转 UTC 的 Z 形式落库会把边界日期挪一天 -function dayToISO(day) { - if (!day) return ''; - const off = -new Date().getTimezoneOffset(); - const sign = off >= 0 ? '+' : '-'; - const a = Math.abs(off); - return day + 'T12:00:00' + sign + pad2(Math.floor(a / 60)) + ':' + pad2(a % 60); -} -function fmtDay(iso) { - if (!iso) return ''; - const d = new Date(iso); - return d.getMonth() + 1 + '月' + d.getDate() + '日'; -} - -const PAGE = 50; - -Page({ - data: { - bill: {}, - cats: [], - cat: '', - customOn: false, - customCat: '', - amount: '', - date: '', - note: '', - list: [], - saving: false, - }, - onLoad() { - this.setData({ date: today() }); - store - .ready() - .then(() => recordTypes.load()) - .then(() => this.loadCats()) - .catch((e) => toastErr(e)); - }, - onShow() { - if (this._inited) this.reload(); - this._inited = true; - }, - onReady() { - this.reload(); - }, - // 类别预置读 record_types 里 cost 的字段配置——后台改一处, - // 这页和 addrecord 同时生效,不会出现两套类别把账单统计切开 - loadCats() { - const t = recordTypes.get('cost'); - const opt = ((t && t.fields) || []).find((f) => f.kind === 'options'); - this.setData({ cats: (opt && opt.options) || [] }); - }, - reload() { - const id = store.currentPetId(); - if (!id) return; - api - .getBill(id, 'month') - .then((bill) => this.setData({ bill: bill || {} })) - .catch(() => {}); - api - .getRecords(id, { type: 'cost', page: 1, pageSize: PAGE }) - .then((res) => { - const now = new Date(); - const list = (res.list || []) - // 只显示本月:接口按时间倒序返回,这里前端切一刀, - // 不为了「本月」再加一个后端参数 - .filter((r) => { - const d = new Date(r.occurred_at); - return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth(); - }) - .map((r) => ({ ...r, timeText: fmtDay(r.occurred_at) })); - this.setData({ list }); - }) - .catch(() => {}); - }, - onAmount(e) { - this.setData({ amount: e.detail.value }); - }, - onPickCat(e) { - const v = e.currentTarget.dataset.val; - this.setData({ cat: this.data.cat === v ? '' : v, customOn: false, customCat: '' }); - }, - toggleCustom() { - const on = !this.data.customOn; - this.setData({ customOn: on, cat: on ? '' : this.data.cat }); - }, - onCustomCat(e) { - this.setData({ customCat: e.detail.value }); - }, - onDate(e) { - this.setData({ date: e.detail.value }); - }, - onNote(e) { - this.setData({ note: e.detail.value }); - }, - onSave() { - if (this.data.saving) return; - const id = store.currentPetId(); - if (!id) return wx.showToast({ title: '先建一份宠物档案', icon: 'none' }); - const amt = parseFloat(this.data.amount); - if (!(amt > 0)) return wx.showToast({ title: '填一个金额', icon: 'none' }); - const cat = (this.data.customOn ? this.data.customCat : this.data.cat).trim(); - if (!cat) return wx.showToast({ title: '选一个类别', icon: 'none' }); - - this.setData({ saving: true }); - // 落的是 type='cost' 的普通记录:金额进 num_value、类别进 category, - // 和 addrecord 走同一条路,报告页的账单聚合照样认 - api - .createRecord(id, { - type: 'cost', - title: '记账:' + amt + '元 ' + cat, - num_value: amt, - category: cat, - description: (this.data.note || '').trim(), - occurred_at: dayToISO(this.data.date), - }) - .then(() => { - this.setData({ saving: false, amount: '', note: '', customCat: '', customOn: false, cat: '' }); - wx.showToast({ title: '已记下', icon: 'success' }); - this.reload(); - }) - .catch((e) => { - this.setData({ saving: false }); - toastErr(e, '保存失败'); - }); - }, - // 长按删除。流水里记错一笔很常见,不给删的话账单就一直是错的 - onDelete(e) { - const id = e.currentTarget.dataset.id; - wx.showModal({ - title: '删掉这笔?', - content: '删了账单统计会跟着变。', - confirmColor: '#EE7D73', - success: (r) => { - if (!r.confirm) return; - api - .deleteRecord(id) - .then(() => this.reload()) - .catch((err) => toastErr(err, '删除失败')); - }, - }); - }, - onShareAppMessage() { - return { title: '养宠一个月花了多少?', path: '/pages/expense/expense' }; - }, -}); diff --git a/pets-fe/pages/expense/expense.json b/pets-fe/pages/expense/expense.json deleted file mode 100644 index 6594cd8..0000000 --- a/pets-fe/pages/expense/expense.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar", - "pt-icon": "/components/pt-icon/index" - } -} diff --git a/pets-fe/pages/expense/expense.wxml b/pets-fe/pages/expense/expense.wxml deleted file mode 100644 index 61da736..0000000 --- a/pets-fe/pages/expense/expense.wxml +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - {{bill.period || '本月'}}花销 - ¥{{bill.total || 0}} - 单笔最高 ¥{{bill.max_single}} - - - {{item.category}}¥{{item.amount}} - - - - 这个月还没记过花销 - - - - - 记一笔 - - - ¥ - - - - 类别 - - {{item}} - - - 自定义 - - - - - 日期 - - {{date}} - - - 备注 - - - - {{saving ? '保存中…' : '记下这笔'}} - - - - - - - 本月流水 - {{list.length}} 笔 - - - - {{item.category || '未分类'}} - {{item.timeText}}{{item.description ? '|' + item.description : ''}} - - ¥{{item.num_value}} - - 还没有流水。长按某笔可以删除。 - - - diff --git a/pets-fe/pages/expense/expense.wxss b/pets-fe/pages/expense/expense.wxss deleted file mode 100644 index 13d59de..0000000 --- a/pets-fe/pages/expense/expense.wxss +++ /dev/null @@ -1,34 +0,0 @@ -.ex-body{padding-bottom:var(--pad-b-plain)} - -/* 合计卡:数字要够大,这页第一眼就该看到花了多少 */ -.ex-sum{text-align:center} -.ex-sum-k{font-size:var(--fs-sm);color:var(--muted)} -.ex-sum-n{font-size:72rpx;font-weight:var(--fw-b);color:var(--text);line-height:1.2;margin:var(--sp-1) 0} -.ex-sum-s{font-size:var(--fs-cap);color:var(--muted2)} -.ex-cats{margin-top:var(--sp-5);text-align:left} -.ex-cat{margin-bottom:var(--sp-3)} -.ex-cat:last-child{margin-bottom:0} -.ex-cat-top{display:flex;justify-content:space-between;font-size:var(--fs-sm);color:var(--text-2);margin-bottom:6rpx} - -/* 金额输入做大,和键盘上的数字对得上 */ -.ex-amt{display:flex;align-items:baseline;gap:var(--sp-2);padding:var(--sp-4) 0 var(--sp-2);border-bottom:2rpx solid var(--primary-line)} -.ex-cur{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--primary-dark)} -.ex-in{flex:1;font-size:64rpx;font-weight:var(--fw-b);color:var(--text);height:84rpx} - -.ex-label{font-size:var(--fs-sm);color:var(--muted);margin:var(--sp-4) 0 var(--sp-2)} -.ex-chips{display:flex;flex-wrap:wrap;gap:var(--sp-2)} -.ex-chip{ - padding:var(--sp-2) var(--sp-4);border-radius:var(--r-full); - background:var(--surface-2);border:1rpx solid var(--line); - font-size:var(--fs-sm);color:var(--text-2); -} -.ex-chip.on{background:var(--primary-soft);border-color:var(--primary);color:var(--primary-ink);font-weight:var(--fw-b)} -.ex-chip-add{display:inline-flex;align-items:center;gap:4rpx} -.ex-custom{margin-top:var(--sp-3)} -.ex-save{margin-top:var(--sp-5)} -.ex-busy{opacity:.6} - -.ex-row{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) 0;border-bottom:1rpx solid var(--line)} -.ex-row:last-of-type{border-bottom:0} -.ex-row-main{flex:1;min-width:0} -.ex-row-amt{flex:none;font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text)} diff --git a/pets-fe/pages/history/history.js b/pets-fe/pages/history/history.js deleted file mode 100644 index 5205d9e..0000000 --- a/pets-fe/pages/history/history.js +++ /dev/null @@ -1,149 +0,0 @@ -const store = require('../../utils/store.js'); -const api = require('../../utils/api.js'); -const { toastErr } = require('../../utils/ui.js'); -const recordTypes = require('../../utils/recordTypes.js'); - -function pad(n) { - return n < 10 ? '0' + n : '' + n; -} -function fmtTime(iso) { - if (!iso) return ''; - const d = new Date(iso); - const now = new Date(); - const hm = pad(d.getHours()) + ':' + pad(d.getMinutes()); - if (d.toDateString() === now.toDateString()) return '今天 ' + hm; - const y = new Date(now.getTime() - 86400000); - if (d.toDateString() === y.toDateString()) return '昨天 ' + hm; - return d.getMonth() + 1 + '月' + d.getDate() + '日'; -} -// 每条记录的分组配色。这页只读历史,引一份轻量的 code→组 映射就够 -function toneOf(type) { - const t = recordTypes.get(type); - return (t && t.tone) || 'tone-1'; -} -function mapRecord(r) { - return { - id: r.id, - // 图标按 type 取(可靠);老数据 icon 字段里的 emoji 只作兜底 - type: r.type || r.icon || '', - tone: toneOf(r.type), - title: r.title, - desc: fmtTime(r.occurred_at) + (r.description ? '|' + r.description : ''), - image: r.image_url || '', - }; -} - -// 时间轴类型筛选。常用的一组,不是全部 24 种——seg-tabs 放满会挤成一条难点的横条 -const REC_FILTERS = [ - { key: '', label: '全部' }, - { key: 'weight', label: '体重' }, - { key: 'poop', label: '排便' }, - { key: 'food', label: '饮食' }, - { key: 'symptom', label: '异常' }, - { key: 'vaccine', label: '疫苗' }, - { key: 'cost', label: '记账' }, - { key: 'photo', label: '照片' }, -]; - -const REC_PAGE = 8; - -Page({ - data: { - pet: {}, - timeline: [], - recPage: 1, - recTotal: 0, - recHasMore: false, - recFilters: REC_FILTERS, - recFilterIdx: 0, - }, - onLoad() { - this._unsub = store.subscribe((pet) => { - this.setData({ pet }); - if (!this._inited) return; - this.loadRecords(); - }); - }, - onUnload() { - if (this._unsub) this._unsub(); - }, - onShow() { - // 分组配色要读类型缓存。这页可能直接从报告页进来,缓存没经过首页热过, - // 自己拉一次(load 内部只真请求一次),拉完刷新已有列表的配色 - recordTypes.load().then(() => this.setData({ timeline: this.data.timeline.map((t) => ({ ...t, tone: toneOf(t.type) })) })).catch(() => {}); - store - .ready() - .then(() => { - this._inited = true; - this.setData({ pet: store.getPet() }); - this.loadRecords(); - }) - .catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' })); - }, - curFilter() { - return this.data.recFilters[this.data.recFilterIdx].key; - }, - onFilterTap(e) { - const i = Number(e.detail.index); - if (i === this.data.recFilterIdx) return; - this.setData({ recFilterIdx: i }, () => this.loadRecords()); - }, - loadRecords() { - const id = store.currentPetId(); - if (!id) return; - api - .getRecords(id, { page: 1, pageSize: REC_PAGE, type: this.curFilter() }) - .then((res) => { - const list = (res.list || []).map(mapRecord); - this.setData({ timeline: list, recPage: 1, recTotal: res.total || 0, recHasMore: list.length < (res.total || 0) }); - }) - .catch((e) => toastErr(e)); - }, - loadMoreRecords() { - const id = store.currentPetId(); - if (!id) return; - const next = this.data.recPage + 1; - api - .getRecords(id, { page: next, pageSize: REC_PAGE, type: this.curFilter() }) - .then((res) => { - const merged = this.data.timeline.concat((res.list || []).map(mapRecord)); - this.setData({ timeline: merged, recPage: next, recTotal: res.total || 0, recHasMore: merged.length < (res.total || 0) }); - }) - .catch(() => {}); - }, - collapseRecords() { - this.loadRecords(); - }, - onDeleteRecord(e) { - const r = this.data.timeline[e.currentTarget.dataset.index]; - if (!r || !r.id) return; - wx.showModal({ - title: '删除记录', - content: '确定删除「' + r.title + '」?删除后不可恢复。', - confirmColor: '#D9534F', - success: (res) => { - if (!res.confirm) return; - api - .deleteRecord(r.id) - .then(() => { - wx.showToast({ title: '已删除', icon: 'success' }); - this.loadRecords(); - }) - .catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' })); - }, - }); - }, - previewImage(e) { - const src = e.currentTarget.dataset.src; - if (src) wx.previewImage({ urls: [src], current: src }); - }, - onAddPet() { - wx.navigateTo({ url: '/pages/petform/petform' }); - }, - onShareAppMessage() { - return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' }; - }, - onShareTimeline() { - return { title: '用肉垫计划记录毛孩子的成长' }; - }, -}); diff --git a/pets-fe/pages/history/history.json b/pets-fe/pages/history/history.json deleted file mode 100644 index 6b28821..0000000 --- a/pets-fe/pages/history/history.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar", - "pet-switch": "/components/pet-switch/pet-switch", - "pt-icon": "/components/pt-icon/index", - "seg-tabs": "/components/seg-tabs/index" - } -} diff --git a/pets-fe/pages/history/history.wxml b/pets-fe/pages/history/history.wxml deleted file mode 100644 index 473f1bf..0000000 --- a/pets-fe/pages/history/history.wxml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - 还没有毛孩子的档案 - 建好档并记上几笔,这里就会长出完整的记录流水。 - 建一份档案 - - - - - - - - - - - - {{item.title}}{{item.desc}} - - - - 这个分类下还没有记录 - - 长按任意一条可删除 - 查看更多(共 {{recTotal}} 条) - 收起 - - - - diff --git a/pets-fe/pages/history/history.wxss b/pets-fe/pages/history/history.wxss deleted file mode 100644 index 8764002..0000000 --- a/pets-fe/pages/history/history.wxss +++ /dev/null @@ -1,19 +0,0 @@ -/* 全部记录时间轴。洞察和体重趋势已经搬到报告页,这里只留流水 + 筛选 */ - -.health-timeline{display:flex;flex-direction:column;gap:var(--sp-2)} -.health-event{display:flex;gap:var(--sp-3);background:var(--surface-2);border-radius:var(--r-md);padding:var(--sp-4)} -/* 底色和文字色由 .tone-N 提供(记录类型决定),这里只管形状 */ -.event-dot{ - width:64rpx;height:64rpx;border-radius:var(--r-sm);flex:none; - display:flex;align-items:center;justify-content:center; -} -.he-b{font-size:var(--fs-md);font-weight:var(--fw-b)} -.he-p{color:var(--muted);font-size:var(--fs-sm);line-height:1.45;margin-top:6rpx} -.he-body{flex:1;min-width:0} -.he-img{margin-top:var(--sp-2);width:220rpx;height:220rpx;border-radius:var(--r-sm)} - -.tl-tip{margin-top:var(--sp-3);text-align:center;color:var(--muted);font-size:var(--fs-cap)} -.tl-more{ - margin-top:var(--sp-3);padding:var(--sp-3);text-align:center;color:var(--primary-dark); - background:var(--surface-2);border-radius:var(--r-md);font-size:var(--fs-sm);font-weight:var(--fw-b); -} diff --git a/pets-fe/pages/home/home.js b/pets-fe/pages/home/home.js index c7491b6..28996dd 100644 --- a/pets-fe/pages/home/home.js +++ b/pets-fe/pages/home/home.js @@ -1,236 +1,68 @@ -const store = require('../../utils/store.js'); -const api = require('../../utils/api.js'); -const { toastErr } = require('../../utils/ui.js'); -const { syncTabBar } = require('../../utils/tabbar.js'); -const recordTypes = require('../../utils/recordTypes.js'); -const petmeta = require('../../utils/petmeta.js'); - -function mapTask(t) { - return { id: t.id, title: t.title, sub: t.description, priority: t.priority, done: t.done, sheet: t.sheet_type }; -} - -// 时间轴上的相对时间。首页关心的是「什么时候记的」,精确到分只在今天有意义 -function fmtWhen(iso) { - if (!iso) return ''; - const d = new Date(iso); - if (isNaN(d.getTime())) return ''; - const p = (n) => (n < 10 ? '0' + n : '' + n); - const now = new Date(); - const sameDay = d.toDateString() === now.toDateString(); - if (sameDay) return '今天 ' + p(d.getHours()) + ':' + p(d.getMinutes()); - const y = new Date(now.getTime() - 86400000); - if (d.toDateString() === y.toDateString()) return '昨天 ' + p(d.getHours()) + ':' + p(d.getMinutes()); - return d.getMonth() + 1 + '月' + d.getDate() + '日'; -} - -// 首页最近记录只是速览,固定最新几条,翻全量去「全部记录」页,避免首页无限滚 -const TL_LIMIT = 6; - +const { secure, toast } = require("../../utils/api"); Page({ data: { - pet: {}, - zodiac: '', - daysTogether: 0, - sexIcon: '', - sexTone: '', + pets: [], + selected: [], tasks: [], - planHint: '', - drift: {}, - needNextPlan: false, - monthCost: 0, - timeline: [], - hasMore: false, - }, - onLoad() { - this._unsub = store.subscribe((pet) => { - this.applyPet(pet); - if (!this._inited) return; - this.loadAll(); - }); - }, - onUnload() { - if (this._unsub) this._unsub(); + walkCount: 0, + name: "铲屎官", + loading: true, }, onShow() { - syncTabBar(this); - recordTypes.load().catch(() => {}); - store - .ready() - .then(() => { - this._inited = true; - this.applyPet(store.getPet()); - this.loadAll(); - }) - .catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' })); + this.load(); }, - // 门面上那几样都是本地算的,不用等接口 - applyPet(pet) { - const p = pet || {}; - const badge = petmeta.genderBadge(p.gender); - this.setData({ - pet: p, - zodiac: petmeta.zodiac(p.birthday), - daysTogether: petmeta.daysTogether(p), - sexIcon: badge.icon, - sexTone: badge.tone, - }); + async load() { + try { + const [pets, tasks, walks, me] = await Promise.all([ + secure("/pets"), + secure("/tasks"), + secure("/walks"), + secure("/me"), + ]); + const chosen = wx.getStorageSync("walk_pet_ids") || [], + month = new Date().toISOString().slice(0, 7); + this.setData({ + pets, + selected: chosen.filter((id) => pets.some((p) => p.id === id)), + tasks: tasks.items, + walkCount: walks.filter((w) => w.started_at.slice(0, 7) === month) + .length, + name: me.nickname || "铲屎官", + loading: false, + }); + } catch (e) { + this.setData({ loading: false }); + toast(e); + } }, - loadAll() { - this.loadTasks(); - this.loadTimeline(1); - this.loadEntries(); - this.loadDrift(); + pick(e) { + this.setData({ selected: e.detail.selected }); + wx.setStorageSync("walk_pet_ids", e.detail.selected); }, - loadTasks() { - const id = store.currentPetId(); - if (!id) return; - api - .getTasks(id) - .then((tasks) => this.setData({ tasks: (tasks || []).map(mapTask) })) - .catch((e) => toastErr(e)); - }, - // 两个入口上的副标题:计划还剩几件、本月花了多少。 - // 空着的话入口就是两个没有信息量的图标 - // 阶段漂移检查。放在 loadEntries 里而不是单独一次 —— 它和「计划还剩几件」 - // 都是首页那一屏要的,一起拉省一个来回 - loadDrift() { - const id = store.currentPetId(); - if (!id) return; - api - .petStageDrift(id) - .then((d) => this.setData({ drift: d || {} })) - .catch(() => this.setData({ drift: {} })); - }, - loadEntries() { - const id = store.currentPetId(); - if (!id) return; - // 副标题优先说「该排下月计划了」——那是有时效的事; - // 本月还剩几件是随时能看的,让位给它 - api - .planMonthStatus(id) - .then((st) => { - const s = st || {}; - this.setData({ - needNextPlan: !!s.need_next, - planHint: s.need_next - ? (s.next_label || '下月') + '计划还没排' - : s.this_undone > 0 - ? '本月还有 ' + s.this_undone + ' 件' - : s.this_month > 0 - ? '本月都做完了' - : '还没有安排', - }); - }) - .catch(() => this.setData({ needNextPlan: false, planHint: '' })); - api - .getBill(id, 'month') - .then((b) => this.setData({ monthCost: (b && b.total) || 0 })) - .catch(() => this.setData({ monthCost: 0 })); - }, - loadTimeline() { - const id = store.currentPetId(); - if (!id) return; - api - .getRecords(id, { page: 1, pageSize: TL_LIMIT }) - .then((res) => { - const list = (res.list || []).map((r) => ({ - id: r.id, - type: r.type, - tone: (recordTypes.get(r.type) || {}).tone || 'tone-1', - title: r.title, - description: r.description, - image: r.image_url || '', - timeText: fmtWhen(r.occurred_at), - })); - // 有更多就露出「查看全部」入口,跳到能分页筛选的全部记录页 - this.setData({ timeline: list, hasMore: (res.total || 0) > list.length }); - }) - .catch(() => {}); - }, - goHistory() { - wx.navigateTo({ url: '/pages/history/history' }); - }, - previewImage(e) { - const url = e.currentTarget.dataset.url; - if (url) wx.previewImage({ urls: [url], current: url }); - }, - onDeleteRecord(e) { - const id = e.currentTarget.dataset.id; - wx.showModal({ - title: '删掉这条记录?', - content: '趋势和统计会跟着变。', - confirmColor: '#EE7D73', - success: (r) => { - if (!r.confirm) return; - api - .deleteRecord(id) - .then(() => this.loadTimeline(1)) - .catch((err) => toastErr(err, '删除失败')); - }, - }); - }, - onTapTask(e) { - const i = e.currentTarget.dataset.index; - const t = this.data.tasks[i]; - // 任务关联了记录类型:去记那一笔。未完成的带上 taskId, - // 在记录页保存成功后才回填这条任务完成(onShow 回来自动刷新任务+最近记录) - if (t.sheet) { - let url = '/pages/addrecord/addrecord?type=' + t.sheet; - if (!t.done) url += '&taskId=' + t.id; - wx.navigateTo({ url }); + start() { + if (!this.data.selected.length) { + wx.showToast({ title: "先选择一起出门的狗狗", icon: "none" }); return; } - // 没有关联记录类型的任务(自定义/无 sheet_type):直接切换完成。 - // 后端 toggle 会在完成时补一条 note 记录,这里完成后刷新最近记录即可 - api - .toggleTask(t.id) - .then((res) => { - const tasks = this.data.tasks.slice(); - tasks[i] = Object.assign({}, tasks[i], { done: res.done }); - this.setData({ tasks }); - if (res.done) this.loadTimeline(1); - }) - .catch((err) => toastErr(err, '操作失败')); + wx.redirectTo({ url: "/pages/walk/walk" }); }, - goPlan() { - wx.navigateTo({ url: '/pages/plan/plan' }); + goTasks() { + wx.redirectTo({ url: "/pages/tasks/tasks" }); }, - // 去建档页改阶段。改完回来 onShow 会重新拉一次漂移状态,提示自然消失。 - // 不在首页直接改:改阶段之后要不要套新方案是同一件事,放在一起做才连贯 - // 点「一起生活的天数」空态,去建档页补到家日期 - onDaysTap() { - const id = this.data.pet && this.data.pet.id; - if (!this.data.daysTogether && id) wx.navigateTo({ url: '/pages/petform/petform?id=' + id }); + goWalks() { + wx.redirectTo({ url: "/pages/walk/walk" }); }, - // 身份卡:门面卡右上角进,用当前宠物 - goIDCard() { - const id = this.data.pet && this.data.pet.id; - if (id) wx.navigateTo({ url: '/pages/idcard/idcard?id=' + id }); - }, - goStageUpdate() { - const id = this.data.pet && this.data.pet.id; - if (id) wx.navigateTo({ url: '/pages/petform/petform?id=' + id }); - }, - goExpense() { - wx.navigateTo({ url: '/pages/expense/expense' }); - }, - goRecord() { - wx.navigateTo({ url: '/pages/record/record' }); - }, - onQuickRecord() { - wx.navigateTo({ url: '/pages/record/record' }); - }, - onAddPet() { - wx.navigateTo({ url: '/pages/petform/petform' }); - }, - onHeroTap() { - const id = this.data.pet && this.data.pet.id; - wx.navigateTo({ url: '/pages/petform/petform' + (id ? '?id=' + id : '') }); - }, - onShareAppMessage() { - return { title: '肉垫计划 · 每天照顾好毛孩子', path: '/pages/home/home' }; - }, - onShareTimeline() { - return { title: '肉垫计划 · 每天照顾好毛孩子' }; + 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: new Date().toLocaleDateString("sv-SE"), + completed: !task.completed, + }); + await this.load(); + } catch (err) { + toast(err); + } }, }); diff --git a/pets-fe/pages/home/home.json b/pets-fe/pages/home/home.json index d173c86..1d3b22b 100644 --- a/pets-fe/pages/home/home.json +++ b/pets-fe/pages/home/home.json @@ -1,8 +1 @@ -{ - "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar", - "pet-switch": "/components/pet-switch/pet-switch", - "fab": "/components/fab/fab", - "pt-icon": "/components/pt-icon/index" - } -} +{ "usingComponents": { "tab-bar": "/components/tab-bar/index", "pet-picker": "/components/pet-picker/index" } } diff --git a/pets-fe/pages/home/home.wxml b/pets-fe/pages/home/home.wxml index 7ac843e..afb4d1f 100644 --- a/pets-fe/pages/home/home.wxml +++ b/pets-fe/pages/home/home.wxml @@ -1,116 +1,83 @@ - - - - - - - - - - - - {{pet.emoji || '🐾'}} - {{sexIcon}} - - - {{pet.name || '还没有毛孩子'}} - - {{pet.breed}} - {{pet.age}} - {{zodiac}} - - - - 身份卡 + + 你好,今天也一起好好生活 + + 早安,{{name}} ☀️ + + + + + 今日的主角 + 小短腿,出发吧! + 多只狗狗 · 每一天都是好日子 + ● 陪伴它们的每一天,都值得记录 + + + + + 带它们出去走走 + 遛狗记录 › + + + + 🐕 + + 今天的风刚刚好 + 选择同行的狗狗,一起出门转转。 - - - 我们一起生活的第 {{daysTogether}} 天 - 还没填到家日期,一起生活的天数显示 - + 这次带谁?可以多选 + + + + + + 本月遛狗 + + {{walkCount}} + 次 + + + + 一起出发 + + {{selected.length}} + 只 + + + + 今日任务 + + {{tasks.length}} + 项 + - - - - - - {{pet.name}}已经 {{drift.age_label}},从{{drift.stored}}进入{{drift.computed}}了 - - {{drift.basis}} - 这个阶段的重点:{{drift.focus}} - {{drift.size}} - 去更新阶段和方案 + + 今天要做的事 + 全部任务 › - - - - - - - - + + + + {{item.completed?'✓':''}} - 管理计划 - {{planHint}} - - - - 记花销 - {{monthCost > 0 ? '本月 ¥' + monthCost : '还没记过'}} - - - - - - - 今日任务 - - - 管理 + + {{item.category==='water'?'💧':item.category==='food'?'🥣':'🐾'}} + + + {{item.title}} + {{item.time_of_day}} - - - - {{item.title}}{{item.sub}} - {{item.priority}} - + + 今天还没有任务,给狗狗安排一件小事吧 🐾 - - - - 最近记录 - + 记一笔 - - - - - - - - - {{item.timeText}} - - {{item.title}} - {{item.description}} - - - - - - - 还没有记录。点上面的「记一笔」开始, - 体重趋势和健康洞察都会从这些记录里长出来。 - - 查看全部记录 › - - - + diff --git a/pets-fe/pages/home/home.wxss b/pets-fe/pages/home/home.wxss index b0b0914..9691430 100644 --- a/pets-fe/pages/home/home.wxss +++ b/pets-fe/pages/home/home.wxss @@ -1,102 +1,173 @@ - -/* 门面卡 */ -.hero-card{ - background: - radial-gradient(circle at 95% 16%, rgba(245,169,71,.22), transparent 32%), - linear-gradient(135deg,#FFFFFF,#FFF6E9); +.top { + margin-top: 10rpx; } - -/* 一周日期条 */ - - -/* ===== 日期带(原计划页「日历」tab 并过来) ===== */ -/* 某天的安排 */ -.dd-title{font-weight:var(--fw-b);font-size:var(--fs-md);margin-bottom:var(--sp-3)} - -/* ===== 接下来 ===== */ - -/* ===== 门面:宠物名片 ===== */ -.hero-card{padding:var(--sp-5)} -.hero-row{display:flex;align-items:center;gap:var(--sp-4)} -.hero-av{ - position:relative;flex:none;width:128rpx;height:128rpx;border-radius:50%; - background:var(--primary-soft);display:flex;align-items:center;justify-content:center; - font-size:60rpx;overflow:visible; +.avatar { + width: 70rpx; + height: 70rpx; + border-radius: 50%; + border: 6rpx solid white; + box-shadow: 0 5rpx 22rpx #174b3520; } -.hero-av-img{width:100%;height:100%;border-radius:50%} -/* 性别徽章叠在头像右下:一个很省事但很出效果的细节 */ -.hero-sex{ - position:absolute;right:-4rpx;bottom:-4rpx;width:42rpx;height:42rpx;border-radius:50%; - border:4rpx solid #fff;color:#fff;font-size:22rpx;line-height:1; - display:flex;align-items:center;justify-content:center; +.hero { + height: 330rpx; + background: #194f42; + border-radius: 38rpx; + position: relative; + overflow: hidden; + display: flex; + align-items: center; + padding: 35rpx; + color: white; } -.hero-sex.male{background:#5B9BE8} -.hero-sex.female{background:#EE85A8} -.hero-main{flex:1;min-width:0} -.hero-title{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--text)} -.hero-meta{display:flex;flex-wrap:wrap;gap:var(--sp-3);font-size:var(--fs-sm);color:var(--muted);margin-top:6rpx} -.hero-arrow{flex:none;color:var(--muted2);display:flex;align-items:center} -.hero-days{ - margin-top:var(--sp-4);background:var(--primary-soft);color:var(--primary-ink); - border-radius:var(--r-md);text-align:center;padding:var(--sp-3); - font-size:var(--fs-md);font-weight:var(--fw-b); +.hero-copy { + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + width: 62%; } -.hd-n{font-size:var(--fs-lg);margin:0 4rpx} - -/* ===== 两个入口 ===== */ -.entry-card{display:flex;padding:var(--sp-4) var(--sp-3)} -.entry{flex:1;display:flex;flex-direction:column;align-items:center;gap:4rpx} -.entry-ic{ - width:76rpx;height:76rpx;border-radius:var(--r-md); - display:flex;align-items:center;justify-content:center;margin-bottom:2rpx; +.hero-small { + color: #abd9b8; + font-size: 23rpx; + font-weight: 700; } -.entry-b{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} -.entry-p{font-size:var(--fs-cap);color:var(--muted)} - -/* ===== 记录时间轴 ===== */ -.tl-head{display:flex;align-items:center;justify-content:space-between;margin:var(--sp-5) 0 var(--sp-3)} -.tl-item{display:flex;gap:var(--sp-3)} -.tl-rail{flex:none;width:56rpx;display:flex;flex-direction:column;align-items:center} -.tl-dot{ - width:56rpx;height:56rpx;border-radius:50%;flex:none; - display:flex;align-items:center;justify-content:center; +.hero-title { + font-size: 43rpx; + font-weight: 800; + margin: 16rpx 0; } -/* 竖线连起来才像一条时间轴;最后一条的线会被 body 的高度自然截断 */ -.tl-line{flex:1;width:2rpx;background:var(--line);margin:6rpx 0} -.tl-item:last-of-type .tl-line{display:none} -.tl-body{flex:1;min-width:0;padding-bottom:var(--sp-4)} -.tl-when{font-size:var(--fs-cap);color:var(--muted);margin-bottom:6rpx} -.tl-card{background:#fff;border-radius:var(--r-md);box-shadow:var(--sd-1);padding:var(--sp-4)} -.tl-title{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} -.tl-desc{font-size:var(--fs-sm);color:var(--text-2);margin-top:6rpx;line-height:1.5} -.tl-img{width:100%;height:320rpx;border-radius:var(--r-sm);margin-top:var(--sp-3);display:block} -.tl-more{text-align:center;padding:var(--sp-4);font-size:var(--fs-sm);color:var(--muted)} - -.entry-ic{position:relative} -.entry-dot{ - position:absolute;top:-2rpx;right:-2rpx;width:18rpx;height:18rpx; - border-radius:50%;background:var(--red);border:3rpx solid #fff; +.hero-meta { + font-size: 23rpx; + color: #d9eadd; } -.entry-p.warn{color:var(--red-ink);font-weight:var(--fw-b)} - -/* 阶段漂移提示。用主色不用红色:这是「可以做一下」不是「出问题了」 */ -.drift-card{border:2rpx solid var(--primary-line);background:var(--primary-soft)} -.drift-top{display:flex;align-items:flex-start;gap:var(--sp-2);color:var(--primary-dark)} -.drift-b{flex:1;min-width:0;font-size:var(--fs-md);font-weight:var(--fw-b);line-height:1.5} -.drift-p{font-size:var(--fs-sm);color:var(--primary-ink);line-height:1.6;margin-top:var(--sp-2)} -.drift-focus{font-size:var(--fs-sm);color:var(--primary-ink);line-height:1.6;margin-top:6rpx} -.drift-size{font-size:var(--fs-cap);color:var(--primary-dark);opacity:.8;margin-top:6rpx} -.drift-cta{margin-top:var(--sp-4)} - -/* 没填到家日期时的天数空态:压成灰、可点去补 */ -.hero-days-empty{background:var(--surface-2);color:var(--muted)} -.hero-days-empty .hd-n{color:var(--muted)} - -/* 门面卡右上角的身份卡入口。原来那里是个 next 箭头(点整卡进编辑), - 身份卡做成小胶囊,和整卡点击分开 */ -.hero-idcard{ - flex:none;align-self:flex-start;display:flex;align-items:center;gap:4rpx; - padding:8rpx var(--sp-3);border-radius:var(--r-full); - background:var(--primary-soft);color:var(--primary-dark); - font-size:var(--fs-cap);font-weight:var(--fw-b); +.hero-caption { + font-size: 20rpx; + color: #b5e2be; + margin-top: 36rpx; +} +.hero image { + position: absolute; + width: 310rpx; + height: 290rpx; + right: -24rpx; + bottom: 8rpx; +} +.heading { + margin-top: 8rpx; +} +.link { + font-size: 24rpx; + color: #398668; + font-weight: 700; +} +.walk-card { + padding: 26rpx; +} +.walk-intro { + display: flex; + gap: 22rpx; + align-items: center; +} +.dog-emoji { + width: 145rpx; + height: 145rpx; + border-radius: 26rpx; + background: #dfeedd; + display: grid; + place-items: center; + font-size: 72rpx; +} +.walk-title { + font-size: 31rpx; + font-weight: 800; + margin-bottom: 12rpx; +} +.walk-intro .muted { + font-size: 23rpx; +} +.picker-label { + font-size: 23rpx; + color: #7d9989; + margin: 27rpx 0 16rpx; +} +.start { + margin: 24rpx 0 0; + width: 100%; +} +.stats { + display: flex; + gap: 14rpx; + margin-top: 20rpx; +} +.stats > view { + flex: 1; + background: white; + border-radius: 28rpx; + padding: 25rpx 18rpx; +} +.stats > view:nth-child(2) { + background: #f7eadc; +} +.stats > view:nth-child(3) { + background: #e8efea; +} +.stats text { + display: block; + color: #8da49a; + font-size: 22rpx; +} +.stats strong { + display: block; + font-size: 40rpx; + margin-top: 12rpx; +} +.stats small { + font-size: 22rpx; + color: #94a69d; +} +.tasks { + padding: 12rpx 25rpx; +} +.task { + display: flex; + align-items: center; + gap: 17rpx; + padding: 22rpx 0; + border-bottom: 1rpx solid #edf1ed; +} +.task:last-child { + border: 0; +} +.check { + width: 40rpx; + height: 40rpx; + border: 3rpx solid #cfdfd2; + border-radius: 13rpx; + text-align: center; + line-height: 37rpx; + color: white; +} +.check.done { + background: #3b9164; + border-color: #3b9164; +} +.task-icon { + width: 65rpx; + height: 65rpx; + border-radius: 16rpx; + background: #f0f5ee; + display: grid; + place-items: center; + font-size: 34rpx; +} +.task-main { + display: flex; + flex-direction: column; + gap: 6rpx; +} +.task-name { + font-weight: 700; +} +.task-main .muted { + font-size: 22rpx; } diff --git a/pets-fe/pages/idcard/idcard.js b/pets-fe/pages/idcard/idcard.js deleted file mode 100644 index d3bae6c..0000000 --- a/pets-fe/pages/idcard/idcard.js +++ /dev/null @@ -1,419 +0,0 @@ -const store = require('../../utils/store.js'); -const api = require('../../utils/api.js'); -const { toastErr } = require('../../utils/ui.js'); - -// 卡片画布尺寸(逻辑像素)。真实导出会按 dpr 放大 -const W = 620; -const H = 1480; - -// 在 canvas 2d 上加载一张图(远程或本地),resolve 出可直接 drawImage 的 image 对象。 -// 加载失败 resolve(null)——头像或小程序码缺一张不该让整张卡画不出来 -function loadImage(canvas, src) { - return new Promise((resolve) => { - if (!src) return resolve(null); - const img = canvas.createImage(); - img.onload = () => resolve(img); - img.onerror = () => resolve(null); - img.src = src; - }); -} - -Page({ - data: { - petId: '', - card: null, - cardImg: '', - qrReady: false, - saving: false, - needPhone: false, // 没手机号:门槛,先验证才生成 - phoneRefused: false, // 用户拒绝了授权 - manualMode: false, // 手动填手机号(getPhoneNumber 唤不起时的兜底) - manualPhone: '', - }, - onLoad(q) { - store - .ready() - .then(() => { - const id = (q && q.id) || store.currentPetId(); - if (!id) { - wx.showToast({ title: '先选一只毛孩子', icon: 'none' }); - return; - } - this.setData({ petId: id }); - return api.petIDCard(id); - }) - .then((card) => { - if (!card) return; - this.setData({ card, qrReady: !!card.qr_ready }); - // 门槛:没手机号不生成,先引导用微信验证 - if (!card.phone) { - this.setData({ needPhone: true }); - return; - } - // 布局要等页面渲染完 canvas 节点才拿得到,放 nextTick - wx.nextTick(() => this.draw()); - }) - .catch((e) => toastErr(e)); - }, - - draw() { - wx.createSelectorQuery() - .select('#idCanvas') - .fields({ node: true, size: true }) - .exec((res) => { - const node = res && res[0] && res[0].node; - if (!node) return; - let dpr = 2; - try { - dpr = (wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()).pixelRatio || 2; - } catch (e) { - dpr = 2; - } - this._dpr = dpr; - node.width = W * dpr; - node.height = H * dpr; - const ctx = node.getContext('2d'); - ctx.scale(dpr, dpr); - - const c = this.data.card || {}; - // 两张远程图都就绪再开画,否则小程序码会画不上或画一半(plan 里点过名的坑) - Promise.all([loadImage(node, c.avatar_url), loadImage(node, c.qr_url)]).then(([avatar, qr]) => { - this.paint(ctx, node, c, avatar, qr); - }); - }); - }, - - paint(ctx, node, c, avatar, qr) { - ctx.clearRect(0, 0, W, H); - - // 外层留白背景 - ctx.fillStyle = '#F3EFE8'; - ctx.fillRect(0, 0, W, H); - - const pad = 30; - const cardW = W - pad * 2; - - const fx = pad, cardW2 = cardW; - - // ══════════ 正面卡(暖黄)══════════ - // 正反两张卡等高(都用 cardH),中间内容垂直居中,避免正面留空 - const cardH = 510; - const fH = cardH; - const fY = pad; - const gf = ctx.createLinearGradient(fx, fY, fx, fY + fH); - gf.addColorStop(0, '#FFF7E0'); - gf.addColorStop(1, '#FFE7AE'); - this.roundRect(ctx, fx, fY, cardW2, fH, 30); - ctx.fillStyle = gf; - ctx.fill(); - - // 头像圆 + 白边 - const avD = 150, avX = fx + 36, avY = fY + 52; - ctx.save(); - ctx.beginPath(); - ctx.arc(avX + avD / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2); - ctx.clip(); - if (avatar) { - const iw = avatar.width || avD, ih = avatar.height || avD, side = Math.min(iw, ih); - ctx.drawImage(avatar, (iw - side) / 2, (ih - side) / 2, side, side, avX, avY, avD, avD); - } else { - ctx.fillStyle = '#FFD9A3'; - ctx.fillRect(avX, avY, avD, avD); - ctx.fillStyle = '#8A5A18'; - ctx.font = '64px sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(c.species === '狗狗' ? '🐶' : '🐱', avX + avD / 2, avY + avD / 2); - } - ctx.restore(); - ctx.textBaseline = 'alphabetic'; - ctx.beginPath(); - ctx.arc(avX + avD / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2); - ctx.strokeStyle = '#fff'; - ctx.lineWidth = 8; - ctx.stroke(); - - // 名字 + 性别(头像右侧)。性别符号必须用「名字的字号」量宽度再定位, - // 否则换成小字号后 measureText 量出的宽度偏小,符号会怼到名字上 - const nx = avX + avD + 28; - const nm = c.name || '毛孩子'; - ctx.textAlign = 'left'; - ctx.fillStyle = '#2D2925'; - ctx.font = '700 52px sans-serif'; - ctx.fillText(nm, nx, avY + 46); - const nmW = ctx.measureText(nm).width; - if (c.gender) { - const isF = c.gender === '女孩'; - ctx.fillStyle = isF ? '#E86A9B' : '#5B9BE8'; - ctx.font = '600 34px sans-serif'; - ctx.fillText(isF ? '♀' : '♂', nx + nmW + 16, avY + 44); - } - // 头像右的资料行 - const kvF = (label, value, yy) => { - ctx.textAlign = 'left'; - ctx.font = '24px sans-serif'; - ctx.fillStyle = '#A98C5A'; - ctx.fillText(label, nx, yy); - ctx.fillStyle = '#3D3427'; - ctx.font = '600 26px sans-serif'; - ctx.fillText(value || '—', nx + 78, yy); - }; - kvF('性别', c.gender, avY + 92); - kvF('品种', c.breed, avY + 130); - kvF('毛色', c.color, avY + 168); - kvF('年龄', c.age, avY + 206); - - // 介绍 quote(在 info 和底栏之间垂直居中) - if (c.intro) { - const ty = fY + 300 + Math.max(0, Math.floor(((fY + fH - 92) - (fY + 300) - 60) / 2)); - ctx.fillStyle = 'rgba(255,255,255,.6)'; - this.roundRect(ctx, fx + 34, ty, cardW2 - 68, 60, 16); - ctx.fill(); - ctx.fillStyle = '#7A5A2E'; - ctx.font = '25px sans-serif'; - ctx.textAlign = 'left'; - ctx.fillText('“ ' + c.intro + ' ”', fx + 54, ty + 40); - } - - // 底部:身份ID + 一起生活天数(贴卡片底边) - ctx.textAlign = 'left'; - ctx.fillStyle = '#9C7A3E'; - ctx.font = '22px sans-serif'; - ctx.fillText('ID ' + (c.id_no || ''), fx + 34, fY + fH - 56); - if (c.days > 0) { - ctx.fillText('和主人一起生活的第 ' + c.days + ' 天', fx + 34, fY + fH - 22); - } - - // 正面印章(右上角) - this.drawStamp(ctx, fx + cardW2 - 78, fY + 82); - - // ══════════ 照护卡(淡紫,仿证件版式)══════════ - const bY = fY + fH + 40, bH = cardH; - const gbc = ctx.createLinearGradient(fx, bY, fx, bY + bH); - gbc.addColorStop(0, '#E6E7FB'); - gbc.addColorStop(1, '#F2F0FA'); - this.roundRect(ctx, fx, bY, cardW2, bH, 30); - ctx.fillStyle = gbc; - ctx.fill(); - - // 大标题(两行左对齐):小字「X星人地球移民」+ 大字「居民身份卡」 - // 汪星人=狗、喵星人=猫,其它兜底宠星人 - const star = c.species === '狗狗' ? '汪' : c.species === '猫猫' ? '喵' : '宠'; - ctx.textAlign = 'left'; - ctx.fillStyle = '#8A90AE'; - ctx.font = '600 27px sans-serif'; - this.spacedText(ctx, star + '星人地球移民', fx + 44, bY + 64, 8); - ctx.fillStyle = '#3E4A63'; - ctx.font = '800 58px sans-serif'; - ctx.fillText('居民身份卡', fx + 44, bY + 140); - - // 资料行(标签 + 值) - const rowX = fx + 44; - const kvB = (label, value, yy) => { - ctx.textAlign = 'left'; - ctx.font = '25px sans-serif'; - ctx.fillStyle = '#9A9FB5'; - ctx.fillText(label, rowX, yy); - ctx.font = '600 26px sans-serif'; - ctx.fillStyle = '#3E4A63'; - ctx.fillText(value || '—', rowX + 160, yy); - }; - let ry = bY + 198; - kvB('主人', c.owner, ry); ry += 46; - kvB('联系电话', c.phone || '未验证', ry); ry += 46; - kvB('签发机构', c.org || '肉垫计划', ry); ry += 46; - kvB('有效期限', c.validity || '永久有效', ry); - - // 小程序码(右下角,直接落在淡紫底上) - const qrD = 114, qrX = fx + cardW2 - 44 - qrD, qrY = bY + bH - 30 - qrD; - ctx.fillStyle = '#fff'; - this.roundRect(ctx, qrX, qrY, qrD, qrD, 14); - ctx.fill(); - if (qr) { - ctx.drawImage(qr, qrX + 9, qrY + 9, qrD - 18, qrD - 18); - } else { - ctx.fillStyle = '#A7ABC4'; - ctx.font = '18px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('小程序码', qrX + qrD / 2, qrY + qrD / 2 - 2); - ctx.fillText('待发布', qrX + qrD / 2, qrY + qrD / 2 + 24); - } - // 扫码说明(左侧,和小程序码同高) - ctx.textAlign = 'left'; - ctx.fillStyle = '#8A90AE'; - ctx.font = '22px sans-serif'; - ctx.fillText('微信扫一扫', fx + 44, qrY + qrD / 2 - 6); - ctx.fillText('查看' + (c.name || '') + '的身份信息', fx + 44, qrY + qrD / 2 + 28); - - // 背面印章(右上角) - this.drawStamp(ctx, fx + cardW2 - 84, bY + 92); - - // 只导出到实际内容底部,避免卡片下面拖一条空白 - const dpr = this._dpr || 2; - const contentH = bY + bH + pad; - wx.canvasToTempFilePath( - { - canvas: node, - x: 0, - y: 0, - width: W, - height: contentH, - destWidth: W * dpr, - destHeight: contentH * dpr, - fileType: 'png', - success: (r) => this.setData({ cardImg: r.tempFilePath }), - fail: () => wx.showToast({ title: '生成失败,稍后再试', icon: 'none' }), - }, - this, - ); - }, - - roundRect(ctx, x, y, w, h, r) { - ctx.beginPath(); - ctx.moveTo(x + r, y); - ctx.arcTo(x + w, y, x + w, y + h, r); - ctx.arcTo(x + w, y + h, x, y + h, r); - ctx.arcTo(x, y + h, x, y, r); - ctx.arcTo(x, y, x + w, y, r); - ctx.closePath(); - }, - - // 逐字加字距地画一行(canvas 的 letterSpacing 各端支持不一,手动排最稳) - spacedText(ctx, text, x, y, gap) { - let cx = x; - for (const ch of text) { - ctx.fillText(ch, cx, y); - cx += ctx.measureText(ch).width + gap; - } - }, - - // 五角星路径(调用方负责 fill/stroke) - star(ctx, cx, cy, rO, rI, n) { - ctx.beginPath(); - for (let i = 0; i < n * 2; i++) { - const r = i % 2 === 0 ? rO : rI; - const a = -Math.PI / 2 + (i * Math.PI) / n; - const x = cx + r * Math.cos(a), y = cy + r * Math.sin(a); - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - ctx.closePath(); - }, - - // 红色公章:双圈 + 顶部五角星 + 「肉垫计划 / 官方认证」,略微旋转、半透明 - drawStamp(ctx, cx, cy) { - ctx.save(); - ctx.translate(cx, cy); - ctx.rotate(-0.16); - ctx.globalAlpha = 0.68; - ctx.strokeStyle = '#CE4534'; - ctx.fillStyle = '#CE4534'; - ctx.lineWidth = 4; - ctx.beginPath(); - ctx.arc(0, 0, 52, 0, Math.PI * 2); - ctx.stroke(); - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.arc(0, 0, 43, 0, Math.PI * 2); - ctx.stroke(); - this.star(ctx, 0, -23, 11, 4.6, 5); - ctx.fill(); - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.font = '700 25px sans-serif'; - ctx.fillText('肉垫计划', 0, 10); - ctx.font = '13px sans-serif'; - ctx.fillText('官方认证', 0, 32); - ctx.restore(); - ctx.globalAlpha = 1; - ctx.textAlign = 'left'; - ctx.textBaseline = 'alphabetic'; - }, - - // 微信取手机号:新版 getPhoneNumber 返回动态 code,后端换成真实手机号存库, - // 拿回后重新取卡(此时电话就有了)并重画 - onGetPhone(e) { - const d = e.detail || {}; - if (!d.code) { - // 用户拒绝授权:不给生成,明确提示 - this.setData({ phoneRefused: true }); - return wx.showToast({ title: '没有手机号,无法生成身份卡', icon: 'none' }); - } - wx.showLoading({ title: '验证中…', mask: true }); - api - .bindPhone(d.code) - .then(() => api.petIDCard(this.data.petId)) - .then((card) => { - wx.hideLoading(); - if (!card) return; - this.setData({ card, qrReady: !!card.qr_ready, needPhone: false, phoneRefused: false }); - wx.nextTick(() => this.draw()); - wx.showToast({ title: '手机号已验证', icon: 'success' }); - }) - .catch((err) => { - wx.hideLoading(); - toastErr(err, '验证失败'); - }); - }, - - // 手动填手机号兜底(getPhoneNumber 需企业认证,唤不起时用这个) - showManual() { - this.setData({ manualMode: true }); - }, - onManualPhone(e) { - this.setData({ manualPhone: e.detail.value }); - }, - submitManual() { - const p = (this.data.manualPhone || '').trim(); - if (!/^1\d{10}$/.test(p)) return wx.showToast({ title: '手机号格式不对', icon: 'none' }); - wx.showLoading({ title: '保存中…', mask: true }); - api - .updateProfile({ phone: p }) - .then(() => api.petIDCard(this.data.petId)) - .then((card) => { - wx.hideLoading(); - if (!card) return; - this.setData({ card, qrReady: !!card.qr_ready, needPhone: false, manualMode: false }); - wx.nextTick(() => this.draw()); - }) - .catch((err) => { - wx.hideLoading(); - toastErr(err, '保存失败'); - }); - }, - - onSave() { - if (!this.data.cardImg) return wx.showToast({ title: '还在生成,稍等一下', icon: 'none' }); - if (this.data.saving) return; - this.setData({ saving: true }); - wx.saveImageToPhotosAlbum({ - filePath: this.data.cardImg, - success: () => wx.showToast({ title: '已保存到相册', icon: 'success' }), - fail: (err) => { - const msg = (err && err.errMsg) || ''; - if (msg.indexOf('cancel') >= 0) return; - // 拒过一次后 authorize 不再弹,必须引导去设置页 - if (msg.indexOf('auth deny') >= 0 || msg.indexOf('authorize') >= 0) { - wx.showModal({ - title: '需要相册权限', - content: '保存身份卡需要允许访问相册,去设置里打开一下?', - confirmText: '去设置', - success: (r) => r.confirm && wx.openSetting({}), - }); - return; - } - wx.showToast({ title: '保存失败', icon: 'none' }); - }, - complete: () => this.setData({ saving: false }), - }); - }, - - onShareAppMessage() { - const c = this.data.card || {}; - return { - title: `${c.name || '我家毛孩子'} 的居民身份卡`, - path: '/pages/idcard/idcard?id=' + this.data.petId, - imageUrl: this.data.cardImg || '', // 用画好的卡当分享封面 - }; - }, -}); diff --git a/pets-fe/pages/idcard/idcard.json b/pets-fe/pages/idcard/idcard.json deleted file mode 100644 index 6594cd8..0000000 --- a/pets-fe/pages/idcard/idcard.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar", - "pt-icon": "/components/pt-icon/index" - } -} diff --git a/pets-fe/pages/idcard/idcard.wxml b/pets-fe/pages/idcard/idcard.wxml deleted file mode 100644 index 972a709..0000000 --- a/pets-fe/pages/idcard/idcard.wxml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - 生成身份卡需要手机号 - 身份卡背面的「联系电话」用于走失时联系你,验证后才能生成 - - - - 没有手机号,无法生成身份卡 - 唤不起来?手动填写手机号 - - - - - - - - - - - - - 正在生成身份卡… - - - - - 下载到相册 - - - - - - - - - diff --git a/pets-fe/pages/idcard/idcard.wxss b/pets-fe/pages/idcard/idcard.wxss deleted file mode 100644 index 6cd7fe7..0000000 --- a/pets-fe/pages/idcard/idcard.wxss +++ /dev/null @@ -1,38 +0,0 @@ -.ic-body{padding-top:var(--sp-4)} - -/* 画好的卡片图。宽度撑满、按比例高 */ -.ic-stage{border-radius:var(--r-lg);overflow:hidden;box-shadow:var(--sd-2);background:#F3EFE8;min-height:600rpx} -.ic-img{width:100%;display:block} -.ic-loading{padding:200rpx 0;text-align:center;color:var(--muted);font-size:var(--fs-sm)} - -.ic-tip{ - margin-top:var(--sp-4);padding:var(--sp-3);border-radius:var(--r-sm); - background:var(--primary-soft);color:var(--primary-ink); - font-size:var(--fs-cap);line-height:1.6;text-align:center; -} - -.ic-actions{display:flex;gap:var(--sp-3);margin-top:var(--sp-5)} -.ic-btn{flex:1} -/* button 元素默认有边框和外边距,压平成和 .btn 一样 */ -button.ic-btn{padding:0;line-height:normal} -button.ic-btn::after{border:none} - -/* 离屏 canvas:挪出可视区,只用来生成图 */ -.ic-canvas{position:fixed;left:-9999rpx;top:0;width:620px;height:1480px} - -/* 手机号门槛卡 */ -.ic-gate{ - background:#fff;border-radius:var(--r-lg);box-shadow:var(--sd-1); - padding:var(--sp-6) var(--sp-5);margin-top:var(--sp-5);text-align:center; -} -.ic-gate-ic{ - width:140rpx;height:140rpx;margin:0 auto var(--sp-4);border-radius:50%; - background:var(--primary-soft);color:var(--primary); - display:flex;align-items:center;justify-content:center; -} -.ic-gate-t{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text)} -.ic-gate-p{font-size:var(--fs-sm);color:var(--muted);line-height:1.6;margin:var(--sp-2) 0 var(--sp-5)} -.ic-gate-refuse{color:var(--red-ink);font-weight:var(--fw-b);font-size:var(--fs-md)} -.ic-gate-btn{margin-top:var(--sp-2)} -.ic-gate-manual{margin-top:var(--sp-4);color:var(--muted);font-size:var(--fs-sm);text-decoration:underline} -.ic-gate-input{margin-bottom:var(--sp-3);text-align:center} diff --git a/pets-fe/pages/learn/learn.js b/pets-fe/pages/learn/learn.js deleted file mode 100644 index c8c500e..0000000 --- a/pets-fe/pages/learn/learn.js +++ /dev/null @@ -1,43 +0,0 @@ -const store = require('../../utils/store.js'); -const api = require('../../utils/api.js'); - -Page({ - data: { - articles: [], - sheetShow: false, - sheetType: '', - }, - onLoad() { - store - .ready() - .then(() => api.listArticles()) - .then((list) => { - this.setData({ - articles: (list || []).map((a) => ({ - id: a.id, - icon: a.icon, - title: a.title, - desc: a.description, - })), - }); - }) - .catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' })); - }, - // 打开文章正文详情页 - openArticle(e) { - const id = e.currentTarget.dataset.id; - if (id) wx.navigateTo({ url: `/pages/article/article?id=${id}` }); - }, - openSheet(e) { - this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true }); - }, - closeSheet() { - this.setData({ sheetShow: false }); - }, - onShareAppMessage() { - return { title: '新手养宠知识合集', path: '/pages/learn/learn' }; - }, - onShareTimeline() { - return { title: '新手养宠知识合集' }; - }, -}); diff --git a/pets-fe/pages/learn/learn.json b/pets-fe/pages/learn/learn.json deleted file mode 100644 index fa34741..0000000 --- a/pets-fe/pages/learn/learn.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar", - "bottom-sheet": "/components/bottom-sheet/bottom-sheet", - "fab": "/components/fab/fab", - "pt-icon": "/components/pt-icon/index" - } -} diff --git a/pets-fe/pages/learn/learn.wxml b/pets-fe/pages/learn/learn.wxml deleted file mode 100644 index 767741a..0000000 --- a/pets-fe/pages/learn/learn.wxml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - 看完顺手生成你的计划。 - - - 还没有文章 - - - {{item.title}}{{item.desc}} - - - - - diff --git a/pets-fe/pages/meetup-edit/meetup-edit.js b/pets-fe/pages/meetup-edit/meetup-edit.js new file mode 100644 index 0000000..ab0a991 --- /dev/null +++ b/pets-fe/pages/meetup-edit/meetup-edit.js @@ -0,0 +1,77 @@ +const { secure, toast } = require("../../utils/api"); +Page({ + data: { + pets: [], + selected: [], + title: "", + description: "", + place_name: "", + latitude: 0, + longitude: 0, + date: "", + time: "", + max_people: 8, + }, + onLoad() { + this.load(); + }, + async load() { + try { + this.setData({ pets: await secure("/pets") }); + } catch (e) { + toast(e); + } + }, + field(e) { + this.setData({ [e.currentTarget.dataset.key]: e.detail.value }); + }, + pick(e) { + this.setData({ selected: e.detail.selected }); + }, + date(e) { + this.setData({ date: e.detail.value }); + }, + time(e) { + this.setData({ time: e.detail.value }); + }, + async location() { + try { + const r = await wx.chooseLocation(); + this.setData({ + place_name: r.name || r.address, + latitude: r.latitude, + longitude: r.longitude, + }); + } catch (e) { + toast(e); + } + }, + async save() { + const d = this.data; + if ( + !d.title.trim() || + !d.place_name || + !d.date || + !d.time || + !d.selected.length + ) { + wx.showToast({ title: "请填完时间地点并选择狗狗", icon: "none" }); + return; + } + try { + await secure("/meetups", "POST", { + title: d.title, + description: d.description, + place_name: d.place_name, + latitude: d.latitude, + longitude: d.longitude, + starts_at: new Date(d.date + "T" + d.time + ":00+08:00").toISOString(), + max_people: Number(d.max_people), + pet_ids: d.selected, + }); + wx.navigateBack(); + } catch (e) { + toast(e); + } + }, +}); diff --git a/pets-fe/pages/meetup-edit/meetup-edit.json b/pets-fe/pages/meetup-edit/meetup-edit.json new file mode 100644 index 0000000..f03ce6b --- /dev/null +++ b/pets-fe/pages/meetup-edit/meetup-edit.json @@ -0,0 +1 @@ +{ "usingComponents": { "pet-picker": "/components/pet-picker/index" } } diff --git a/pets-fe/pages/meetup-edit/meetup-edit.wxml b/pets-fe/pages/meetup-edit/meetup-edit.wxml new file mode 100644 index 0000000..6493ebd --- /dev/null +++ b/pets-fe/pages/meetup-edit/meetup-edit.wxml @@ -0,0 +1,53 @@ + + HOST A WALK + 发起约遛 🌿 + + 活动名称 + + + + 时间 + + + {{date||'选择日期'}} + + + {{time||'选择时间'}} + + + + + 集合地点 + + 📍 {{place_name||'选择地图位置'}} + + + + 人数上限 + + + + 介绍一下 + - - {{item}} - - - {{stageBasis}} - {{sizeLabel}}——阶段阈值按体型算 - 按生日推算是「{{stageAuto}}」,可以手改 - - - - - - - - 要不要套一套养护方案? - 按 {{form.stage}} 配的,套用后会排进本月的计划。之后随时能改、能删。 - - - - {{item.name}} - - - {{item.summary}} - - 每天 {{item.daily}} 项 - 每周 {{item.weekly}} 项 - 每月 {{item.monthly}} 项 - 一次性 {{item.once}} 项 - - - - - - - 先空着,我自己排 - - - 之后在「管理计划」里随时能套用 - - - 这个阶段还没有配方案,建完档可以自己排 - 疫苗和驱虫提醒不管选哪个都会建 —— 漏针是真会出事的 - - - - - - - - 上一步 - - {{saving ? '保存中…' : (editing ? '保存' : (step < 3 ? '下一步' : '完成建档'))}} - - - - - - - - 选择品种 - 不确定 - - - - - {{item.initial}} - {{b.name}} - - 还没有配置品种 - - - {{item.initial}} - - - - diff --git a/pets-fe/pages/petform/petform.wxss b/pets-fe/pages/petform/petform.wxss deleted file mode 100644 index b486f03..0000000 --- a/pets-fe/pages/petform/petform.wxss +++ /dev/null @@ -1,108 +0,0 @@ -.pf-body{padding-bottom:calc(200rpx + env(safe-area-inset-bottom))} - -/* 三步进度条。不用 seg-tabs:那是「可以随意切」的语义, - 这里是有顺序的,点第三步跳过前两步会拿到一份没名字的档案 */ -.pf-steps{ - flex:none;display:flex;align-items:flex-start;justify-content:center; - gap:var(--sp-6);padding:var(--sp-3) var(--pad-x) var(--sp-4); -} -.pf-step{display:flex;flex-direction:column;align-items:center;gap:6rpx} -.pf-dot{ - width:48rpx;height:48rpx;border-radius:50%; - display:flex;align-items:center;justify-content:center; - background:#fff;border:1rpx solid var(--line); - color:var(--muted2);font-size:var(--fs-sm);font-weight:var(--fw-b); -} -.pf-slabel{font-size:var(--fs-cap);color:var(--muted2)} -.pf-step.on .pf-dot{background:var(--primary);border-color:var(--primary);color:#fff} -.pf-step.on .pf-slabel{color:var(--primary-dark);font-weight:var(--fw-b)} - -.pf-h{font-size:var(--fs-lg);font-weight:var(--fw-b);margin-bottom:var(--sp-4)} - -/* 头像 */ -.pf-av-wrap{display:flex;flex-direction:column;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-5)} -.pf-av{ - width:176rpx;height:176rpx;border-radius:50%;overflow:hidden; - background:var(--primary-soft);color:var(--primary-dark); - display:flex;align-items:center;justify-content:center; -} -.pf-av-img{width:100%;height:100%} -.pf-av-tip{font-size:var(--fs-cap);color:var(--primary-dark)} - -/* 猫 / 狗 */ -.pf-species{display:flex;gap:var(--sp-3);margin-bottom:var(--sp-4)} -.pf-sp{ - flex:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-2); - padding:var(--sp-5) 0;border-radius:var(--r-md); - background:var(--surface-2);border:2rpx solid var(--line); - color:var(--muted);font-size:var(--fs-md); -} -.pf-sp.on{background:var(--primary-soft);border-color:var(--primary);color:var(--primary-ink);font-weight:var(--fw-b)} -.pf-empty{color:var(--muted2)} - -/* 底部按钮 */ -.pf-foot{ - position:fixed;left:0;right:0;bottom:0;z-index:50;display:flex;gap:var(--sp-3); - padding:var(--sp-4) var(--pad-x) calc(var(--sp-4) + env(safe-area-inset-bottom)); - background:#fff;box-shadow:0 -12rpx 32rpx rgba(70,48,25,.06); -} -.pf-back{flex:none;width:200rpx} -.pf-next{flex:1} -.pf-busy{opacity:.6} - -/* 品种选择器 */ -.pf-mask{position:fixed;inset:0;z-index:200;background:rgba(45,41,37,.45);display:flex;align-items:flex-end} -.pf-sheet{ - width:100%;background:var(--bg2);border-radius:var(--r-lg) var(--r-lg) 0 0; - padding:var(--sp-5) 0 calc(var(--sp-3) + env(safe-area-inset-bottom)); - box-shadow:var(--sd-3); -} -.pf-sheet-h{ - display:flex;align-items:center;justify-content:space-between; - padding:0 var(--pad-x) var(--sp-4); - font-size:var(--fs-lg);font-weight:var(--fw-b); -} -.pf-breed-wrap{display:flex;height:60vh} -.pf-breed-list{flex:1;min-width:0;padding-left:var(--pad-x)} -/* 首字母吸顶:滚长列表时得知道现在在哪一段 */ -.pf-bi{ - position:sticky;top:0;z-index:1; - background:var(--bg2);color:var(--primary-dark); - font-size:var(--fs-sm);font-weight:var(--fw-b);padding:var(--sp-2) 0; -} -.pf-brow{ - padding:var(--sp-3) 0;font-size:var(--fs-md);color:var(--text); - border-bottom:1rpx solid var(--line); -} -.pf-brow.on{color:var(--primary-dark);font-weight:var(--fw-b)} -/* 右侧 A-Z 索引 */ -.pf-index{ - flex:none;width:56rpx;display:flex;flex-direction:column; - align-items:center;justify-content:center;gap:2rpx; -} -.pf-ichar{ - font-size:var(--fs-cap);color:var(--muted); - padding:2rpx 8rpx;line-height:1.4; -} - -/* 阶段的划分依据。摆出来而不是让用户猜「幼年期」是几个月到几个月 */ -.pf-basis{ - margin-top:var(--sp-2);background:var(--primary-soft);color:var(--primary-ink); - border-radius:var(--r-sm);padding:var(--sp-2) var(--sp-3); - font-size:var(--fs-cap);line-height:1.6; -} - -/* 第四步:方案卡 */ -.pf-sub{font-size:var(--fs-sm);color:var(--muted);line-height:1.6;margin:calc(-1 * var(--sp-2)) 0 var(--sp-4)} -.pf-tpl{ - border:2rpx solid var(--line);border-radius:var(--r-md); - padding:var(--sp-4);margin-bottom:var(--sp-3);background:var(--surface-2); -} -.pf-tpl.on{border-color:var(--primary);background:var(--primary-soft)} -.pf-tpl-top{display:flex;align-items:center;gap:var(--sp-2)} -.pf-tpl-name{flex:1;font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} -.pf-tpl-tick{flex:none;color:var(--primary-dark);display:flex;align-items:center} -.pf-tpl-sum{font-size:var(--fs-sm);color:var(--text-2);margin-top:6rpx;line-height:1.5} -.pf-tpl-meta{display:flex;flex-wrap:wrap;gap:var(--sp-3);margin-top:var(--sp-2); - font-size:var(--fs-cap);color:var(--muted)} -.pf-tpl-note{margin-top:var(--sp-2)} diff --git a/pets-fe/pages/pets/pets.js b/pets-fe/pages/pets/pets.js new file mode 100644 index 0000000..f1738ce --- /dev/null +++ b/pets-fe/pages/pets/pets.js @@ -0,0 +1,22 @@ +const { secure, toast } = require("../../utils/api"); +Page({ + data: { pets: [] }, + onShow() { + this.load(); + }, + async load() { + try { + this.setData({ pets: await secure("/pets") }); + } catch (e) { + toast(e); + } + }, + add() { + wx.navigateTo({ url: "/pages/pet-edit/pet-edit" }); + }, + edit(e) { + wx.navigateTo({ + url: "/pages/pet-edit/pet-edit?id=" + e.currentTarget.dataset.id, + }); + }, +}); diff --git a/pets-fe/pages/pets/pets.json b/pets-fe/pages/pets/pets.json new file mode 100644 index 0000000..995a014 --- /dev/null +++ b/pets-fe/pages/pets/pets.json @@ -0,0 +1 @@ +{ "usingComponents": { "tab-bar": "/components/tab-bar/index" } } diff --git a/pets-fe/pages/pets/pets.wxml b/pets-fe/pages/pets/pets.wxml new file mode 100644 index 0000000..d1d7a22 --- /dev/null +++ b/pets-fe/pages/pets/pets.wxml @@ -0,0 +1,43 @@ + + MY LITTLE FAMILY + 我的毛孩子 🐕 + + + 五只柯基,也能一起遛 + 每一只都有自己的故事 + + + + + 宠物档案 · {{pets.length}} + + 添加 + + + + + 🐕 + + + {{item.name}} + + {{item.breed||'小可爱'}} · + {{item.gender==='male'?'男孩':item.gender==='female'?'女孩':'未知'}} + + 体重 {{item.weight_kg||'—'}} kg + + › + + 给第一只狗狗建个档案吧 🐾 + + + diff --git a/pets-fe/pages/pets/pets.wxss b/pets-fe/pages/pets/pets.wxss new file mode 100644 index 0000000..669a49d --- /dev/null +++ b/pets-fe/pages/pets/pets.wxss @@ -0,0 +1,75 @@ +.family { + background: #194f42; + border-radius: 35rpx; + color: white; + min-height: 220rpx; + padding: 35rpx; + position: relative; + overflow: hidden; +} +.family text, +.family strong { + display: block; + position: relative; + z-index: 2; +} +.family text { + color: #a4d6b8; + font-size: 23rpx; +} +.family strong { + font-size: 32rpx; + width: 55%; + margin-top: 20rpx; +} +.family image { + width: 270rpx; + height: 205rpx; + position: absolute; + right: -5rpx; + bottom: 0; +} +.add { + font-weight: 800; + color: #368764; +} +.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: #8a9c90; +} +.arrow { + font-size: 42rpx; + color: #97a99d; +} +.add-button { + margin-top: 25rpx; +} diff --git a/pets-fe/pages/plan/plan.js b/pets-fe/pages/plan/plan.js deleted file mode 100644 index ba450dd..0000000 --- a/pets-fe/pages/plan/plan.js +++ /dev/null @@ -1,282 +0,0 @@ -const store = require('../../utils/store.js'); -const api = require('../../utils/api.js'); -const { toastErr } = require('../../utils/ui.js'); - -function pad2(n) { - return n < 10 ? '0' + n : '' + n; -} -function ymd(d) { - return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); -} -// "2026-07-04" → "7月4日" -function fmtMD(date) { - const p = (date || '').split('-'); - return p.length < 3 ? '' : +p[1] + '月' + +p[2] + '日'; -} -function parseYMD(s) { - const p = (s || '').split('-'); - return p.length < 3 ? null : new Date(+p[0], +p[1] - 1, +p[2]); -} - -// 自然月网格。周一为一周起点,首尾补空格对齐星期。 -// 按月切而不是「从计划开始日起 30 天」:那样窗口跨两个月, -// 用户看着一个 7月26日—8月25日 的日历,既不像本月也不像下月。 -function buildCells(year, month, byDate, todayStr) { - const first = new Date(year, month - 1, 1); - const lead = (first.getDay() + 6) % 7; - const total = new Date(year, month, 0).getDate(); - const cells = []; - for (let i = 0; i < lead; i++) cells.push({ blank: true }); - for (let d = 1; d <= total; d++) { - const s = year + '-' + pad2(month) + '-' + pad2(d); - cells.push({ day: d, date: s, count: (byDate[s] || []).length, today: s === todayStr }); - } - while (cells.length % 7) cells.push({ blank: true }); - return cells; -} - -// off=0 本月,off=1 下月 -function monthOf(off) { - const n = new Date(); - const d = new Date(n.getFullYear(), n.getMonth() + off, 1); - return { year: d.getFullYear(), month: d.getMonth() + 1 }; -} -function monthEnd(off) { - const m = monthOf(off); - return m.year + '-' + pad2(m.month) + '-' + pad2(new Date(m.year, m.month, 0).getDate()); -} - -Page({ - data: { - pet: {}, - plan: null, - cells: [], - monthOff: 0, - monthLabel: '', - monthEndLabel: '', - nextCount: 0, - monthEmpty: false, - tplOptions: [], - tplShow: false, - pickStart: '', - pickEnd: '', - selDate: '', - selLabel: '', - dayTasks: [], - editShow: false, - editId: '', - form: { title: '', date: '', description: '', daily: false }, - saving: false, - sheetShow: false, - sheetType: '', - }, - onLoad() { - this._unsub = store.subscribe((pet) => { - this.setData({ pet }); - if (this._inited) this.load(); - }); - }, - onUnload() { - if (this._unsub) this._unsub(); - }, - onShow() { - store - .ready() - .then(() => { - this._inited = true; - this.setData({ pet: store.getPet() }); - this.load(); - }) - .catch((e) => toastErr(e)); - }, - load() { - const id = store.currentPetId(); - if (!id) return; - this.loadTemplates(); - api - .getPlan(id) - .then((plan) => this.apply(plan)) - .catch(() => this.setData({ plan: null, cells: [], dayTasks: [] })); - }, - apply(plan) { - const tasks = plan.tasks || []; - const byDate = {}; - tasks.forEach((t) => { - if (!t.date) return; - (byDate[t.date] = byDate[t.date] || []).push(t); - }); - this._byDate = byDate; - this.setData({ - plan: { ...plan, tasks }, - // 能排的区间:不早于计划开始日,不晚于下月月底。和后端 dayOffset 的校验一致 - pickStart: (plan.start_date || '').slice(0, 10), - pickEnd: monthEnd(1), - }); - this.renderMonth(this.data.monthOff); - if (this._pendingSel) { - // 刚加/改完,跳到那一天,不然用户看不到东西去哪了 - this.selectDay(this._pendingSel); - this._pendingSel = ''; - } - }, - renderMonth(off) { - const m = monthOf(off); - const todayStr = ymd(new Date()); - const byDate = this._byDate || {}; - const prefix = m.year + '-' + pad2(m.month); - this.setData({ - monthOff: off, - monthLabel: m.year + ' 年 ' + m.month + ' 月', - monthEndLabel: fmtMD(monthEnd(off)), - cells: buildCells(m.year, m.month, byDate, todayStr), - nextCount: Object.keys(byDate).filter((d) => d.indexOf(monthOf(1).year + '-' + pad2(monthOf(1).month)) === 0) - .reduce((n, d) => n + byDate[d].length, 0), - // 当前看的这个月一条都没有 —— 空月份要给套方案的入口 - monthEmpty: !Object.keys(byDate).some((d) => d.indexOf(prefix) === 0), - }); - // 切到本月默认选今天;切到下月默认选 1 号(今天不在那个月里) - const inMonth = todayStr.indexOf(prefix) === 0; - this.selectDay(inMonth ? todayStr : prefix + '-01', todayStr); - }, - // 方案按宠物当前阶段取。宠物换了(pet-switch)阶段也可能不同,所以跟着 load 走 - loadTemplates() { - const pet = this.data.pet || {}; - if (!pet.stage) return; - api - .careTemplates(pet.type === '狗狗' ? 'dog' : 'cat', pet.stage) - .then((opts) => this.setData({ tplOptions: opts || [] })) - .catch(() => this.setData({ tplOptions: [] })); - }, - openTpl() { - this.setData({ tplShow: true }); - }, - closeTpl() { - this.setData({ tplShow: false }); - }, - applyTpl(e) { - const id = e.currentTarget.dataset.id; - const petID = store.currentPetId(); - if (!petID || !id) return; - wx.showLoading({ title: '排进去…' }); - api - .applyTemplate(petID, id, this.data.monthOff) - .then((res) => { - wx.hideLoading(); - this.setData({ tplShow: false }); - const r = res || {}; - wx.showToast({ - title: r.plans > 0 ? '排了 ' + r.plans + ' 件事' : '这个月已经有这些安排了', - icon: 'none', - }); - this.load(); - }) - .catch((err) => { - wx.hideLoading(); - toastErr(err, '套用失败'); - }); - }, - switchMonth(e) { - this.renderMonth(Number(e.currentTarget.dataset.off)); - }, - selectDay(date, todayStr) { - const t = todayStr || ymd(new Date()); - this.setData({ - selDate: date, - selLabel: date === t ? '今天的安排' : fmtMD(date) + '的安排', - dayTasks: (this._byDate || {})[date] || [], - }); - }, - onTapDay(e) { - const d = e.currentTarget.dataset.date; - if (d) this.selectDay(d); - }, - - toggleTask(e) { - api - .togglePlanTask(e.currentTarget.dataset.id) - .then(() => this.load()) - .catch((err) => toastErr(err, '操作失败')); - }, - - // ── 加 / 改 ── - openAdd() { - this.setData({ - editShow: true, editId: '', - form: { title: '', date: this.data.selDate || ymd(new Date()), description: '', daily: false }, - }); - }, - openEdit(e) { - const t = (this.data.dayTasks || []).find((x) => x.id === e.currentTarget.dataset.id); - if (!t) return; - this.setData({ - editShow: true, editId: t.id, - form: { title: t.title, date: t.date || this.data.selDate, description: t.description || '', daily: false }, - }); - }, - closeEdit() { - this.setData({ editShow: false }); - }, - noop() {}, - onFormTitle(e) { - this.setData({ 'form.title': e.detail.value }); - }, - onFormDate(e) { - this.setData({ 'form.date': e.detail.value }); - }, - onFormDesc(e) { - this.setData({ 'form.description': e.detail.value }); - }, - onFormDaily(e) { - this.setData({ 'form.daily': e.detail.value }); - }, - onSubmit() { - if (this.data.saving) return; - const f = this.data.form; - if (!(f.title || '').trim()) return wx.showToast({ title: '写一下做什么', icon: 'none' }); - const id = store.currentPetId(); - if (!id) return; - this.setData({ saving: true }); - const req = this.data.editId - ? api.updatePlanTask(this.data.editId, f) - : api.addPlanTask(id, f); - req - .then(() => { - this.setData({ saving: false, editShow: false }); - // 加完/改完要跳到那一天,不然用户改了日期却看不到东西去哪了 - this._pendingSel = f.date; - this.load(); - }) - .catch((e) => { - this.setData({ saving: false }); - toastErr(e, '保存失败'); - }); - }, - onDelete(e) { - const { id, title } = e.currentTarget.dataset; - wx.showModal({ - title: '删掉这件事?', - content: title || '', - confirmColor: '#EE7D73', - success: (r) => { - if (!r.confirm) return; - api - .deletePlanTask(id) - .then(() => this.load()) - .catch((err) => toastErr(err, '删除失败')); - }, - }); - }, - - closeSheet() { - this.setData({ sheetShow: false }); - }, - onAddPet() { - wx.navigateTo({ url: '/pages/petform/petform' }); - }, - onShareAppMessage() { - return { title: '我给毛孩子做了份养护计划', path: '/pages/plan/plan' }; - }, - onShareTimeline() { - return { title: '我给毛孩子做了份养护计划' }; - }, -}); diff --git a/pets-fe/pages/plan/plan.json b/pets-fe/pages/plan/plan.json deleted file mode 100644 index 58b7029..0000000 --- a/pets-fe/pages/plan/plan.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar", - "pet-switch": "/components/pet-switch/pet-switch", - "bottom-sheet": "/components/bottom-sheet/bottom-sheet", - "fab": "/components/fab/fab", - "pt-icon": "/components/pt-icon/index", - "seg-tabs": "/components/seg-tabs/index" - } -} diff --git a/pets-fe/pages/plan/plan.wxml b/pets-fe/pages/plan/plan.wxml deleted file mode 100644 index 530b6c8..0000000 --- a/pets-fe/pages/plan/plan.wxml +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - 还没有毛孩子的档案 - 建好档会按它的年龄阶段自动排好 30 天计划和疫苗驱虫提醒。 - 建一份档案 - - - - - - - {{monthLabel}} - - 本月 - 下月 - - - - - - {{monthLabel}}还没有安排 - 套一套方案 - - - 一二三四五六日 - {{item.day || ''}} - - - - - - - {{selLabel}} - - 套方案 - + 加一件 - - - - - - - - {{item.title}} - {{item.description}} - - - - - - 这天还没有安排,点右上角加一件 - - - - - - - - - - {{editId ? '改这件事' : '加一件事'}} - - - - - {{form.date}} - - 只能安排 {{pickStart}} 到 {{pickEnd}} 之间 - - - - - - - 应用到每天 - 从这天排到 {{monthEndLabel}},每天一条,可以分别打勾 - - - - {{saving ? '保存中…' : '保存'}} - - - - - - - 套一套方案到{{monthLabel}} - 按 {{pet.stage}} 配的。套用是「往里加」,不会覆盖你已经排好的。 - - {{item.name}} - {{item.summary}} - - 每天 {{item.daily}} 项 - 每周 {{item.weekly}} 项 - 每月 {{item.monthly}} 项 - 一次性 {{item.once}} 项 - - - 这个阶段还没有配方案 - - - - diff --git a/pets-fe/pages/plan/plan.wxss b/pets-fe/pages/plan/plan.wxss deleted file mode 100644 index d320448..0000000 --- a/pets-fe/pages/plan/plan.wxss +++ /dev/null @@ -1,70 +0,0 @@ -/* 竖线时间轴 */ -.timeline{padding-left:var(--sp-6);position:relative} -.timeline::before{content:"";position:absolute;left:12rpx;top:12rpx;bottom:16rpx;width:4rpx;background:#E8DED0} -.time-block{position:relative;margin-bottom:var(--sp-4)} -.time-label{font-weight:var(--fw-b);color:var(--primary-dark);font-size:var(--fs-sm);margin-bottom:var(--sp-3);position:relative} -.time-date{margin-left:var(--sp-2);font-weight:var(--fw);color:var(--muted);font-size:var(--fs-cap)} -.time-label::before{ - content:"";position:absolute;left:-34rpx;top:8rpx;width:24rpx;height:24rpx;border-radius:50%; - background:var(--primary);border:6rpx solid #fff;box-shadow:0 0 0 4rpx var(--primary-line); -} -.plan-task{ - background:#fff;border-radius:var(--r-md);padding:var(--sp-4);box-shadow:var(--sd-1); - border:1rpx solid transparent;display:flex;gap:var(--sp-3);align-items:flex-start; -} -.plan-task.done{opacity:.55} -.pk-check{ - width:44rpx;height:44rpx;border-radius:50%;border:4rpx solid #DED1C4;flex:none;margin-top:2rpx; - display:flex;align-items:center;justify-content:center;color:#fff; -} -.plan-task.done .pk-check{background:var(--green);border-color:var(--green)} -.pk-body{flex:1} -.pk-b{font-size:var(--fs-md);font-weight:var(--fw-b)} -.pk-p{color:var(--muted);font-size:var(--fs-sm);line-height:1.45;margin-top:var(--sp-1)} -.ai-plan-tip{color:var(--muted);font-size:var(--fs-sm);line-height:1.6;margin-bottom:var(--sp-3)} - -/* 日历 */ -/* AI 提取信息 */ -.extracted{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-3);margin:var(--sp-4) 0} -.extract{background:var(--surface-2);border-radius:var(--r-sm);padding:var(--sp-3);font-size:var(--fs-sm)} -.ex-b{display:block;font-size:var(--fs-sm);margin-bottom:6rpx;font-weight:var(--fw-b);color:var(--muted)} - -/* 计划节点右侧的删除键。计划是用户自己排的,就得能删 */ -.pk-del{flex:none;padding:0 var(--sp-1);color:var(--muted2);display:flex;align-items:center} - -/* 加/改计划的浮层。字段只有三个,再开一页太重 */ -.pe-mask{ - position:fixed;inset:0;z-index:200;background:rgba(45,41,37,.45); - display:flex;align-items:flex-end; -} -.pe-sheet{ - width:100%;background:var(--bg2);border-radius:var(--r-lg) var(--r-lg) 0 0; - padding:var(--sp-5) var(--pad-x) calc(var(--sp-5) + env(safe-area-inset-bottom)); - box-shadow:var(--sd-3);max-height:82vh;overflow-y:auto; -} -.pe-h{font-size:var(--fs-lg);font-weight:var(--fw-b);margin-bottom:var(--sp-4);text-align:center} - -/* 下月还空着时的提示。放在月历里而不是弹 toast——用户切到下月本来就是来排的 */ -.plan-tip{ - display:flex;align-items:center;gap:var(--sp-2); - background:var(--primary-soft);color:var(--primary-ink); - border-radius:var(--r-sm);padding:var(--sp-3); - font-size:var(--fs-sm);margin-bottom:var(--sp-3); -} - -.pe-daily{display:flex;align-items:center;gap:var(--sp-4)} -.pe-daily-main{flex:1;min-width:0} - -/* 空月份的提示条:一行放不下「文案 + 按钮」,改成 flex */ -.plan-tip-b{flex:1;min-width:0} - -/* 套方案浮层 */ -.pt-sub{margin:calc(-1 * var(--sp-2)) 0 var(--sp-4);text-align:center} -.pt-item{ - border:2rpx solid var(--line);border-radius:var(--r-md); - padding:var(--sp-4);margin-bottom:var(--sp-3);background:#fff; -} -.pt-name{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} -.pt-sum{font-size:var(--fs-sm);color:var(--text-2);margin-top:6rpx;line-height:1.5} -.pt-meta{display:flex;flex-wrap:wrap;gap:var(--sp-3);margin-top:var(--sp-2); - font-size:var(--fs-cap);color:var(--muted)} diff --git a/pets-fe/pages/record/record.js b/pets-fe/pages/record/record.js deleted file mode 100644 index 191692e..0000000 --- a/pets-fe/pages/record/record.js +++ /dev/null @@ -1,53 +0,0 @@ -const store = require('../../utils/store.js'); -const recordTypes = require('../../utils/recordTypes.js'); - -// 这一页只干一件事:把后端配置的记录事项按分组列出来,点一个进添加页。 -// 原来它还带着健康洞察、体重趋势、健康时间轴——那三块搬到 pages/history 了, -// 从报告页进。一个「我要记一笔」的页面上摆着三块只读图表, -// 用户每次都得先滚过去才能找到要点的东西。 -// tone-N 只给格子用(它自带背景色);组标题的颜色走 g-N。 -// short 是去掉「记录」后缀的组名——「日常记录」在标题位置念着啰嗦 -const G = { 'tone-1': 'g-1', 'tone-2': 'g-2', 'tone-3': 'g-3', 'tone-4': 'g-4' }; -function decorate(g) { - return { ...g, g: G[g.tone] || 'g-1', short: (g.label || '').replace(/记录$/, '') }; -} - -Page({ - data: { pet: {}, typeGroups: [], sheetShow: false, sheetType: '' }, - onLoad() { - this._unsub = store.subscribe((pet) => this.setData({ pet })); - }, - onUnload() { - if (this._unsub) this._unsub(); - }, - onShow() { - this.loadTypes(); - store - .ready() - .then(() => this.setData({ pet: store.getPet() })) - .catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' })); - }, - loadTypes() { - if (this.data.typeGroups.length) return; - recordTypes - .load() - .then((groups) => this.setData({ typeGroups: groups.map(decorate) })) - .catch(() => {}); - }, - goAdd(e) { - wx.navigateTo({ url: '/pages/addrecord/addrecord?type=' + e.currentTarget.dataset.type }); - }, - // 没建档时要能建档,所以这页留一个只用于 addPet 的弹层 - onAddPet() { - wx.navigateTo({ url: '/pages/petform/petform' }); - }, - closeSheet() { - this.setData({ sheetShow: false }); - }, - onShareAppMessage() { - return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' }; - }, - onShareTimeline() { - return { title: '用肉垫计划记录毛孩子的成长' }; - }, -}); diff --git a/pets-fe/pages/record/record.json b/pets-fe/pages/record/record.json deleted file mode 100644 index d447f47..0000000 --- a/pets-fe/pages/record/record.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar", - "bottom-sheet": "/components/bottom-sheet/bottom-sheet", - "pt-icon": "/components/pt-icon/index" - } -} diff --git a/pets-fe/pages/record/record.wxml b/pets-fe/pages/record/record.wxml deleted file mode 100644 index 3510b50..0000000 --- a/pets-fe/pages/record/record.wxml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - 还没有毛孩子的档案 - 建好档才能记体重、便便、饮食这些,趋势图也是从这些记录里长出来的。 - 建一份档案 - - - - - - {{item.short}} - - - - {{t.label}} - - - - - 还没有配置可记事项 - - - - - diff --git a/pets-fe/pages/record/record.wxss b/pets-fe/pages/record/record.wxss deleted file mode 100644 index 518c99c..0000000 --- a/pets-fe/pages/record/record.wxss +++ /dev/null @@ -1,38 +0,0 @@ -/* 记录页只有这一页在用这套分组宫格,样式就放这里(不进 app.wxss)。 - 原来是整格铺 tone 浅色底 + 直接浮在页面渐变上,四个一行是一片肥色块。 - 改成:每组一张白卡收纳,颜色只集中在图标那个小圆角块上,标签用纯深色。 */ - -/* .card 自带 --sp-5 padding 和 margin-bottom,这里只把上下内边距收紧一点, - 底部留白靠 card 的 margin-bottom,不再叠 margin-top */ -.rec-group{padding:var(--sp-4) var(--sp-4) var(--sp-3)} - -/* 组标题:一根同色系竖条 + 组名。颜色由外层 .g-N 决定 */ -.rec-ghead{ - display:flex;align-items:center;gap:var(--sp-2); - font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text); - margin-bottom:var(--sp-3); -} -.rec-bar{width:6rpx;height:28rpx;border-radius:3rpx;background:currentColor} -.g-1 .rec-bar{color:var(--primary)} -.g-2 .rec-bar{color:var(--green)} -.g-3 .rec-bar{color:var(--purple)} -.g-4 .rec-bar{color:var(--blue)} - -.rec-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--sp-4) var(--sp-2)} - -.rec-item{ - display:flex;flex-direction:column;align-items:center;gap:var(--sp-2); - padding-bottom:var(--sp-2);border-radius:var(--r-md); - transition:transform .06s; -} -.rec-item-hover{transform:scale(.94)} - -/* 图标的小圆角色块。tone-N(app.wxss)给它浅色底 + 深色 ink, - pt-icon 走 currentColor 正好染成 ink 色 */ -.rec-ic{ - width:96rpx;height:96rpx;border-radius:26rpx; - display:flex;align-items:center;justify-content:center; -} - -/* 标签用纯深色,不跟着 tone 变 —— 四组底下的字统一是深灰,才不花 */ -.rec-label{font-size:var(--fs-sm);color:var(--text-2);line-height:1.2} diff --git a/pets-fe/pages/relations/relations.js b/pets-fe/pages/relations/relations.js deleted file mode 100644 index 6cb5c7f..0000000 --- a/pets-fe/pages/relations/relations.js +++ /dev/null @@ -1,43 +0,0 @@ -const api = require('../../utils/api.js'); -const { toastErr } = require('../../utils/ui.js'); - -Page({ - data: { uid: '', kind: 'following', title: '关注', list: [], page: 1, hasMore: false, loaded: false }, - onLoad(q) { - const kind = q && q.kind === 'followers' ? 'followers' : 'following'; - this.setData({ uid: (q && q.id) || '', kind, title: kind === 'followers' ? '粉丝' : '关注' }); - this.load(1); - }, - load(page) { - api - .relations(this.data.uid, this.data.kind, page) - .then((res) => { - // WXML 表达式不支持字符串下标,首字母得在这里算好 - const list = (res.list || []).map((u) => ({ ...u, initial: (u.nickname || '?').slice(0, 1) })); - const merged = page === 1 ? list : this.data.list.concat(list); - this.setData({ list: merged, page, loaded: true, hasMore: merged.length < (res.total || 0) }); - }) - .catch((e) => { - this.setData({ loaded: true }); - toastErr(e); - }); - }, - loadMore() { - if (this.data.hasMore) this.load(this.data.page + 1); - }, - goUser(e) { - wx.navigateTo({ url: `/pages/user/user?id=${e.currentTarget.dataset.id}` }); - }, - // 同样先本地翻转再发请求,失败回滚 - toggleFollow(e) { - const i = e.currentTarget.dataset.index; - const u = this.data.list[i]; - if (!u || u.is_self) return; - const next = !u.followed; - this.setData({ [`list[${i}].followed`]: next }); - (next ? api.followUser(u.id) : api.unfollowUser(u.id)).catch((err) => { - this.setData({ [`list[${i}].followed`]: !next }); - toastErr(err, '操作失败'); - }); - }, -}); diff --git a/pets-fe/pages/relations/relations.json b/pets-fe/pages/relations/relations.json deleted file mode 100644 index 6594cd8..0000000 --- a/pets-fe/pages/relations/relations.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar", - "pt-icon": "/components/pt-icon/index" - } -} diff --git a/pets-fe/pages/relations/relations.wxml b/pets-fe/pages/relations/relations.wxml deleted file mode 100644 index 09f45b3..0000000 --- a/pets-fe/pages/relations/relations.wxml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - {{item.initial}} - - {{item.nickname}}AI - - - - - {{kind === 'followers' ? '还没有人关注 TA' : '还没有关注任何人,去宠友圈逛逛'}} - - - diff --git a/pets-fe/pages/relations/relations.wxss b/pets-fe/pages/relations/relations.wxss deleted file mode 100644 index 1ac6fad..0000000 --- a/pets-fe/pages/relations/relations.wxss +++ /dev/null @@ -1,12 +0,0 @@ -.rel{ - display:flex;align-items:center;gap:var(--sp-3); - background:#fff;border-radius:var(--r-md);box-shadow:var(--sd-1); - padding:var(--sp-3) var(--sp-4);margin-bottom:var(--sp-2); -} -.rel-av{ - width:80rpx;height:80rpx;border-radius:50%;flex:none;overflow:hidden; - display:flex;align-items:center;justify-content:center; - background:var(--primary-soft);color:var(--primary-ink);font-size:var(--fs-lg);font-weight:var(--fw-b); -} -.rel-av-img{width:100%;height:100%} -.rel-name{flex:1;min-width:0;font-size:var(--fs-md);font-weight:var(--fw-b)} diff --git a/pets-fe/pages/report/report.js b/pets-fe/pages/report/report.js deleted file mode 100644 index 9af86a0..0000000 --- a/pets-fe/pages/report/report.js +++ /dev/null @@ -1,168 +0,0 @@ -const store = require('../../utils/store.js'); -const api = require('../../utils/api.js'); -const { syncTabBar } = require('../../utils/tabbar.js'); - -function fmtDay(iso) { - if (!iso) return ''; - const d = new Date(iso); - return d.getMonth() + 1 + '月' + d.getDate() + '日'; -} - -// 体重点 → 趋势图。返回 { svg(折线 data-uri), dots(可点圆点,坐标为百分比) }。 -// 从历史页搬过来的:报告页才是「看数据」的地方,图不该埋在二级页 -function buildChart(points) { - const vals = points.map((p) => Number(p.value) || 0); - const W = 300, H = 100, pad = 12, n = vals.length; - let min = Math.min.apply(null, vals); - let max = Math.max.apply(null, vals); - if (max === min) max = min + 1; - const xs = (i) => (n === 1 ? W / 2 : pad + ((W - 2 * pad) * i) / (n - 1)); - const ys = (v) => H - pad - ((H - 2 * pad) * (v - min)) / (max - min); - const pts = vals.map((v, i) => xs(i).toFixed(1) + ',' + ys(v).toFixed(1)).join(' '); - const svg = - '' + - '' + - ''; - const dots = points.map((p, i) => ({ - xp: Number(((xs(i) / W) * 100).toFixed(2)), - yp: Number(((ys(vals[i]) / H) * 100).toFixed(2)), - value: vals[i], - date: fmtDay(p.occurred_at), - note: p.note || '', - delta: i > 0 ? Number((vals[i] - vals[i - 1]).toFixed(2)) : null, - })); - return { svg: 'data:image/svg+xml,' + encodeURIComponent(svg), dots }; -} - -// 洞察等级 → 图标。alert/warn 用警告号,info 用趋势 -function mapInsight(i) { - return { ...i, icon: i.level === 'info' ? 'trend' : 'warn' }; -} - -Page({ - data: { - pet: {}, - report: null, - gainText: '+0.0', - insights: [], - trendSvg: '', - trendStats: null, - trendDots: [], - selDot: null, - selIdx: -1, - summary: null, - bill: null, - billTop: null, - sheetShow: false, - sheetType: '', - }, - onLoad() { - this._unsub = store.subscribe((pet) => { - this.setData({ pet }); - if (!this._inited) return; - this.loadData(); - }); - }, - onUnload() { - if (this._unsub) this._unsub(); - }, - onShow() { - syncTabBar(this); - store - .ready() - .then(() => { - this._inited = true; - this.setData({ pet: store.getPet() }); - this.loadData(); - }) - .catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' })); - }, - loadData() { - const id = store.currentPetId(); - if (!id) return; - api - .weeklyReport(id) - .then((report) => { - const g = (report && report.weight_gain) || 0; - this.setData({ report, gainText: (g >= 0 ? '+' : '') + g.toFixed(1) }); - }) - .catch(() => {}); - api - .petInsights(id) - .then((list) => this.setData({ insights: (list || []).map(mapInsight) })) - .catch(() => this.setData({ insights: [] })); - api - .healthSummary(id) - .then((summary) => this.setData({ summary })) - .catch(() => {}); - // 账单只在报告页取一个概要,详细录入和流水在花销页 - api - .getBill(id, 'month') - .then((bill) => { - const cats = (bill && bill.categories) || []; - this.setData({ bill, billTop: cats.length ? cats[0] : null }); - }) - .catch(() => {}); - this.loadTrend(id); - }, - loadTrend(id) { - api - .weightTrend(id) - .then((points) => { - points = points || []; - if (points.length < 2) { - this.setData({ trendSvg: '', trendStats: null, trendDots: [], selDot: null, selIdx: -1 }); - return; - } - const vals = points.map((p) => Number(p.value) || 0); - const latest = vals[vals.length - 1]; - const delta = Number((latest - vals[0]).toFixed(2)); - const chart = buildChart(points); - const last = chart.dots.length - 1; - this.setData({ - trendSvg: chart.svg, - trendDots: chart.dots, - selDot: chart.dots[last], // 默认选中最新一次 - selIdx: last, - trendStats: { latest, delta, count: points.length }, - }); - }) - .catch(() => {}); - }, - onTapPoint(e) { - const i = e.currentTarget.dataset.index; - this.setData({ selDot: this.data.trendDots[i], selIdx: i }); - }, - // 点一条洞察,跳到对应类型的记录入口(洞察带 action = 记录类型 code) - onTapInsight(e) { - const it = this.data.insights[e.currentTarget.dataset.index]; - if (it && it.action) wx.navigateTo({ url: '/pages/addrecord/addrecord?type=' + it.action }); - }, - // 体重趋势的「记一笔」直接去体重记录页(原来开的 weight 弹层已经搬成独立页) - goWeight() { - wx.navigateTo({ url: '/pages/addrecord/addrecord?type=weight' }); - }, - goExpense() { - wx.navigateTo({ url: '/pages/expense/expense' }); - }, - goHistory() { - wx.navigateTo({ url: '/pages/history/history' }); - }, - openSheet(e) { - this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true }); - }, - closeSheet() { - this.setData({ sheetShow: false }); - }, - onAddPet() { - wx.navigateTo({ url: '/pages/petform/petform' }); - }, - onShareAppMessage() { - const n = (this.data.pet && this.data.pet.name) || '我家毛孩子'; - return { title: `${n} 的成长报告`, path: '/pages/report/report' }; - }, - onShareTimeline() { - const n = (this.data.pet && this.data.pet.name) || '我家毛孩子'; - return { title: `${n} 的成长报告` }; - }, -}); diff --git a/pets-fe/pages/report/report.json b/pets-fe/pages/report/report.json deleted file mode 100644 index 381942f..0000000 --- a/pets-fe/pages/report/report.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar", - "pet-switch": "/components/pet-switch/pet-switch", - "bottom-sheet": "/components/bottom-sheet/bottom-sheet", - "pt-icon": "/components/pt-icon/index" - } -} diff --git a/pets-fe/pages/report/report.wxml b/pets-fe/pages/report/report.wxml deleted file mode 100644 index 2e5239c..0000000 --- a/pets-fe/pages/report/report.wxml +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - - 还没有毛孩子的档案 - 成长报告和账单都基于记录生成,先建个档开始记吧。 - 建一份档案 - - - - - - 本周概览{{report.health_status || '稳定成长'}} - {{report.summary || '这周还没有足够的记录,多记几笔就有小结了'}} - - {{report.tasks_completed || 0}}完成任务 - {{gainText}}kg 变化 - {{report.high_risk_count || 0}}高风险异常 - - - - - - - 健康洞察来自你的记录 - - - - {{item.title}} - - - {{item.detail}} - {{item.evidence}} - - - - - - 体重趋势记一笔 - 最新 {{trendStats.latest}}kg · 较首次 {{trendStats.delta >= 0 ? '+' : ''}}{{trendStats.delta}}kg · 共 {{trendStats.count}} 次 - - - - - - - - - - {{selDot.date}} - {{selDot.value}}kg - {{selDot.delta > 0 ? '+' : ''}}{{selDot.delta}}kg - - {{selDot.note}} - 这次没有填备注 - - 点击圆点查看每次记录 - - 先记录几次体重,这里会自动画出趋势曲线 - - - - - 健康摘要就医摘要 - 疫苗进度{{summary.vaccine_progress || '—'}} - 驱虫状态{{summary.deworm_status || '—'}} - 体重趋势{{summary.weight_trend || '—'}} - 异常记录{{summary.anomaly_count || 0}} 次 - - - - - 养宠花销去记账 › - - ¥{{bill.total || 0}} - 本月{{billTop ? ',' + billTop.category + '花得最多' : ''}} - - - - {{item.category}} - - ¥{{item.amount}} - - - 这个月还没有记过花销 - - - - - - - 全部记录 - 按类型筛选、翻看历史 - - - - - - - - diff --git a/pets-fe/pages/report/report.wxss b/pets-fe/pages/report/report.wxss deleted file mode 100644 index 51ed02c..0000000 --- a/pets-fe/pages/report/report.wxss +++ /dev/null @@ -1,60 +0,0 @@ -.report-card{background:linear-gradient(135deg,#FFFFFF,#F2FAF6)} -.rc-text{color:var(--text-2);font-size:var(--fs-md);line-height:1.6} -.rc-share{margin-top:var(--sp-4)} - -/* ===== 健康洞察(从历史页搬来,报告页的主角)===== */ -.ins{border-radius:var(--r-md);padding:var(--sp-4);margin-bottom:var(--sp-2)} -.ins:last-child{margin-bottom:0} -.ins-info{background:var(--surface-2)} -.ins-warn{background:var(--primary-soft)} -.ins-alert{background:var(--red-soft)} -.ins-head{display:flex;align-items:flex-start;gap:var(--sp-2)} -.ins-ic{flex:none;margin-top:2rpx} -.ins-info .ins-ic{color:var(--green-ink)} -.ins-warn .ins-ic{color:var(--primary-ink)} -.ins-alert .ins-ic{color:var(--red-ink)} -.ins-title{flex:1;font-size:var(--fs-md);font-weight:var(--fw-b);line-height:1.4} -.ins-go{flex:none;color:var(--muted2);margin-top:2rpx} -.ins-detail{color:var(--text-2);font-size:var(--fs-sm);line-height:1.6;margin-top:var(--sp-2)} -.ins-ev{color:var(--muted);font-size:var(--fs-cap);margin-top:var(--sp-2)} - -/* ===== 体重趋势曲线(从历史页搬来)===== */ -.trend-stats{margin-top:var(--sp-3);font-size:var(--fs-sm);color:var(--muted)} -.mini-chart{ - height:256rpx;border-radius:var(--r-md);margin-top:var(--sp-4);position:relative;overflow:hidden; - background: - linear-gradient(180deg,rgba(115,190,157,.16),rgba(115,190,157,0)), - repeating-linear-gradient(0deg,#fff,#fff 60rpx,#EFE8DE 62rpx); -} -.chart-line{position:absolute;left:36rpx;right:36rpx;top:44rpx;bottom:44rpx;width:auto;height:auto} -.dot-layer{position:absolute;left:36rpx;right:36rpx;top:44rpx;bottom:44rpx} -.dot{position:absolute;width:40rpx;height:40rpx;margin:-20rpx 0 0 -20rpx;border-radius:50%} -.dot::after{content:'';position:absolute;left:50%;top:50%;width:18rpx;height:18rpx;margin:-9rpx 0 0 -9rpx;border-radius:50%;background:#fff;border:4rpx solid var(--green);box-sizing:border-box} -.dot.on::after{width:26rpx;height:26rpx;margin:-13rpx 0 0 -13rpx;background:var(--green);box-shadow:0 0 0 6rpx rgba(115,190,157,.25)} -.point-detail{ - margin-top:var(--sp-3);padding:var(--sp-4);border-radius:var(--r-md); - background:var(--green-soft);border:1rpx solid #DCEFE4; -} -.pd-main{display:flex;align-items:baseline;gap:var(--sp-3)} -.pd-date{font-size:var(--fs-sm);color:var(--muted)} -.pd-weight{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--green-ink)} -.pd-delta{font-size:var(--fs-sm);font-weight:var(--fw-b)} -.pd-delta.up{color:var(--red-ink)} -.pd-delta.down{color:var(--green-ink)} -.pd-note{margin-top:var(--sp-2);font-size:var(--fs-md);color:var(--text-2);line-height:1.5} -.pd-note.muted{color:var(--muted)} -.chart-hint{margin-top:var(--sp-2);font-size:var(--fs-cap);color:var(--muted);text-align:center} - -/* ===== 养宠花销概要(详细在花销页)===== */ -.bill-card:active{opacity:.85} -.bill-sum{margin:var(--sp-2) 0 var(--sp-4)} -.bill-total{font-size:56rpx;font-weight:var(--fw-b);color:var(--text);line-height:1.1} -.bill-cur{font-size:var(--fs-lg);color:var(--muted);margin-right:4rpx} -.bill-sub{font-size:var(--fs-sm);color:var(--muted);margin-top:4rpx} -.bill-bars{display:flex;flex-direction:column;gap:var(--sp-3)} -.bill-row{display:grid;grid-template-columns:88rpx 1fr 108rpx;gap:var(--sp-3);align-items:center;font-size:var(--fs-sm)} -.bill-cat{color:var(--text)} -.bill-amt{font-weight:var(--fw-b);text-align:right} -/* 账单条比全局 .bar 细一档,蓝色是「花费」的语义色 */ -.mini-bar{height:var(--sp-2);border-radius:var(--r-full);background:#F0E7DD;overflow:hidden} -.mini-bar-fill{display:block;height:100%;border-radius:var(--r-full);background:var(--blue)} diff --git a/pets-fe/pages/settings/settings.js b/pets-fe/pages/settings/settings.js deleted file mode 100644 index 0d8ae85..0000000 --- a/pets-fe/pages/settings/settings.js +++ /dev/null @@ -1,80 +0,0 @@ -const store = require('../../utils/store.js'); -const api = require('../../utils/api.js'); -const upload = require('../../utils/upload.js'); - -function completion(pet) { - if (!pet) return 0; - const fields = ['name', 'emoji', 'type', 'gender', 'birthday', 'weight', 'stage', 'age', 'color', 'breed']; - const filled = fields.filter((f) => pet[f]).length; - return Math.round((filled / fields.length) * 100); -} - -Page({ - data: { - pet: {}, - user: {}, - summary: { pets: 0, records: 0, reminders: 0, pro_status: 'none' }, - profilePct: 0, - sheetShow: false, - sheetType: '', - }, - onLoad() { - this._unsub = store.subscribe((pet) => this.setData({ pet, profilePct: completion(pet) })); - }, - onUnload() { - if (this._unsub) this._unsub(); - }, - onShow() { - store - .ready() - .then(() => { - const pet = store.getPet(); - this.setData({ pet, user: store.getUser() || {}, profilePct: completion(pet) }); - this.loadSummary(); - }) - .catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' })); - }, - loadSummary() { - api.userSummary().then((summary) => this.setData({ summary })).catch(() => {}); - }, - openSheet(e) { - this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true }); - }, - // 自己的关注/粉丝。之前只有帖子上的「+关注」按钮,关注完没有任何地方能看 - goRelations(e) { - const uid = (this.data.user && this.data.user.id) || ''; - if (!uid) return wx.showToast({ title: '登录信息还没就绪', icon: 'none' }); - wx.navigateTo({ url: `/pages/relations/relations?id=${uid}&kind=${e.currentTarget.dataset.kind}` }); - }, - goRecord() { - wx.navigateTo({ url: '/pages/record/record' }); - }, - onPickAvatar() { - upload - .chooseAndUploadImage() - .then((f) => api.updateProfile({ avatar_file_id: f.id })) - .then((user) => { - this.setData({ user }); - store.setUser(user); - wx.showToast({ title: '头像已更新', icon: 'success' }); - }) - .catch((e) => { - if (e && e.canceled) return; - wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' }); - }); - }, - onAddPet() { - wx.navigateTo({ url: '/pages/petform/petform' }); - }, - onEditPet() { - const id = store.currentPetId(); - wx.navigateTo({ url: '/pages/petform/petform' + (id ? '?id=' + id : '') }); - }, - closeSheet() { - this.setData({ sheetShow: false }); - this.loadSummary(); - }, - onShareAppMessage() { - return { title: '肉垫计划 · 新手养宠助手', path: '/pages/home/home' }; - }, -}); diff --git a/pets-fe/pages/settings/settings.json b/pets-fe/pages/settings/settings.json deleted file mode 100644 index fa34741..0000000 --- a/pets-fe/pages/settings/settings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar", - "bottom-sheet": "/components/bottom-sheet/bottom-sheet", - "fab": "/components/fab/fab", - "pt-icon": "/components/pt-icon/index" - } -} diff --git a/pets-fe/pages/settings/settings.wxml b/pets-fe/pages/settings/settings.wxml deleted file mode 100644 index 3c02ed8..0000000 --- a/pets-fe/pages/settings/settings.wxml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - 提醒设置{{summary.reminders}} 条 - - - - 会员权益{{summary.pro_status === 'active' ? '已开通' : '未开通'}} - 数据导出文本 - 意见反馈去填写 - 关于我们v0.2 - - - - - - diff --git a/pets-fe/pages/settings/settings.wxss b/pets-fe/pages/settings/settings.wxss deleted file mode 100644 index 80c2b2e..0000000 --- a/pets-fe/pages/settings/settings.wxss +++ /dev/null @@ -1,11 +0,0 @@ -.profile-header{text-align:center;padding:var(--sp-4) 0 var(--sp-5)} -.ph-avatar{margin:0 auto var(--sp-3);width:152rpx;height:152rpx;font-size:76rpx;border-radius:var(--r-lg);position:relative;overflow:visible} -.ph-avatar-img{width:100%;height:100%;border-radius:var(--r-lg)} -.ph-avatar-cam{ - position:absolute;right:-6rpx;bottom:-6rpx;width:52rpx;height:52rpx; - display:flex;align-items:center;justify-content:center;color:var(--text-2); - background:#fff;border-radius:50%;box-shadow:var(--sd-1); -} -.ph-name{font-size:var(--fs-2xl);font-weight:var(--fw-b)} -.ph-meta{font-size:var(--fs-sm);margin-top:var(--sp-1)} - diff --git a/pets-fe/pages/share/share.js b/pets-fe/pages/share/share.js new file mode 100644 index 0000000..709f326 --- /dev/null +++ b/pets-fe/pages/share/share.js @@ -0,0 +1,39 @@ +const { request } = require("../../utils/api"); +Page({ + data: { + latitude: 31.23, + longitude: 121.47, + markers: [], + status: "加载中", + updated: "", + }, + onLoad(options) { + this.token = options.token; + this.load(); + this.timer = setInterval(() => this.load(), 15000); + }, + onUnload() { + clearInterval(this.timer); + }, + async load() { + try { + const p = await request("/share/walk/" + this.token); + this.setData({ + latitude: p.latitude, + longitude: p.longitude, + markers: [ + { + id: 1, + latitude: p.latitude, + longitude: p.longitude, + title: "目前在这里", + }, + ], + status: p.status === "paused" ? "暂时停下休息" : "正在遛狗", + updated: new Date(p.recorded_at).toLocaleTimeString(), + }); + } catch (e) { + this.setData({ status: "分享已结束或尚无位置" }); + } + }, +}); diff --git a/pets-fe/pages/share/share.json b/pets-fe/pages/share/share.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/pets-fe/pages/share/share.json @@ -0,0 +1 @@ +{} diff --git a/pets-fe/pages/share/share.wxml b/pets-fe/pages/share/share.wxml new file mode 100644 index 0000000..05d60b5 --- /dev/null +++ b/pets-fe/pages/share/share.wxml @@ -0,0 +1,15 @@ + + 肉垫计划 · 位置分享 + 找到小短腿的脚印 🐾 + + + {{status}} + 上次更新 {{updated}} + + diff --git a/pets-fe/pages/share/share.wxss b/pets-fe/pages/share/share.wxss new file mode 100644 index 0000000..4b9ec56 --- /dev/null +++ b/pets-fe/pages/share/share.wxss @@ -0,0 +1,6 @@ +page { + background: #f6f9f6; +} +.card { + margin-top: 20rpx; +} diff --git a/pets-fe/pages/tasks/tasks.js b/pets-fe/pages/tasks/tasks.js new file mode 100644 index 0000000..0929547 --- /dev/null +++ b/pets-fe/pages/tasks/tasks.js @@ -0,0 +1,104 @@ +const { secure, toast } = require("../../utils/api"); +const today = () => new Date().toLocaleDateString("sv-SE"); +Page({ + data: { + pets: [], + tasks: [], + date: today(), + showForm: false, + title: "", + petIndex: 0, + category: "food", + time_of_day: "08:30", + repeat_rule: "daily", + done: 0, + }, + onShow() { + this.load(); + }, + async load() { + try { + const [pets, result] = await Promise.all([ + secure("/pets"), + secure("/tasks?date=" + this.data.date), + ]); + const tasks = result.items.map((t) => ({ + ...t, + petName: (pets.find((p) => p.id === t.pet_id) || {}).name || "宠物", + })); + this.setData({ + pets, + tasks, + 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, + }); + this.load(); + } catch (err) { + toast(err); + } + }, + open() { + this.setData({ showForm: true }); + }, + close() { + this.setData({ showForm: false }); + }, + field(e) { + this.setData({ [e.currentTarget.dataset.key]: e.detail.value }); + }, + petChange(e) { + this.setData({ petIndex: Number(e.detail.value) }); + }, + timeChange(e) { + this.setData({ time_of_day: e.detail.value }); + }, + categoryChange(e) { + this.setData({ category: e.detail.value }); + }, + repeatChange(e) { + this.setData({ repeat_rule: e.detail.value }); + }, + async save() { + if (!this.data.pets.length || !this.data.title.trim()) { + wx.showToast({ title: "请选择宠物并填写任务名", icon: "none" }); + return; + } + try { + await secure("/tasks", "POST", { + pet_id: this.data.pets[this.data.petIndex].id, + title: this.data.title, + category: this.data.category, + time_of_day: this.data.time_of_day, + repeat_rule: this.data.repeat_rule, + }); + this.setData({ showForm: false, title: "" }); + this.load(); + } catch (e) { + toast(e); + } + }, + async remove(e) { + const t = this.data.tasks[e.currentTarget.dataset.index]; + const result = await wx.showModal({ + title: "删除任务", + content: "确定删除「" + t.title + "」吗?", + }); + if (!result.confirm) return; + try { + await secure("/tasks/" + t.id, "DELETE"); + this.load(); + } catch (err) { + toast(err); + } + }, +}); diff --git a/pets-fe/pages/tasks/tasks.json b/pets-fe/pages/tasks/tasks.json new file mode 100644 index 0000000..995a014 --- /dev/null +++ b/pets-fe/pages/tasks/tasks.json @@ -0,0 +1 @@ +{ "usingComponents": { "tab-bar": "/components/tab-bar/index" } } diff --git a/pets-fe/pages/tasks/tasks.wxml b/pets-fe/pages/tasks/tasks.wxml new file mode 100644 index 0000000..340c942 --- /dev/null +++ b/pets-fe/pages/tasks/tasks.wxml @@ -0,0 +1,134 @@ + + LITTLE THINGS, BIG LOVE + 今日小任务 ☑ + + 今天也要照顾好它们 + + {{done}} + / {{tasks.length}} + + + + + + + {{date}} 的日常 + + 添加任务 + + + + + {{item.completed?'✓':''}} + + + {{item.category==='water'?'💧':item.category==='food'?'🥣':'🐾'}} + + + {{item.title}} + + {{item.petName}} · {{item.time_of_day}} · + {{item.repeat_rule==='daily'?'每天':item.repeat_rule==='weekdays'?'工作日':'周末'}} + + + × + + + 从喝水、喂食开始,安排温柔的小日常 ✨ + + + + + + + + 新建小任务 + × + + + 任务名称 + + + + 照顾谁 + + {{pets[petIndex].name||'请先添加宠物'}} + + + + 类型 + + + + + + + + 时间 + + {{time_of_day}} + + + + 重复 + + + + + + + + + + diff --git a/pets-fe/pages/tasks/tasks.wxss b/pets-fe/pages/tasks/tasks.wxss new file mode 100644 index 0000000..7294dd3 --- /dev/null +++ b/pets-fe/pages/tasks/tasks.wxss @@ -0,0 +1,123 @@ +.summary { + background: #194f42; + color: white; + border-radius: 34rpx; + padding: 34rpx; +} +.summary > view:first-child { + font-size: 24rpx; + color: #bbe3c4; +} +.summary strong { + display: block; + font-size: 55rpx; + margin: 10rpx 0; +} +.summary strong text { + font-size: 30rpx; + color: #b4d8be; +} +.progress { + height: 13rpx; + background: #3f7566; + border-radius: 99rpx; + overflow: hidden; +} +.progress view { + height: 100%; + background: #e9ad6a; + border-radius: 99rpx; +} +.add { + color: #2b8a63; + font-size: 24rpx; + font-weight: 700; +} +.list { + padding: 8rpx 26rpx; +} +.task { + display: flex; + align-items: center; + gap: 15rpx; + padding: 22rpx 0; + border-bottom: 1rpx solid #eef2ee; +} +.task:last-child { + border: 0; +} +.check { + width: 42rpx; + height: 42rpx; + border: 3rpx solid #cbdcd1; + border-radius: 13rpx; + text-align: center; + line-height: 37rpx; + color: white; +} +.check.done { + background: #389867; + border-color: #389867; +} +.icon { + width: 65rpx; + height: 65rpx; + background: #eff5ee; + border-radius: 18rpx; + display: grid; + place-items: center; + font-size: 34rpx; +} +.info { + flex: 1; +} +.info strong, +.info text { + display: block; +} +.info text { + font-size: 21rpx; + color: #92a498; + margin-top: 5rpx; +} +.remove { + font-size: 36rpx; + color: #c4d0c7; +} +.overlay { + position: fixed; + inset: 0; + background: #102a24a0; + z-index: 30; + display: flex; + align-items: flex-end; +} +.sheet { + width: 100%; + background: #f6f9f6; + border-radius: 35rpx 35rpx 0 0; + padding: 32rpx 32rpx calc(env(safe-area-inset-bottom) + 30rpx); + max-height: 85vh; + overflow: auto; +} +.sheet > .row strong { + font-size: 33rpx; +} +.sheet > .row text { + font-size: 43rpx; +} +.sheet radio-group { + display: flex; + flex-wrap: wrap; + gap: 15rpx; +} +.sheet label { + font-size: 23rpx; + display: flex; + align-items: center; +} +.picker { + background: white; + padding: 23rpx; + border-radius: 20rpx; +} diff --git a/pets-fe/pages/user/user.js b/pets-fe/pages/user/user.js deleted file mode 100644 index a4da6c5..0000000 --- a/pets-fe/pages/user/user.js +++ /dev/null @@ -1,101 +0,0 @@ -const api = require('../../utils/api.js'); -const store = require('../../utils/store.js'); -const { toastErr } = require('../../utils/ui.js'); - -const TAG_CLASS = { 求助: 'warn', 经验: 'blue', 精选: 'purple', 避坑: 'red', 晒宠: '' }; -// 审核状态 → 展示文案(只在看自己主页时出现) -const STATUS_TEXT = { pending: '审核中', rejected: '未通过' }; - -function fmtAgo(iso) { - if (!iso) return ''; - const d = new Date(iso); - if (isNaN(d.getTime())) return ''; - const min = Math.floor((Date.now() - d.getTime()) / 60000); - if (min < 1) return '刚刚'; - if (min < 60) return min + ' 分钟前'; - if (min < 60 * 24) return Math.floor(min / 60) + ' 小时前'; - if (min < 60 * 24 * 7) return Math.floor(min / 1440) + ' 天前'; - const p = (n) => (n < 10 ? '0' + n : '' + n); - return p(d.getMonth() + 1) + '-' + p(d.getDate()); -} - -Page({ - data: { uid: '', card: null, posts: [], page: 1, hasMore: false, loading: true }, - onLoad(q) { - const uid = q && q.id; - if (!uid) return wx.showToast({ title: '缺少用户', icon: 'none' }); - this.setData({ uid }); - this.loadCard(); - this.loadPosts(1); - }, - loadCard() { - api - .userCard(this.data.uid) - .then((card) => this.setData({ card, loading: false })) - .catch((e) => { - this.setData({ loading: false }); - toastErr(e); - }); - }, - loadPosts(page) { - api - .userPosts(this.data.uid, page) - .then((res) => { - const list = (res.list || []).map((p) => { - const tag = (p.tags || [])[0] || ''; - return { - ...p, - timeText: fmtAgo(p.created_at), - author_emoji: p.author_emoji || '🐾', - tag, - tagClass: TAG_CLASS[tag] || '', - // 只有看自己主页才会拿到非「已发布」的帖子,给个审核状态标 - statusText: STATUS_TEXT[p.status] || '', - }; - }); - const merged = page === 1 ? list : this.data.posts.concat(list); - this.setData({ posts: merged, page, hasMore: merged.length < (res.total || 0) }); - }) - .catch(() => {}); - }, - loadMore() { - if (this.data.hasMore) this.loadPosts(this.data.page + 1); - }, - // 关注状态先本地翻转,请求失败再翻回去——等接口返回按钮才变会显得很迟钝 - toggleFollow() { - const c = this.data.card; - if (!c || c.is_self) return; - const next = !c.followed; - this.setData({ - 'card.followed': next, - 'card.follower_count': Math.max(0, c.follower_count + (next ? 1 : -1)), - }); - (next ? api.followUser(c.id) : api.unfollowUser(c.id)).catch((e) => { - this.setData({ 'card.followed': !next, 'card.follower_count': c.follower_count }); - toastErr(e, '操作失败'); - }); - }, - goRelations(e) { - const kind = e.detail.kind; - wx.navigateTo({ url: `/pages/relations/relations?id=${this.data.uid}&kind=${kind}` }); - }, - // 自己看自己的主页时,头部按钮是「装扮」而不是「关注」 - goDecorate() { - wx.navigateTo({ url: '/pages/decorate/decorate' }); - }, - // 别人的宠物只是名片,点了不跳;自己的才切过去看 - goPet(e) { - const c = this.data.card; - if (!c || !c.is_self) return; - store.switchPet(e.detail.id); - wx.switchTab({ url: '/pages/home/home' }); - }, - previewImage(e) { - const { urls, cur } = e.currentTarget.dataset; - if (urls && urls.length) wx.previewImage({ urls, current: cur }); - }, - onShareAppMessage() { - const n = (this.data.card && this.data.card.nickname) || '宠友'; - return { title: `${n} 的主页`, path: `/pages/user/user?id=${this.data.uid}` }; - }, -}); diff --git a/pets-fe/pages/user/user.json b/pets-fe/pages/user/user.json deleted file mode 100644 index b4ba05a..0000000 --- a/pets-fe/pages/user/user.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "usingComponents": { - "nav-bar": "/components/nav-bar/nav-bar", - "pt-icon": "/components/pt-icon/index", - "profile-head": "/components/profile-head/index" - } -} diff --git a/pets-fe/pages/user/user.wxml b/pets-fe/pages/user/user.wxml deleted file mode 100644 index fee2bff..0000000 --- a/pets-fe/pages/user/user.wxml +++ /dev/null @@ -1,45 +0,0 @@ - -module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; } - - - - - - 加载中… - - - - - - - {{item.author_emoji}} - - {{item.author_name}} - {{item.timeText}} - - {{item.statusText}} - {{item.tag}} - - 内容审核中,通过后其他人才能看到 - 未通过内容安全审核,仅你可见 - {{item.content}} - - - - {{img}} - - - - {{item.like_count || 0}} - {{item.comment_count || 0}} - - - - TA 还没有发过帖子 - - - diff --git a/pets-fe/pages/user/user.wxss b/pets-fe/pages/user/user.wxss deleted file mode 100644 index 3e9ff8c..0000000 --- a/pets-fe/pages/user/user.wxss +++ /dev/null @@ -1,15 +0,0 @@ -/* 头部整块(头像/昵称/签名/计数/养宠数据/宠物墙)已抽成 components/profile-head, - 帖子卡走 app.wxss 的 .post-card。这里没有页面独有样式了。 */ - -/* 审核状态标(只在看自己主页出现) */ -.post-status{ - padding:2rpx var(--sp-2);border-radius:var(--r-full); - font-size:var(--fs-cap);font-weight:var(--fw-b);margin-right:var(--sp-2); -} -.post-status.st-pending{background:#FDECC8;color:#B26A00} -.post-status.st-rejected{background:var(--red-soft);color:var(--red-ink)} -.status-note{ - margin:0 0 var(--sp-2);padding:var(--sp-2) var(--sp-3);border-radius:var(--r-sm); - background:var(--surface-2);color:var(--muted);font-size:var(--fs-cap);line-height:1.5; -} -.status-note.st-note-red{background:var(--red-soft);color:var(--red-ink)} diff --git a/pets-fe/pages/walk/walk.js b/pets-fe/pages/walk/walk.js new file mode 100644 index 0000000..edb751e --- /dev/null +++ b/pets-fe/pages/walk/walk.js @@ -0,0 +1,165 @@ +const { secure, toast } = require("../../utils/api"); +Page({ + data: { + pets: [], + selected: [], + walk: null, + points: [], + polyline: [], + latitude: 31.23, + longitude: 121.47, + duration: "00:00", + distance: "0.00", + share: false, + }, + onShow() { + this.load(); + this.clock = setInterval(() => this.tick(), 1000); + }, + onHide() { + this.stopTimers(); + }, + onUnload() { + this.stopTimers(); + }, + stopTimers() { + clearInterval(this.clock); + clearInterval(this.tracker); + }, + async load() { + try { + const [pets, walks] = await Promise.all([ + secure("/pets"), + secure("/walks"), + ]); + const selected = (wx.getStorageSync("walk_pet_ids") || []).filter((id) => + pets.some((p) => p.id === id), + ); + const walk = + walks.find((w) => w.status === "active" || w.status === "paused") || + null; + this.setData({ pets, selected, walk }); + if (walk) { + this.renderWalk(walk); + this.track(); + } + } catch (e) { + toast(e); + } + }, + pick(e) { + if (this.data.walk) return; + this.setData({ selected: e.detail.selected }); + wx.setStorageSync("walk_pet_ids", e.detail.selected); + }, + async start() { + if (!this.data.selected.length) { + wx.showToast({ title: "请选择同行的狗狗", icon: "none" }); + return; + } + try { + const walk = await secure("/walks", "POST", { + pet_ids: this.data.selected, + }); + this.setData({ walk }); + this.track(); + this.point(); + } catch (e) { + toast(e); + } + }, + track() { + clearInterval(this.tracker); + this.tracker = setInterval(() => this.point(), 15000); + }, + async point() { + if (!this.data.walk || this.data.walk.status !== "active") return; + try { + const pos = await wx.getLocation({ type: "gcj02" }); + const walk = await secure( + "/walks/" + this.data.walk.id + "/points", + "POST", + { latitude: pos.latitude, longitude: pos.longitude }, + ); + this.renderWalk(walk); + } catch (e) { + console.warn(e); + } + }, + renderWalk(walk) { + const pts = (walk.points || []).map((p) => ({ + latitude: p.latitude, + longitude: p.longitude, + })); + const last = pts[pts.length - 1]; + this.setData({ + walk, + points: pts, + polyline: + pts.length > 1 ? [{ points: pts, color: "#268e6a", width: 5 }] : [], + latitude: last ? last.latitude : this.data.latitude, + longitude: last ? last.longitude : this.data.longitude, + distance: (walk.distance_meters / 1000).toFixed(2), + share: walk.share_enabled, + }); + this.tick(); + }, + tick() { + const walk = this.data.walk; + if (!walk) return; + const end = walk.ended_at + ? new Date(walk.ended_at) + : walk.status === "paused" + ? new Date(walk.paused_at || Date.now()) + : new Date(); + const sec = Math.max( + 0, + Math.floor((end - new Date(walk.started_at)) / 1000) - + (walk.paused_seconds || 0), + ); + this.setData({ + duration: + String(Math.floor(sec / 60)).padStart(2, "0") + + ":" + + String(sec % 60).padStart(2, "0"), + }); + }, + async action(e) { + const kind = e.currentTarget.dataset.kind; + try { + const walk = await secure( + "/walks/" + this.data.walk.id + "/" + kind, + "POST", + ); + this.renderWalk(walk); + if (kind === "finish") { + clearInterval(this.tracker); + wx.showToast({ title: "今天的快乐已记录", icon: "success" }); + } + } catch (err) { + toast(err); + } + }, + async shareChange(e) { + try { + const walk = await secure( + "/walks/" + this.data.walk.id + "/share", + "PUT", + { enabled: e.detail.value }, + ); + this.renderWalk(walk); + } catch (err) { + toast(err); + } + }, + onShareAppMessage() { + const walk = this.data.walk; + return { + title: "肉垫计划 · 看看我们遛到哪儿了 🐾", + path: + walk && walk.share_enabled + ? "/pages/share/share?token=" + walk.share_token + : "/pages/home/home", + }; + }, +}); diff --git a/pets-fe/pages/walk/walk.json b/pets-fe/pages/walk/walk.json new file mode 100644 index 0000000..1d3b22b --- /dev/null +++ b/pets-fe/pages/walk/walk.json @@ -0,0 +1 @@ +{ "usingComponents": { "tab-bar": "/components/tab-bar/index", "pet-picker": "/components/pet-picker/index" } } diff --git a/pets-fe/pages/walk/walk.wxml b/pets-fe/pages/walk/walk.wxml new file mode 100644 index 0000000..545ee72 --- /dev/null +++ b/pets-fe/pages/walk/walk.wxml @@ -0,0 +1,80 @@ + + PAWPRINT WALK + 一起去兜风 🐾 + + + + {{walk?walk.status==='active'?'记录中':'已暂停':'等待出发'}} + + + + {{walk?'快乐进行时':'出门走走吧'}} + + {{walk?'跟小短腿一起探索':'选择今天同行的狗狗'}} + + + + {{duration}} + 时长 + + + {{distance}} + 公里 + + + {{points.length}} + 足迹 + + + 同行伙伴 · 可多选 + + + 先到「宠物」添加你的柯基吧 🐕 + + + + + + + + + + + 🔒 只有你主动打开分享时,朋友才能查看这次遛狗的位置。 + + + diff --git a/pets-fe/pages/walk/walk.wxss b/pets-fe/pages/walk/walk.wxss new file mode 100644 index 0000000..f0c80da --- /dev/null +++ b/pets-fe/pages/walk/walk.wxss @@ -0,0 +1,105 @@ +.walk-screen { + padding-left: 0; + padding-right: 0; +} +.walk-screen > .eyebrow, +.walk-screen > .title { + margin-left: 32rpx; +} +.map-box { + height: 450rpx; + position: relative; + overflow: hidden; +} +.map-tag { + position: absolute; + top: 25rpx; + left: 30rpx; + background: white; + color: #288363; + padding: 12rpx 21rpx; + border-radius: 99rpx; + font-size: 23rpx; + box-shadow: 0 8rpx 25rpx #173e3622; +} +.session { + margin: -30rpx 25rpx 0; + position: relative; + z-index: 2; + padding: 32rpx; +} +.session-title { + font-size: 35rpx; + font-weight: 800; + margin: 12rpx 0 30rpx; +} +.stats { + display: flex; + border-bottom: 2rpx solid #edf1eb; + padding-bottom: 25rpx; +} +.stats > view { + flex: 1; + border-right: 2rpx solid #e9efea; + padding-left: 22rpx; +} +.stats > view:first-child { + padding-left: 0; +} +.stats > view:last-child { + border: 0; +} +.stats strong { + display: block; + font-size: 38rpx; +} +.stats text { + font-size: 22rpx; + color: #99aa9d; +} +.picker-title { + font-weight: 700; + margin: 26rpx 0 18rpx; +} +.main-button { + margin-top: 30rpx; +} +.actions { + display: flex; + gap: 14rpx; + margin-top: 28rpx; +} +.actions button { + flex: 1; +} +.share-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 28rpx; +} +.share-row strong, +.share-row text { + display: block; +} +.share-row text { + font-size: 21rpx; + color: #90a199; + max-width: 450rpx; + margin-top: 5rpx; +} +.share-button { + margin-top: 20rpx; + background: #e6f1e8; + color: #267d59; + border-radius: 20rpx; + font-size: 25rpx; +} +.privacy { + font-size: 22rpx; + color: #739180; + background: #e9f3e7; + border-radius: 22rpx; + padding: 22rpx; + margin: 20rpx 25rpx; +} diff --git a/pets-fe/preview-v2.html b/pets-fe/preview-v2.html deleted file mode 100644 index f34d32d..0000000 --- a/pets-fe/preview-v2.html +++ /dev/null @@ -1,502 +0,0 @@ -修复验证 -
-

① Pro 长文案按钮(原来撑爆)

-
Pro 会员(即将开放)
-
添加毛孩子
- -

② 用户主页(帖子样式 + 首字头像)

-
-
奶
-
奶牛猫饲养员AI
-
-
1帖子
-
0关注
-
1粉丝
-
已关注
-
-
🐮
-
奶牛猫饲养员
42 分钟前
-
经验
-
刚打完第二针疫苗,小家伙有点蔫蔫的…
-
-
8
-
1
- -

③ 关注/粉丝列表

-
奶
奶牛猫饲养员AI
-
布
布偶少爷
-
\ No newline at end of file diff --git a/pets-fe/preview-v3.html b/pets-fe/preview-v3.html deleted file mode 100644 index ef9d45f..0000000 --- a/pets-fe/preview-v3.html +++ /dev/null @@ -1,414 +0,0 @@ - - - -肉垫计划 · 视觉稿 v3 - - -

肉垫计划 · 视觉稿 v3

-

-用的是 app.wxss 的真 token 值和真图标资源(Tabler mask,含本次新增的 15 个),不是另画一套。 -rpx 按 2:1 折成 px,所以这里的尺寸就是小程序里的尺寸。
-要定的只有第 ① 项:门面色块和浅底内容的接缝怎么处理。 -

- -

① 接缝方案 —— 三选一

-

-关键约束(跟规划里写的不一样,实际读代码才发现):nav-bar 是 position:fixed, -背景是半透明奶白 + backdrop-filter,底边还有一层 mask 淡出——当初就是为了别和页面渐变留一道硬接缝。
-所以品牌色块要盖到最顶部,导航栏必须参与:得给 nav-bar 加一个 tone 属性, -在色块上时不铺奶白、文字转深棕。A / B 需要这个改动,C 不需要。 -

-
-
-
- -
钞票奶盖+
-
-
-
🐶♂
-
钞票
-
柯基2岁7个月射手座
-
›
-
-
我们一起生活的第 862 天
-
-
-
-

A · 色块只到宠物切换条
门面卡压在接缝上,一半在色块里。

-
-
-
- -
钞票奶盖+
-
-
-
🐶♂
-
钞票
-
柯基2岁7个月射手座
-
›
-
-
我们一起生活的第 862 天
-
-
-
-

B · 色块整个包住门面卡
最接近竞品。白卡完整浮在黄底上。

-
-
-
- -
钞票奶盖+
-
-
-
🐶♂
-
钞票
-
柯基2岁7个月射手座
-
›
-
-
我们一起生活的第 862 天
-
-
-
-

C · 色块渐隐,没有硬边界
不动 nav-bar。改动最小,也最不像竞品。

-
-
- -

② 记录页:24 种 4 组 新增 15 种

-

-四组各一个色系,全部走现成的 --primary / green / purple / blue 的 soft/ink 二件套,没有新色值。 -竞品看着「彩色」的真正原因是浅色圆底 + 分组配色,不是插画;图标仍是线性 Tabler mask,零美术成本。 -

-
-
-
- -
日常6
体重
-
排便
-
饮食
-
喝水
-
记账
-
照片
-
健康7
异常
-
疫苗
-
驱虫
-
体检
-
看病
-
给药
-
保健品
-
洗护6
洗澡
-
剪指甲
-
洗耳朵
-
刷牙
-
梳毛
-
美容
-
清洁5
换猫砂
-
洗猫砂盆
-
洗食盆
-
洗水盆
-
消毒
-
-

新:24 种 4 组
一屏看到日常+健康,滚一下到洗护/清洁。

-
-
-
- -
体重
-
便便
-
饮食
-
异常
-
消费
-
用药
-
照片
-
疫苗
-
驱虫
-
-

现状:9 种一锅
全是低频医疗和记账。今天打开大概率没什么可记。

-
-
-
-
新增的 15 个图标
-
喝水
-
洗澡
-
剪指甲
-
洗耳朵
-
刷牙
-
梳毛
-
美容
-
换猫砂
-
洗猫砂盆
-
洗食盆
-
洗水盆
-
消毒
-
体检
-
看病
-
保健品
-

- 从本地那份 tabler-icons-3.46.0 里挑的真源,不是我凭记忆画路径。 - 已解码抽检确认路径非空——Tabler 是 stroke 描边,这套方案唯一的坑就是空字形, - 转字体必踩,走 mask 不会。

-
-
-
- -

③ 身份卡

-

-绘制复用现有海报那套 canvas 2d(bottom-sheet.js 270-380 行),换的是内容不是功能。
-唯一新增后端工作:小程序码要调微信 wxacode.getUnlimited,AppID/Secret 配置里已有、接口还没写。 -身份 ID 用「品种码 + 生日 + 序号」本地拼,不入库。 -

-
-
-
- -
-
-
-
-
🐶
-
-
姓名钞票
-
性别男  品种柯基
-
出生2023 年 12 月 15 日
-
介绍汪星人来地球啦~
-
-
-
身份 ID1116 9832 0231 2150
-
-
-
-
宠星人地球移民
-
居民身份卡
-
签发机构 肉垫计划
有效期限 2023.12.15 - 永远
-
-
小程序码
(待接)
-
-
-
存相册
-
分享
-
-
-
-
-

身份卡
价值全在「可炫耀、可分享」,不在信息量。

-
-
- - diff --git a/pets-fe/preview.html b/pets-fe/preview.html deleted file mode 100644 index 66c5510..0000000 --- a/pets-fe/preview.html +++ /dev/null @@ -1,2385 +0,0 @@ - - - - - - 肉垫计划 Pro UI - 微信小程序交互原型 - - - -
-
-
-
9:41●●● 5G ▰
-
- - 肉垫计划 -
··· ○
-
- - -
-
-
- -

肉垫计划

-

新手养宠的 AI 日历、健康记录和成长报告。
先生成计划,再每天照着做。

-
-
-
30天新手计划
-
8类快速记录
-
AI观察建议
-
- - -
- -
-

你的毛孩子是?

-

第一版建议只支持猫狗,容易做深,不要一开始做全品类。

-
-
-
🐱
猫猫幼猫、成年猫、老年猫 -
-
-
🐶
狗狗幼犬、成年犬、老年犬 -
-
- -
- -
-

告诉我一点它的信息

-

这里越轻越好,先让用户完成建档,再逐步补充信息。

-
-
-
-
-
-
- -
- -
-

它现在处于哪个阶段?

-

阶段会决定今日任务、提醒和 AI 建议。

-
-
幼年期

疫苗、驱虫、体重增长、行为训练

-
刚到家 0-30 天

适应环境、饮食稳定、基础照护

-
成年期

体重、饮食、日常健康管理

-
老年期

慢病、体检、用药和护理

-
- -
- -
-

你最想解决什么?

-

多选。这个信息适合后续用于推荐计划模板和付费能力。

-
-
✓不知道每天该做什么
-
✓疫苗驱虫怕忘记
-
想记录体重和健康
-
担心生病不会判断
-
想控制养宠开销
-
想生成成长报告
-
- -
- -
-

正在生成专属养宠计划

-

真实产品中可做成 2 秒的 AI 生成动画,增强完成感。

-
-
✓ 已识别:幼年期
-
✓ 已生成:30 天新手计划
-
✓ 已安排:疫苗 / 驱虫提醒
-
✓ 已开启:体重、便便、饮食记录
-
✓ 已准备:首张成长报告卡片
-
- -
-
- - -
-
-
-

早上好,Aiden

-

今天也要照顾好 奶球 🐾

-
-
- - -
-
- -
- - - -
- -
-
-
🐱
-
- 奶球 -

4个月|2.8kg|幼年期

-
- 正常 -
-
本周健康完成度68%
-
-
-
5天后第 2 针疫苗
-
+0.2kg本周体重
-
2天连续记录
-
-
- -
-
一29
-
二30
-
三1
-
四2
-
五3
-
六4
-
日5
-
- -
-
-

今日任务

- -
-
-
✓
记录一次体重

幼年期建议每周至少记录 2 次

重要
-
✓
观察饮水和排便

换粮、应激都可能影响排便状态

-
✓
检查疫苗预约

第 2 针疫苗还有 5 天

-
-
- -
-
-

快速记录

- -
-
- - - - - - - - -
-
- -
-
-

AI 今日建议

- -
-

奶球处于快速成长阶段,本周建议重点记录体重和排便。若出现连续呕吐、精神明显变差或便血,应尽快就医。

-
- -
-
-

新手内容

- -
-
-
💉
-
幼猫疫苗和驱虫怎么安排?

把内容引流和计划生成打通,适合冷启动获客。

-
-
-
- - -
-
-

养宠计划

-

计划页也要按宠物切换,避免多宠家庭计划混在一起。

-
- -
- - - -
- -
- - - -
- -
-
-
-

奶球的 30 天计划

- 12/30 -
-
完成度40%
-
-
-
-
今天
观察排便状态

记录颜色、形态、次数,发现软便可连续观察。

-
明天
检查疫苗预约

距离下一针还有 5 天,提前确认医院和时间。

-
第 7 天
体重趋势检查

幼年期每周称重,观察是否稳定增长。

-
第 14 天
复盘饮食与便便

如果近期换粮,建议把换粮过程和异常记录合并查看。

-
-
- -
-
-
2026 年 7 月
-
- 一二三四五六日 -
29
30
1
2
3
4
5
-
6
7
8
9
10
11
12
-
13
14
15
16
17
18
19
-
20
21
22
23
24
25
26
-
-
-
-

7月2日任务

3项
-
-
✓
记录体重

预计 1 分钟完成

-
✓
观察排便

建议拍照留存

-
-
-
- -
-
-

AI 计划模式

Plan
-
-
我会先提取关键信息,再生成可执行计划。你可以审核后确认。
-
奶球 4 个月,最近刚换粮,担心软便,也快打第二针疫苗。
-
我提取到了这些信息,请确认是否准确。
-
-
-
阶段幼年期 / 疫苗期
-
风险换粮软便
-
重点排便观察
-
提醒第二针疫苗
-
-
- - -
-
-
-

推荐计划模板

-
-
🍽️
-
7 天换粮观察计划

每日记录饮食、便便、精神状态,自动生成复盘。

-
-
-
💉
-
疫苗前后护理计划

含洗澡避坑、异常观察、就医摘要。

-
-
-
-
- - -
-
-

快速记录

-

先选择宠物,再记录体重、饮食、异常和消费。

-
- -
- - - -
- -
- - - - - - - - - -
- -
-

体重趋势

-
-
- - - - -
-
-
- -
-

健康时间轴

-
-
⚖️
体重 2.8kg

今天 09:12|较上次 +0.2kg

-
💩
排便正常

昨天 20:18|颜色和形态正常

-
💰
猫粮 ¥168

7月1日|食品类支出

-
-
-
- - -
-
-

成长报告

-

不同宠物的报告、账单和健康摘要要完全分开。

-
- -
- - - -
- -
-

本周成长报告

稳定成长
-

本周 奶球 完成 6 项任务,体重 +0.2kg,无高风险异常记录。

-
-
6完成任务
-
+0.2kg 增长
-
0高风险异常
-
-
- - -
-
- -
-

健康摘要

-
疫苗进度2/3,即将到期
-
驱虫状态下次 7月15日
-
体重趋势稳定增长
-
异常记录低风险 1 次
-
- -
-

养宠账单

-
-
¥326本月花费
-
¥168最大支出
-
3消费类别
-
-
-
食品
¥168
-
用品
¥96
-
医疗
¥62
-
-
- -
-

Pro 会员

变现点
-

365 天计划、PDF 健康档案、月度报告、多宠物管理和年度账单都适合做付费权益。

- -
-
- - - -
-
-

宠友圈

-

社区适合做交流、晒宠和内容增长,但不要压过工具主线。

-
- -
- - - - - -
- -
-
🐱
-
分享一下今天和 奶球 的日常...
- -
- -
-
-
🐱
-
奶球的铲屎官幼猫新手|刚刚
- 求助 -
-
刚换粮第 2 天有一点软便,精神和食欲都正常。大家一般会观察几天?我已经在记录里保存了便便状态。
-
-
🍽️
-
💩
-
🐱
-
-
- - - -
-
- -
-
-
🐶
-
布丁今天不拆家幼犬训练|12 分钟前
- 经验 -
-
分享一个幼犬到家 7 天适应计划:先固定睡觉区域,再固定喂食时间,最后做短时间独处训练。不要一开始就全天陪着。
-
-
🐶
-
🏠
-
🧸
-
-
- - - -
-
- -
-
-
🐾
-
肉垫计划官方精选内容|今天
- 精选 -
-
新手帖:猫咪软便不一定马上就医,但要记录持续时间、精神状态、食欲和是否便血。可以从异常记录里生成就医前摘要。
-
- - - -
-
-
- - -
-
-

新手养宠知识

-

内容页负责冷启动获客,结尾引导生成计划。

-
-
💉
幼猫疫苗和驱虫怎么安排?

适合搜索流量和社群传播,引导生成提醒。

-
💩
猫咪软便要不要去医院?

接异常记录和 AI 观察建议。

-
🍽️
7 天换粮计划怎么做?

接计划模板,适合一次性付费。

-
💰
一个月养猫大概花多少钱?

接养宠账本和年度账单。

-
- - -
-
-
🐱
-

奶球

-

4个月|2.8kg|幼年期

-
-
-
宠物档案 已完善 72%
-
提醒设置 已开启
-
健康记录 12 条
-
多宠物管理 Pro
-
-
-
会员权益 未开通
-
数据导出 PDF
-
意见反馈 去填写
-
关于我们 v0.2
-
- -
- - - - - -
-
-
-
-
- - -
- - - - \ No newline at end of file diff --git a/pets-fe/project.config.json b/pets-fe/project.config.json index 17dcaad..1165bae 100644 --- a/pets-fe/project.config.json +++ b/pets-fe/project.config.json @@ -1,36 +1,7 @@ { - "description": "肉垫计划 - 新手养宠小程序", + "description": "肉垫计划 - 多宠物遛狗与日常照护", "packOptions": { - "ignore": [ - { - "type": "file", - "value": "preview.html" - }, - { - "type": "file", - "value": "preview-v2.html" - }, - { - "type": "folder", - "value": "assets/options" - }, - { - "type": "file", - "value": "assets/logo-512.png" - }, - { - "type": "file", - "value": "assets/logo-lockup.svg" - }, - { - "type": "folder", - "value": "scripts" - }, - { - "type": "file", - "value": "preview-v3.html" - } - ], + "ignore": [], "include": [] }, "setting": { diff --git a/pets-fe/project.private.config.json b/pets-fe/project.private.config.json index 937cea3..bcb2d8f 100644 --- a/pets-fe/project.private.config.json +++ b/pets-fe/project.private.config.json @@ -1,5 +1,5 @@ { - "libVersion": "3.13.0", + "libVersion": "3.17.3", "projectname": "pets-fe", "condition": {}, "setting": { @@ -17,6 +17,7 @@ "compileHotReLoad": true, "checkInvalidKey": true, "ignoreDevUnusedFiles": true, - "bigPackageSizeSupport": false + "bigPackageSizeSupport": false, + "useGlassEaselForWxml": false } } \ No newline at end of file diff --git a/pets-fe/scripts/check.js b/pets-fe/scripts/check.js deleted file mode 100644 index fc76aed..0000000 --- a/pets-fe/scripts/check.js +++ /dev/null @@ -1,169 +0,0 @@ -// 用法:node scripts/check.js(在 pets-fe 目录下跑,或直接给全路径) -// -// 静态自查:class 有没有人定义、而且是不是「在能生效的地方」定义、pt-icon 的 name -// 是否存在、绑的事件方法是否存在、组件是否已注册。 -// -// 防的是「漏改一处 → 元素完全裸奔」这种不报错、不在 diff 里、只在特定页面/弹层才暴露 -// 的问题。这个项目已经踩过两次同一个坑:.post-card 留在 community.wxss 却被用户主页 -// 用(帖子没样式)、.profile-row 留在 settings.wxss 却被「我的」用(入口整个散架)。 -// 所以 class 检查是按作用域来的,不是全局并一起看——那样恰好检查不出这类问题。 -const fs = require('fs'); -const path = require('path'); - -const FE = path.resolve(__dirname, '..'); -const walk = (d, out = []) => { - for (const e of fs.readdirSync(d, { withFileTypes: true })) { - const p = path.join(d, e.name); - if (e.isDirectory()) { if (!['node_modules', '.git'].includes(e.name)) walk(p, out); } - else out.push(p); - } - return out; -}; -const files = walk(FE); -const read = (p) => fs.readFileSync(p, 'utf8'); -const rel = (p) => path.relative(FE, p); - -// ── 一个 wxss 里定义的 class,跟着 @import 一起收 ──────────────────────────── -function classesOf(wxss, seen = new Set()) { - const out = new Set(); - if (!fs.existsSync(wxss) || seen.has(wxss)) return out; - seen.add(wxss); - const src = read(wxss); - for (const m of src.matchAll(/\.([a-zA-Z][\w-]*)/g)) out.add(m[1]); - for (const m of src.matchAll(/@import\s+["']([^"']+)["']/g)) { - for (const c of classesOf(path.resolve(path.dirname(wxss), m[1]), seen)) out.add(c); - } - return out; -} - -// ── 作用域 ───────────────────────────────────────────────────────────────── -// app.wxss 是全局的;页面 wxss 只对自己那一页生效;组件 wxss 只对组件自己的节点生效。 -// 页面之间互不可见——这正是前面两次事故的成因。 -const GLOBAL = classesOf(path.join(FE, 'app.wxss')); - -// 谁定义了这个 class,报错时好指路 -const whereDefined = new Map(); -for (const f of files.filter((f) => f.endsWith('.wxss'))) { - for (const c of classesOf(f)) { - if (!whereDefined.has(c)) whereDefined.set(c, []); - whereDefined.get(c).push(rel(f)); - } -} - -const iconNames = new Set(); -for (const m of read(path.join(FE, 'styles/iconfont.wxss')).matchAll(/\.pt-i-([\w-]+)\{/g)) iconNames.add(m[1]); - -const problems = []; -for (const f of files.filter((f) => f.endsWith('.wxml'))) { - const src = read(f); - const r = rel(f); - const isComponent = /^(components|custom-tab-bar)\//.test(r); - - // 这个 wxml 能看到的 class:自己那个同名 wxss,加上全局(组件要开了 addGlobalClass 才有) - const own = classesOf(f.replace(/\.wxml$/, '.wxss')); - const js = f.replace(/\.wxml$/, '.js'); - const seesGlobal = !isComponent || - (fs.existsSync(js) && read(js).includes('addGlobalClass')); - const scope = seesGlobal ? new Set([...own, ...GLOBAL]) : own; - - for (const m of src.matchAll(/class="([^"]*)"/g)) { - // 先把 {{...}} 整段挖掉(里面是表达式不是 class),再看剩下的静态部分; - // 表达式里的三元字面量 'active' 单独收集 - const dyn = [...m[1].matchAll(/\{\{([^}]*)\}\}/g)].map((x) => x[1]).join(' '); - const statics = m[1].replace(/\{\{[^}]*\}\}/g, ' '); - const names = statics.split(/\s+/).filter(Boolean); - // 只认三元表达式里的字面量(? 'x' : 'y');wxs 函数的字符串入参不是 class - for (const q of dyn.matchAll(/[?:]\s*'([\w-]+)'/g)) names.push(q[1]); - // 纯布局壳,没样式是故意的 - const LAYOUT_ONLY = new Set(['greeting', 'tab-text', 'aiPlan']); - for (const raw of names) { - if (LAYOUT_ONLY.has(raw)) continue; - // class="row-{{item.role}}" 挖掉表达式后剩个 row-,是拼接前缀不是类名 - if (raw.endsWith('-')) continue; - if (scope.has(raw)) continue; - const at = whereDefined.get(raw); - if (at && at.length) { - // 最坑的一种:class 有定义,但定义在别的页面/组件里,这一页拿不到,元素裸奔 - problems.push(`${r}: class "${raw}" 只定义在 ${at.join(' / ')} —— 页面级 wxss 跨不了页,这里是裸的。两页共用就提到 app.wxss`); - } else { - problems.push(`${r}: class "${raw}" 没有任何 wxss 定义`); - } - } - } - - // wx:if / wx:elif / wx:else 链的第一支必须是 wx:if。 - // 删掉链首那一支(比如把 9 个记录分支整体搬走)会让整条链从 wx:elif 开头, - // 小程序报「wx:if not found」直接编译不过——整个组件一起挂,不是少个样式。 - // - // 判断必须靠真正的标签栈,不能靠缩进:这个项目里 scroll-view 和它的子节点 - // 缩进是一样深的,按缩进判「同层」会把父节点当成兄弟节点,什么都查不出来。 - { - const noComment = src.replace(//g, ''); - const stack = [{ last: null }]; // 每层记「上一个兄弟节点带的条件指令」 - const re = /<(\/?)([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g; - let m; - while ((m = re.exec(noComment))) { - const [, closing, tag, rawAttrs] = m; - // 自闭合要从 attrs 尾部判断:属性那段是贪婪的,会把 /> 里的 / 一起吃掉, - // 单独用一个 (\/?) 捕获组永远是空的。多行写的 就是这么 - // 被当成非自闭合、入了栈,把整份栈的层级全带偏,满屏误报。 - const selfClose = /\/\s*$/.test(rawAttrs); - const attrs = rawAttrs.replace(/\/\s*$/, ''); - const top = stack[stack.length - 1]; - if (closing) { - if (stack.length > 1) stack.pop(); - continue; - } - const cond = /\bwx:if\b/.test(attrs) ? 'if' - : /\bwx:elif\b/.test(attrs) ? 'elif' - : /\bwx:else\b/.test(attrs) ? 'else' : ''; - if (cond === 'elif' || cond === 'else') { - if (top.last !== 'if' && top.last !== 'elif') { - const line = noComment.slice(0, m.index).split('\n').length; - problems.push(`${r}: 第 ${line} 行附近 <${tag} wx:${cond}> 前面没有 wx:if` + - ` —— 链首被删了,这个文件编译不过`); - } - } - // 没带条件的兄弟节点会把链断开,所以无条件覆盖 - top.last = cond || null; - // 不搞「void 标签」白名单:wxml 里 是显式闭合的, - // 把它当 void 就会「开标签不入栈、闭标签却弹栈」,一路把父级弹光, - // 于是满屏误报。只认 /> 自闭合,其余一律入栈。 - if (!selfClose) stack.push({ last: null }); - } - } - - // 事件绑定的方法必须真的存在——绑了个不存在的方法,点了没反应且不报错 - if (fs.existsSync(js)) { - const code = read(js); - const handlers = new Set(); - for (const m of src.matchAll(/\b(?:bind|catch)(?::)?[a-z]+="([A-Za-z_$][\w$]*)"/g)) handlers.add(m[1]); - for (const h of handlers) { - if (!new RegExp(`(^|[\\s,{])${h}\\s*[(:]`, 'm').test(code)) { - problems.push(`${r}: 绑了 ${h},但 ${path.basename(js)} 里没有这个方法 —— 点了没反应`); - } - } - } - - // pt-icon 的静态 name - for (const m of src.matchAll(/]*name="([^"{]*)"/g)) { - if (m[1] && !iconNames.has(m[1])) problems.push(`${r}: pt-icon name="${m[1]}" 图标不存在`); - } - // 用到的自定义组件是否在同名 .json 里注册 - const json = f.replace(/\.wxml$/, '.json'); - const reg = fs.existsSync(json) ? Object.keys(JSON.parse(read(json)).usingComponents || {}) : []; - const known = ['nav-bar', 'pet-switch', 'bottom-sheet', 'fab', 'pt-icon', 'seg-tabs', 'stat-ring', 'profile-head']; - for (const tag of known) { - if (new RegExp('<' + tag + '[\\s>]').test(src) && !reg.includes(tag)) { - problems.push(`${r}: 用了 <${tag}> 但 ${path.basename(json)} 没有注册`); - } - } -} - -if (problems.length) { - console.log('发现 ' + problems.length + ' 处问题:'); - problems.forEach((p) => console.log(' ✗ ' + p)); - process.exit(1); -} -console.log('✓ class 作用域 / 图标名 / 事件方法 / 组件注册 全部对得上(检查了 ' + - files.filter((f) => f.endsWith('.wxml')).length + ' 个 wxml,' + iconNames.size + ' 个图标)'); diff --git a/pets-fe/styles/iconfont.wxss b/pets-fe/styles/iconfont.wxss deleted file mode 100644 index 0e08a25..0000000 --- a/pets-fe/styles/iconfont.wxss +++ /dev/null @@ -1,59 +0,0 @@ -/* 图标资源:由 Tabler Icons(MIT) 生成,勿手改。 - 用 mask-image 而非字体:Tabler 是 stroke 描边图标,转字体需先把描边展开成填充轮廓, - 漏了这步会生成空字形,真机表现和「字体没加载」一模一样,极难排查。mask 走 alpha,没这个坑。 - 染色靠 background-color:currentColor,选中态直接改文字色即可。 */ -.pt-i-weight{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik03IDIwbDEwIDAiIC8+PHBhdGggZD0iTTYgNmw2IC0xbDYgMSIgLz48cGF0aCBkPSJNMTIgM2wwIDE3IiAvPjxwYXRoIGQ9Ik05IDEybC0zIC02bC0zIDZhMyAzIDAgMCAwIDYgMCIgLz48cGF0aCBkPSJNMjEgMTJsLTMgLTZsLTMgNmEzIDMgMCAwIDAgNiAwIiAvPjwvc3ZnPg==")} -.pt-i-poop{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMCAxMmguMDEiIC8+PHBhdGggZD0iTTE0IDEyaC4wMSIgLz48cGF0aCBkPSJNMTAgMTZhMy41IDMuNSAwIDAgMCA0IDAiIC8+PHBhdGggZD0iTTExIDRjMiAwIDMuNSAxLjUgMy41IDRsLjE2NCAwYTIuNSAyLjUgMCAwIDEgMi4xOTYgMy4zMmEzIDMgMCAwIDEgMS42MTUgMy4wNjNhMyAzIDAgMCAxIC0xLjI5OSA1LjYwN2wtLjE3NiAwaC0xMGEzIDMgMCAwIDEgLTEuNDc0IC01LjYxM2EzIDMgMCAwIDEgMS42MTUgLTMuMDYyYTIuNSAyLjUgMCAwIDEgMi4xOTUgLTMuMzJsLjE2NCAwYzEuNSAwIDIuNSAtMiAxLjUgLTRsMCAuMDA1IiAvPjwvc3ZnPg==")} -.pt-i-food{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDExaDE2YTEgMSAwIDAgMSAxIDF2LjVjMCAxLjUgLTIuNTE3IDUuNTczIC00IDYuNXYxYTEgMSAwIDAgMSAtMSAxaC04YTEgMSAwIDAgMSAtMSAtMXYtMWMtMS42ODcgLTEuMDU0IC00IC01IC00IC02LjV2LS41YTEgMSAwIDAgMSAxIC0xIiAvPjxwYXRoIGQ9Ik04IDdjMS42NTcgMCAzIC0uODk1IDMgLTJzLTEuMzQzIC0yIC0zIC0ycy0zIC44OTUgLTMgMnMxLjM0MyAyIDMgMiIgLz48cGF0aCBkPSJNMTEgNWg5IiAvPjwvc3ZnPg==")} -.pt-i-symptom{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xOSA1YTIuODI4IDIuODI4IDAgMCAxIDAgNGwtOCA4aC00di00bDggLThhMi44MjggMi44MjggMCAwIDEgNCAwIiAvPjxwYXRoIGQ9Ik0xNiA3bC0xLjUgLTEuNSIgLz48cGF0aCBkPSJNMTMgMTBsLTEuNSAtMS41IiAvPjxwYXRoIGQ9Ik0xMCAxM2wtMS41IC0xLjUiIC8+PHBhdGggZD0iTTcgMTdsLTMgMyIgLz48L3N2Zz4=")} -.pt-i-medicine{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00LjUgMTIuNWw4IC04YTQuOTQgNC45NCAwIDAgMSA3IDdsLTggOGE0Ljk0IDQuOTQgMCAwIDEgLTcgLTciIC8+PHBhdGggZD0iTTguNSA4LjVsNyA3IiAvPjwvc3ZnPg==")} -.pt-i-cost{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0zIDEyYTkgOSAwIDEgMCAxOCAwYTkgOSAwIDEgMCAtMTggMCIgLz48cGF0aCBkPSJNOSAxM2g2IiAvPjxwYXRoIGQ9Ik05IDhsMyA0LjUiIC8+PHBhdGggZD0iTTE1IDhsLTMgNC41djQuNSIgLz48L3N2Zz4=")} -.pt-i-vaccine{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xNyAzbDQgNCIgLz48cGF0aCBkPSJNMTkgNWwtNC41IDQuNSIgLz48cGF0aCBkPSJNMTEuNSA2LjVsNiA2IiAvPjxwYXRoIGQ9Ik0xNi41IDExLjVsLTYuNSA2LjVoLTR2LTRsNi41IC02LjUiIC8+PHBhdGggZD0iTTcuNSAxMi41bDEuNSAxLjUiIC8+PHBhdGggZD0iTTEwLjUgOS41bDEuNSAxLjUiIC8+PHBhdGggZD0iTTMgMjFsMyAtMyIgLz48L3N2Zz4=")} -.pt-i-deworm{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMS40NiAyMC44NDZhMTIgMTIgMCAwIDEgLTcuOTYgLTE0Ljg0NmExMiAxMiAwIDAgMCA4LjUgLTNhMTIgMTIgMCAwIDAgOC41IDNhMTIgMTIgMCAwIDEgLS4wOSA3LjA2IiAvPjxwYXRoIGQ9Ik0xNSAxOWwyIDJsNCAtNCIgLz48L3N2Zz4=")} -.pt-i-photo{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik01IDdoMWEyIDIgMCAwIDAgMiAtMmExIDEgMCAwIDEgMSAtMWg2YTEgMSAwIDAgMSAxIDFhMiAyIDAgMCAwIDIgMmgxYTIgMiAwIDAgMSAyIDJ2OWEyIDIgMCAwIDEgLTIgMmgtMTRhMiAyIDAgMCAxIC0yIC0ydi05YTIgMiAwIDAgMSAyIC0yIiAvPjxwYXRoIGQ9Ik05IDEzYTMgMyAwIDEgMCA2IDBhMyAzIDAgMCAwIC02IDAiIC8+PC9zdmc+")} -.pt-i-home{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik01IDEybC0yIDBsOSAtOWw5IDlsLTIgMCIgLz48cGF0aCBkPSJNNSAxMnY3YTIgMiAwIDAgMCAyIDJoMTBhMiAyIDAgMCAwIDIgLTJ2LTciIC8+PHBhdGggZD0iTTkgMjF2LTZhMiAyIDAgMCAxIDIgLTJoMmEyIDIgMCAwIDEgMiAydjYiIC8+PC9zdmc+")} -.pt-i-plan{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDdhMiAyIDAgMCAxIDIgLTJoMTJhMiAyIDAgMCAxIDIgMnYxMmEyIDIgMCAwIDEgLTIgMmgtMTJhMiAyIDAgMCAxIC0yIC0ydi0xMiIgLz48cGF0aCBkPSJNMTYgM3Y0IiAvPjxwYXRoIGQ9Ik04IDN2NCIgLz48cGF0aCBkPSJNNCAxMWgxNiIgLz48cGF0aCBkPSJNOCAxNHY0IiAvPjxwYXRoIGQ9Ik0xMiAxNHY0IiAvPjxwYXRoIGQ9Ik0xNiAxNHY0IiAvPjwvc3ZnPg==")} -.pt-i-record{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDIwaDRsMTAuNSAtMTAuNWEyLjgyOCAyLjgyOCAwIDEgMCAtNCAtNGwtMTAuNSAxMC41djQiIC8+PHBhdGggZD0iTTEzLjUgNi41bDQgNCIgLz48cGF0aCBkPSJNMTYgMTloNiIgLz48cGF0aCBkPSJNMTkgMTZ2NiIgLz48L3N2Zz4=")} -.pt-i-report{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0zIDN2MThoMTgiIC8+PHBhdGggZD0iTTIwIDE4djMiIC8+PHBhdGggZD0iTTE2IDE2djUiIC8+PHBhdGggZD0iTTEyIDEzdjgiIC8+PHBhdGggZD0iTTggMTZ2NSIgLz48cGF0aCBkPSJNMyAxMWM2IDAgNSAtNSA5IC01czMgNSA5IDUiIC8+PC9zdmc+")} -.pt-i-community{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xNC43IDEzLjVjLTEuMSAtMiAtMS40NDEgLTIuNSAtMi43IC0yLjVjLTEuMjU5IDAgLTEuNzM2IC43NTUgLTIuODM2IDIuNzQ3Yy0uOTQyIDEuNzAzIC0yLjg0NiAxLjg0NSAtMy4zMjEgMy4yOTFjLS4wOTcgLjI2NSAtLjE0NSAuNjc3IC0uMTQzIC45NjJjMCAxLjE3NiAuNzg3IDIgMS44IDJjMS4yNTkgMCAzIC0xIDQuNSAtMXMzLjI0MSAxIDQuNSAxYzEuMDEzIDAgMS44IC0uODIzIDEuOCAtMmMwIC0uMjg1IC0uMDQ5IC0uNjk3IC0uMTQ2IC0uOTYyYy0uNDc1IC0xLjQ1MSAtMi41MTIgLTEuODM1IC0zLjQ1NCAtMy41MzgiIC8+PHBhdGggZD0iTTIwLjE4OCA4LjA4MmExLjAzOSAxLjAzOSAwIDAgMCAtLjQwNiAtLjA4MmgtLjAxNWMtLjczNSAuMDEyIC0xLjU2IC43NSAtMS45OTMgMS44NjZjLS41MTkgMS4zMzUgLS4yOCAyLjcgLjUzOCAzLjA1MmMuMTI5IC4wNTUgLjI2NyAuMDgyIC40MDYgLjA4MmMuNzM5IDAgMS41NzUgLS43NDIgMi4wMTEgLTEuODY2Yy41MTYgLTEuMzM1IC4yNzMgLTIuNyAtLjU0IC0zLjA1MmwtLjAwMSAwIiAvPjxwYXRoIGQ9Ik05LjQ3NCA5Yy4wNTUgMCAuMTA5IDAgLjE2MyAtLjAxMWMuOTQ0IC0uMTI4IDEuNTMzIC0xLjM0NiAxLjMyIC0yLjcyMmMtLjIwMyAtMS4yOTcgLTEuMDQ3IC0yLjI2NyAtMS45MzIgLTIuMjY3Yy0uMDU1IDAgLS4xMDkgMCAtLjE2MyAuMDExYy0uOTQ0IC4xMjggLTEuNTMzIDEuMzQ2IC0xLjMyIDIuNzIyYy4yMDQgMS4yOTMgMS4wNDggMi4yNjcgMS45MzMgMi4yNjciIC8+PHBhdGggZD0iTTE2LjQ1NiA2LjczM2MuMjE0IC0xLjM3NiAtLjM3NSAtMi41OTQgLTEuMzIgLTIuNzIyYTEuMTY0IDEuMTY0IDAgMCAwIC0uMTYyIC0uMDExYy0uODg1IDAgLTEuNzI4IC45NyAtMS45MyAyLjI2N2MtLjIxNCAxLjM3NiAuMzc1IDIuNTk0IDEuMzIgMi43MjJjLjA1NCAuMDA3IC4xMDggLjAxMSAuMTYyIC4wMTFjLjg4NSAwIDEuNzMgLS45NzQgMS45MyAtMi4yNjciIC8+PHBhdGggZD0iTTUuNjkgMTIuOTE4Yy44MTYgLS4zNTIgMS4wNTQgLTEuNzE5IC41MzYgLTMuMDUyYy0uNDM2IC0xLjEyNCAtMS4yNzEgLTEuODY2IC0yLjAwOSAtMS44NjZjLS4xNCAwIC0uMjc3IC4wMjcgLS40MDcgLjA4MmMtLjgxNiAuMzUyIC0xLjA1NCAxLjcxOSAtLjUzNiAzLjA1MmMuNDM2IDEuMTI0IDEuMjcxIDEuODY2IDIuMDA5IDEuODY2Yy4xNCAwIC4yNzcgLS4wMjcgLjQwNyAtLjA4MiIgLz48L3N2Zz4=")} -.pt-i-back{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xNSA2bC02IDZsNiA2IiAvPjwvc3ZnPg==")} -.pt-i-next{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik05IDZsNiA2bC02IDYiIC8+PC9zdmc+")} -.pt-i-plus{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMiA1bDAgMTQiIC8+PHBhdGggZD0iTTUgMTJsMTQgMCIgLz48L3N2Zz4=")} -.pt-i-check{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik01IDEybDUgNWwxMCAtMTAiIC8+PC9zdmc+")} -.pt-i-close{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xOCA2bC0xMiAxMiIgLz48cGF0aCBkPSJNNiA2bDEyIDEyIiAvPjwvc3ZnPg==")} -.pt-i-trash{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDdsMTYgMCIgLz48cGF0aCBkPSJNMTAgMTFsMCA2IiAvPjxwYXRoIGQ9Ik0xNCAxMWwwIDYiIC8+PHBhdGggZD0iTTUgN2wxIDEyYTIgMiAwIDAgMCAyIDJoOGEyIDIgMCAwIDAgMiAtMmwxIC0xMiIgLz48cGF0aCBkPSJNOSA3di0zYTEgMSAwIDAgMSAxIC0xaDRhMSAxIDAgMCAxIDEgMXYzIiAvPjwvc3ZnPg==")} -.pt-i-edit{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDIwaDRsMTAuNSAtMTAuNWEyLjgyOCAyLjgyOCAwIDEgMCAtNCAtNGwtMTAuNSAxMC41djQiIC8+PHBhdGggZD0iTTEzLjUgNi41bDQgNCIgLz48L3N2Zz4=")} -.pt-i-share{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMyA0djRjLTYuNTc1IDEuMDI4IC05LjAyIDYuNzg4IC0xMCAxMmMtLjAzNyAuMjA2IDUuMzg0IC01Ljk2MiAxMCAtNnY0bDggLTdsLTggLTciIC8+PC9zdmc+")} -.pt-i-comment{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0zIDIwbDEuMyAtMy45Yy0yLjMyNCAtMy40MzcgLTEuNDI2IC03Ljg3MiAyLjEgLTEwLjM3NGMzLjUyNiAtMi41MDEgOC41OSAtMi4yOTYgMTEuODQ1IC40OGMzLjI1NSAyLjc3NyAzLjY5NSA3LjI2NiAxLjAyOSAxMC41MDFjLTIuNjY2IDMuMjM1IC03LjYxNSA0LjIxNSAtMTEuNTc0IDIuMjkzbC00LjcgMSIgLz48L3N2Zz4=")} -.pt-i-like{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xOS41IDEyLjU3MmwtNy41IDcuNDI4bC03LjUgLTcuNDI4YTUgNSAwIDEgMSA3LjUgLTYuNTY2YTUgNSAwIDEgMSA3LjUgNi41NzIiIC8+PC9zdmc+")} -.pt-i-like-on{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iIzAwMCIgPjxwYXRoIGQ9Ik02Ljk3OSAzLjA3NGE2IDYgMCAwIDEgNC45ODggMS40MjVsLjAzNyAuMDMzbC4wMzQgLS4wM2E2IDYgMCAwIDEgNC43MzMgLTEuNDRsLjI0NiAuMDM2YTYgNiAwIDAgMSAzLjM2NCAxMC4wMDhsLS4xOCAuMTg1bC0uMDQ4IC4wNDFsLTcuNDUgNy4zNzlhMSAxIDAgMCAxIC0xLjMxMyAuMDgybC0uMDk0IC0uMDgybC03LjQ5MyAtNy40MjJhNiA2IDAgMCAxIDMuMTc2IC0xMC4yMTV6IiAvPjwvc3ZnPg==")} -.pt-i-more{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDEyYTEgMSAwIDEgMCAyIDBhMSAxIDAgMSAwIC0yIDAiIC8+PHBhdGggZD0iTTExIDEyYTEgMSAwIDEgMCAyIDBhMSAxIDAgMSAwIC0yIDAiIC8+PHBhdGggZD0iTTE4IDEyYTEgMSAwIDEgMCAyIDBhMSAxIDAgMSAwIC0yIDAiIC8+PC9zdmc+")} -.pt-i-bell{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMCA1YTIgMiAwIDEgMSA0IDBhNyA3IDAgMCAxIDQgNnYzYTQgNCAwIDAgMCAyIDNoLTE2YTQgNCAwIDAgMCAyIC0zdi0zYTcgNyAwIDAgMSA0IC02IiAvPjxwYXRoIGQ9Ik05IDE3djFhMyAzIDAgMCAwIDYgMHYtMSIgLz48L3N2Zz4=")} -.pt-i-settings{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMC4zMjUgNC4zMTdjLjQyNiAtMS43NTYgMi45MjQgLTEuNzU2IDMuMzUgMGExLjcyNCAxLjcyNCAwIDAgMCAyLjU3MyAxLjA2NmMxLjU0MyAtLjk0IDMuMzEgLjgyNiAyLjM3IDIuMzdhMS43MjQgMS43MjQgMCAwIDAgMS4wNjUgMi41NzJjMS43NTYgLjQyNiAxLjc1NiAyLjkyNCAwIDMuMzVhMS43MjQgMS43MjQgMCAwIDAgLTEuMDY2IDIuNTczYy45NCAxLjU0MyAtLjgyNiAzLjMxIC0yLjM3IDIuMzdhMS43MjQgMS43MjQgMCAwIDAgLTIuNTcyIDEuMDY1Yy0uNDI2IDEuNzU2IC0yLjkyNCAxLjc1NiAtMy4zNSAwYTEuNzI0IDEuNzI0IDAgMCAwIC0yLjU3MyAtMS4wNjZjLTEuNTQzIC45NCAtMy4zMSAtLjgyNiAtMi4zNyAtMi4zN2ExLjcyNCAxLjcyNCAwIDAgMCAtMS4wNjUgLTIuNTcyYy0xLjc1NiAtLjQyNiAtMS43NTYgLTIuOTI0IDAgLTMuMzVhMS43MjQgMS43MjQgMCAwIDAgMS4wNjYgLTIuNTczYy0uOTQgLTEuNTQzIC44MjYgLTMuMzEgMi4zNyAtMi4zN2MxIC42MDggMi4yOTYgLjA3IDIuNTcyIC0xLjA2NSIgLz48cGF0aCBkPSJNOSAxMmEzIDMgMCAxIDAgNiAwYTMgMyAwIDAgMCAtNiAwIiAvPjwvc3ZnPg==")} -.pt-i-ai{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xNiAxOGEyIDIgMCAwIDEgMiAyYTIgMiAwIDAgMSAyIC0yYTIgMiAwIDAgMSAtMiAtMmEyIDIgMCAwIDEgLTIgMm0wIC0xMmEyIDIgMCAwIDEgMiAyYTIgMiAwIDAgMSAyIC0yYTIgMiAwIDAgMSAtMiAtMmEyIDIgMCAwIDEgLTIgMm0tNyAxMmE2IDYgMCAwIDEgNiAtNmE2IDYgMCAwIDEgLTYgLTZhNiA2IDAgMCAxIC02IDZhNiA2IDAgMCAxIDYgNiIgLz48L3N2Zz4=")} -.pt-i-warn{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMiA5djQiIC8+PHBhdGggZD0iTTEwLjM2MyAzLjU5MWwtOC4xMDYgMTMuNTM0YTEuOTE0IDEuOTE0IDAgMCAwIDEuNjM2IDIuODcxaDE2LjIxNGExLjkxNCAxLjkxNCAwIDAgMCAxLjYzNiAtMi44N2wtOC4xMDYgLTEzLjUzNmExLjkxNCAxLjkxNCAwIDAgMCAtMy4yNzQgMCIgLz48cGF0aCBkPSJNMTIgMTZoLjAxIiAvPjwvc3ZnPg==")} -.pt-i-doc{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xNCAzdjRhMSAxIDAgMCAwIDEgMWg0IiAvPjxwYXRoIGQ9Ik0xNyAyMWgtMTBhMiAyIDAgMCAxIC0yIC0ydi0xNGEyIDIgMCAwIDEgMiAtMmg3bDUgNXYxMWEyIDIgMCAwIDEgLTIgMiIgLz48cGF0aCBkPSJNOSA5bDEgMCIgLz48cGF0aCBkPSJNOSAxM2w2IDAiIC8+PHBhdGggZD0iTTkgMTdsNiAwIiAvPjwvc3ZnPg==")} -.pt-i-note{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik01IDVhMiAyIDAgMCAxIDIgLTJoMTBhMiAyIDAgMCAxIDIgMnYxNGEyIDIgMCAwIDEgLTIgMmgtMTBhMiAyIDAgMCAxIC0yIC0ybDAgLTE0IiAvPjxwYXRoIGQ9Ik05IDdsNiAwIiAvPjxwYXRoIGQ9Ik05IDExbDYgMCIgLz48cGF0aCBkPSJNOSAxNWw0IDAiIC8+PC9zdmc+")} -.pt-i-book{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0zIDE5YTkgOSAwIDAgMSA5IDBhOSA5IDAgMCAxIDkgMCIgLz48cGF0aCBkPSJNMyA2YTkgOSAwIDAgMSA5IDBhOSA5IDAgMCAxIDkgMCIgLz48cGF0aCBkPSJNMyA2bDAgMTMiIC8+PHBhdGggZD0iTTEyIDZsMCAxMyIgLz48cGF0aCBkPSJNMjEgNmwwIDEzIiAvPjwvc3ZnPg==")} -.pt-i-trend{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDE5bDE2IDAiIC8+PHBhdGggZD0iTTQgMTVsNCAtNmw0IDJsNCAtNWw0IDQiIC8+PC9zdmc+")} -.pt-i-cat{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0yMCAzdjEwYTggOCAwIDEgMSAtMTYgMHYtMTBsMy40MzIgMy40MzJhNy45NjMgNy45NjMgMCAwIDEgNC41NjggLTEuNDMyYzEuNzY5IDAgMy40MDMgLjU3NCA0LjcyOCAxLjU0NmwzLjI3MiAtMy41NDYiIC8+PHBhdGggZD0iTTIgMTZoNWwtNCA0IiAvPjxwYXRoIGQ9Ik0yMiAxNmgtNWw0IDQiIC8+PHBhdGggZD0iTTExIDE2YTEgMSAwIDEgMCAyIDBhMSAxIDAgMSAwIC0yIDAiIC8+PHBhdGggZD0iTTkgMTF2LjAxIiAvPjxwYXRoIGQ9Ik0xNSAxMXYuMDEiIC8+PC9zdmc+")} -.pt-i-dog{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMSA1aDIiIC8+PHBhdGggZD0iTTE5IDEyYy0uNjY3IDUuMzMzIC0yLjMzMyA4IC01IDhoLTRjLTIuNjY3IDAgLTQuMzMzIC0yLjY2NyAtNSAtOCIgLz48cGF0aCBkPSJNMTEgMTZjMCAuNjY3IC4zMzMgMSAxIDFzMSAtLjMzMyAxIC0xaC0yIiAvPjxwYXRoIGQ9Ik0xMiAxOHYyIiAvPjxwYXRoIGQ9Ik0xMCAxMXYuMDEiIC8+PHBhdGggZD0iTTE0IDExdi4wMSIgLz48cGF0aCBkPSJNNSA0bDYgLjk3bC02LjIzOCA2LjY4OGExLjAyMSAxLjAyMSAwIDAgMSAtMS40MSAuMTExYS45NTMgLjk1MyAwIDAgMSAtLjMyNyAtLjk1NGwxLjk3NSAtNi44MTUiIC8+PHBhdGggZD0iTTE5IDRsLTYgLjk3bDYuMjM4IDYuNjg4Yy4zNTggLjQwOCAuOTg5IC40NTggMS40MSAuMTExYS45NTMgLjk1MyAwIDAgMCAuMzI3IC0uOTU0bC0xLjk3NSAtNi44MTUiIC8+PC9zdmc+")} -.pt-i-user{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik04IDdhNCA0IDAgMSAwIDggMGE0IDQgMCAwIDAgLTggMCIgLz48cGF0aCBkPSJNNiAyMXYtMmE0IDQgMCAwIDEgNCAtNGg0YTQgNCAwIDAgMSA0IDR2MiIgLz48L3N2Zz4=")} -.pt-i-clock{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0zIDEyYTkgOSAwIDEgMCAxOCAwYTkgOSAwIDAgMCAtMTggMCIgLz48cGF0aCBkPSJNMTIgN3Y1bDMgMyIgLz48L3N2Zz4=")} -.pt-i-up{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik02IDE1bDYgLTZsNiA2IiAvPjwvc3ZnPg==")} -.pt-i-down{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik02IDlsNiA2bDYgLTYiIC8+PC9zdmc+")} -.pt-i-water{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik03LjUwMiAxOS40MjNjMi42MDIgMi4xMDUgNi4zOTUgMi4xMDUgOC45OTYgMGMyLjYwMiAtMi4xMDUgMy4yNjIgLTUuNzA4IDEuNTY2IC04LjU0NmwtNC44OSAtNy4yNmMtLjQyIC0uNjI1IC0xLjI4NyAtLjgwMyAtMS45MzYgLS4zOTdhMS4zNzYgMS4zNzYgMCAwIDAgLS40MSAuMzk3bC00Ljg5MyA3LjI2Yy0xLjY5NSAyLjgzOCAtMS4wMzUgNi40NDEgMS41NjcgOC41NDYiIC8+PC9zdmc+")} -.pt-i-bath{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDEyaDE2YTEgMSAwIDAgMSAxIDF2M2E0IDQgMCAwIDEgLTQgNGgtMTBhNCA0IDAgMCAxIC00IC00di0zYTEgMSAwIDAgMSAxIC0xIiAvPiA8cGF0aCBkPSJNNiAxMnYtN2EyIDIgMCAwIDEgMiAtMmgzdjIuMjUiIC8+IDxwYXRoIGQ9Ik00IDIxbDEgLTEuNSIgLz4gPHBhdGggZD0iTTIwIDIxbC0xIC0xLjUiIC8+PC9zdmc+")} -.pt-i-nail{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0zIDdhMyAzIDAgMSAwIDYgMGEzIDMgMCAxIDAgLTYgMCIgLz4gPHBhdGggZD0iTTMgMTdhMyAzIDAgMSAwIDYgMGEzIDMgMCAxIDAgLTYgMCIgLz4gPHBhdGggZD0iTTguNiA4LjZsMTAuNCAxMC40IiAvPiA8cGF0aCBkPSJNOC42IDE1LjRsMTAuNCAtMTAuNCIgLz48L3N2Zz4=")} -.pt-i-ear{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik02IDEwYTcgNyAwIDEgMSAxMyAzLjZhMTAgMTAgMCAwIDEgLTIgMmE4IDggMCAwIDAgLTIgM2E0LjUgNC41IDAgMCAxIC02LjggMS40IiAvPiA8cGF0aCBkPSJNMTAgMTBhMyAzIDAgMSAxIDUgMi4yIiAvPjwvc3ZnPg==")} -.pt-i-tooth{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMiA1LjVjLTEuMDc0IC0uNTg2IC0yLjU4MyAtMS41IC00IC0xLjVjLTIuMSAwIC00IDEuMjQ3IC00IDVjMCA0Ljg5OSAxLjA1NiA4LjQxIDIuNjcxIDEwLjUzN2MuNTczIC43NTYgMS45NyAuNTIxIDIuNTY3IC0uMjM2Yy4zOTggLS41MDUgLjgxOSAtMS40MzkgMS4yNjIgLTIuODAxYy4yOTIgLS43NzEgLjg5MiAtMS41MDQgMS41IC0xLjVjLjYwMiAwIDEuMjEgLjczNyAxLjUgMS41Yy40NDMgMS4zNjIgLjg2NCAyLjI5NSAxLjI2MiAyLjhjLjU5NyAuNzU5IDIgLjk5MyAyLjU2NyAuMjM3YzEuNjE1IC0yLjEyNyAyLjY3MSAtNS42MzcgMi42NzEgLTEwLjUzN2MwIC0zLjc0IC0xLjkwOCAtNSAtNCAtNWMtMS40MjMgMCAtMi45MiAuOTExIC00IDEuNSIgLz4gPHBhdGggZD0iTTEyIDUuNWwzIDEuNSIgLz48L3N2Zz4=")} -.pt-i-brush{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0zIDIxdi00YTQgNCAwIDEgMSA0IDRoLTQiIC8+IDxwYXRoIGQ9Ik0yMSAzYTE2IDE2IDAgMCAwIC0xMi44IDEwLjIiIC8+IDxwYXRoIGQ9Ik0yMSAzYTE2IDE2IDAgMCAxIC0xMC4yIDEyLjgiIC8+IDxwYXRoIGQ9Ik0xMC42IDlhOSA5IDAgMCAxIDQuNCA0LjQiIC8+PC9zdmc+")} -.pt-i-groom{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xNiAxOGEyIDIgMCAwIDEgMiAyYTIgMiAwIDAgMSAyIC0yYTIgMiAwIDAgMSAtMiAtMmEyIDIgMCAwIDEgLTIgMm0wIC0xMmEyIDIgMCAwIDEgMiAyYTIgMiAwIDAgMSAyIC0yYTIgMiAwIDAgMSAtMiAtMmEyIDIgMCAwIDEgLTIgMm0tNyAxMmE2IDYgMCAwIDEgNiAtNmE2IDYgMCAwIDEgLTYgLTZhNiA2IDAgMCAxIC02IDZhNiA2IDAgMCAxIDYgNiIgLz48L3N2Zz4=")} -.pt-i-litter{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDdhOCA0IDAgMSAwIDE2IDBhOCA0IDAgMSAwIC0xNiAwIiAvPiA8cGF0aCBkPSJNNCA3YzAgLjY2NCAuMDg4IDEuMzI0IC4yNjMgMS45NjVsMi43MzcgMTAuMDM1Yy41IDEuNSAyLjIzOSAyIDUgMnM0LjUgLS41IDUgLTJjLjMzMyAtMSAxLjI0NiAtNC4zNDUgMi43MzcgLTEwLjAzNWE3LjQ1IDcuNDUgMCAwIDAgLjI2MyAtMS45NjUiIC8+PC9zdmc+")} -.pt-i-litterbox{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik01IDE2bDEuNDY1IDEuNjM4YTIgMiAwIDEgMSAtMy4wMTUgLjA5OWwxLjU1IC0xLjczNyIgLz4gPHBhdGggZD0iTTEzLjczNyA5LjczN2MyLjI5OSAtMi4zIDMuMjMgLTUuMDk1IDIuMDgxIC02LjI0NWMtMS4xNSAtMS4xNSAtMy45NDUgLS4yMTcgLTYuMjQ0IDIuMDgyYy0yLjMgMi4yOTkgLTMuMjMxIDUuMDk1IC0yLjA4MiA2LjI0NGMxLjE1IDEuMTUgMy45NDYgLjIxOCA2LjI0NSAtMi4wODEiIC8+IDxwYXRoIGQ9Ik03LjQ5MiAxMS44MThjLjM2MiAuMzYyIC43NjggLjY3NiAxLjIwOCAuOTM0bDYuODk1IDQuMDQ3YzEuMDc4IC41NTcgMi4yNTUgLS4wNzUgMy42OTIgLTEuNTEyYzEuNDM3IC0xLjQzNyAyLjA3IC0yLjYxNCAxLjUxMiAtMy42OTJjLS4zNzIgLS43MTggLTEuNzIgLTMuMDE3IC00LjA0NyAtNi44OTVhNi4wMTUgNi4wMTUgMCAwIDAgLS45MzQgLTEuMjA4IiAvPjwvc3ZnPg==")} -.pt-i-bowl{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDhoMTZhMSAxIDAgMCAxIDEgMXYuNWMwIDEuNSAtMi41MTcgNS41NzMgLTQgNi41djFhMSAxIDAgMCAxIC0xIDFoLThhMSAxIDAgMCAxIC0xIC0xdi0xYy0xLjY4NyAtMS4wNTQgLTQgLTUgLTQgLTYuNXYtLjVhMSAxIDAgMCAxIDEgLTEiIC8+PC9zdmc+")} -.pt-i-waterbowl{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik04IDIxbDggMCIgLz4gPHBhdGggZD0iTTEyIDE1bDAgNiIgLz4gPHBhdGggZD0iTTE3IDNsMSA3YzAgMy4wMTIgLTIuNjg2IDUgLTYgNXMtNiAtMS45ODggLTYgLTVsMSAtN2gxMCIgLz4gPHBhdGggZD0iTTYgMTBhNSA1IDAgMCAxIDYgMGE1IDUgMCAwIDAgNiAwIiAvPjwvc3ZnPg==")} -.pt-i-clean{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00IDEyYTIgMiAwIDAgMSAyIC0yaDRhMiAyIDAgMCAxIDIgMnY3YTIgMiAwIDAgMSAtMiAyaC00YTIgMiAwIDAgMSAtMiAtMmwwIC03IiAvPiA8cGF0aCBkPSJNNiAxMHYtNGExIDEgMCAwIDEgMSAtMWgyYTEgMSAwIDAgMSAxIDF2NCIgLz4gPHBhdGggZD0iTTE1IDdoLjAxIiAvPiA8cGF0aCBkPSJNMTggOWguMDEiIC8+IDxwYXRoIGQ9Ik0xOCA1aC4wMSIgLz4gPHBhdGggZD0iTTIxIDNoLjAxIiAvPiA8cGF0aCBkPSJNMjEgN2guMDEiIC8+IDxwYXRoIGQ9Ik0yMSAxMWguMDEiIC8+IDxwYXRoIGQ9Ik0xMCA3aDEiIC8+PC9zdmc+")} -.pt-i-checkup{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik02IDRoLTFhMiAyIDAgMCAwIC0yIDJ2My41YTUuNSA1LjUgMCAwIDAgMTEgMHYtMy41YTIgMiAwIDAgMCAtMiAtMmgtMSIgLz4gPHBhdGggZD0iTTggMTVhNiA2IDAgMSAwIDEyIDB2LTMiIC8+IDxwYXRoIGQ9Ik0xMSAzdjIiIC8+IDxwYXRoIGQ9Ik02IDN2MiIgLz4gPHBhdGggZD0iTTE4IDEwYTIgMiAwIDEgMCA0IDBhMiAyIDAgMSAwIC00IDAiIC8+PC9zdmc+")} -.pt-i-vet{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik04IDh2LTJhMiAyIDAgMCAxIDIgLTJoNGEyIDIgMCAwIDEgMiAydjIiIC8+IDxwYXRoIGQ9Ik00IDEwYTIgMiAwIDAgMSAyIC0yaDEyYTIgMiAwIDAgMSAyIDJ2OGEyIDIgMCAwIDEgLTIgMmgtMTJhMiAyIDAgMCAxIC0yIC0ybDAgLTgiIC8+IDxwYXRoIGQ9Ik0xMCAxNGg0IiAvPiA8cGF0aCBkPSJNMTIgMTJ2NCIgLz48L3N2Zz4=")} -.pt-i-supplement{--m:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik00LjUgMTIuNWw4IC04YTQuOTQgNC45NCAwIDAgMSA3IDdsLTggOGE0Ljk0IDQuOTQgMCAwIDEgLTcgLTciIC8+IDxwYXRoIGQ9Ik04LjUgOC41bDcgNyIgLz48L3N2Zz4=")} diff --git a/pets-fe/utils/api.js b/pets-fe/utils/api.js index 4a90407..90e2dca 100644 --- a/pets-fe/utils/api.js +++ b/pets-fe/utils/api.js @@ -1,167 +1,62 @@ -const { request, uploadFile, setToken, setRefreshToken } = require('./request.js'); - -// 登录成功后统一落盘 access + refresh 两个令牌 -function saveTokens(data) { - setToken(data.token); - setRefreshToken(data.refresh_token); - return data; -} - -// wx.login 取 code -function wxLogin() { +const { baseURL } = require("./config"); +function request(path, method = "GET", data, auth = true) { return new Promise((resolve, reject) => { - wx.login({ - success: (r) => (r.code ? resolve(r.code) : reject(new Error('wx.login 未返回 code'))), + const token = getApp().globalData.token || wx.getStorageSync("pet_token"); + wx.request({ + url: baseURL + path, + method, + data, + 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 = ""; + } + const error = new Error((res.data && res.data.message) || "请求失败"); + error.status = res.statusCode; + reject(error); + } + }, fail: reject, }); }); } - -// 登录:优先微信 code2session,失败回退开发态 Mock 登录(后端 dev_login=true) -async function login() { +async function secure(path, method, data) { + await getApp().login(); try { - const code = await wxLogin(); - // noAuthRetry:登录接口失败时也会返回 40100,不能再触发「续期→重新登录」,否则会自己套自己 - const data = await request({ url: '/api/auth/wechat', method: 'POST', data: { code }, noAuthRetry: true }); - return saveTokens(data); - } catch (e) { - console.warn('[api] 微信登录失败,回退 Mock 登录:', e && e.message); - const data = await request({ - url: '/api/auth/login', - method: 'POST', - data: { nickname: '开发用户' }, - noAuthRetry: true, - }); - return saveTokens(data); + return await request(path, method, data); + } catch (error) { + if (error.status === 401) { + await getApp().login(); + return request(path, method, data); + } + throw error; } } - -const api = { - login, - uploadFile, - - // 用户 - getProfile: () => request({ url: '/api/user/profile' }), - bindPhone: (code) => request({ url: '/api/user/phone', method: 'POST', data: { code } }), - updateProfile: (body) => request({ url: '/api/user/profile', method: 'PUT', data: body }), - - // 用户 - userSummary: () => request({ url: '/api/user/summary' }), - // 记录类型(后台可配)。分组好的,前端直接渲染 - recordTypes: () => request({ url: '/api/record-types' }), - updateDecoration: (body) => request({ url: '/api/user/decoration', method: 'PUT', data: body }), - - // 宠物 - getPets: () => request({ url: '/api/pets' }), - homeSummary: (id) => request({ url: `/api/pets/${id}/home-summary` }), - getPet: (id) => request({ url: `/api/pets/${id}` }), - // 养护方案:可选列表 + 套用到某个月 - careTemplates: (species, stage) => - request({ url: '/api/care-templates?species=' + species + '&stage=' + encodeURIComponent(stage) }), - applyTemplate: (petId, templateID, monthOffset) => - request({ url: `/api/pets/${petId}/apply-template`, method: 'POST', - data: { template_id: templateID, month_offset: monthOffset || 0 } }), - - // 生命阶段的划分标准。判定和展示用同一份数据 —— 不另写一份说明文案, - // 否则改了阈值忘了改文案,用户看到的依据和实际行为对不上 - lifeStages: (species, size) => - request({ url: `/api/life-stages?species=${species}&size=${size || ''}` }), - petIDCard: (id) => request({ url: `/api/pets/${id}/id-card` }), - petStageDrift: (petId) => request({ url: `/api/pets/${petId}/stage-drift` }), - - // 品种(后台可配),按首字母分组 - breeds: (species) => request({ url: '/api/breeds?species=' + species }), - createPet: (body) => request({ url: '/api/pets', method: 'POST', data: body }), - updatePet: (id, body) => request({ url: `/api/pets/${id}`, method: 'PUT', data: body }), - deletePet: (id) => request({ url: `/api/pets/${id}`, method: 'DELETE' }), - onboarding: (body) => request({ url: '/api/onboarding', method: 'POST', data: body }), - - // 记录 - getRecords: (id, { type, page, pageSize } = {}) => { - const qs = []; - if (type) qs.push(`type=${type}`); - if (page) qs.push(`page=${page}`); - if (pageSize) qs.push(`page_size=${pageSize}`); - return request({ url: `/api/pets/${id}/records${qs.length ? '?' + qs.join('&') : ''}` }); - }, - createRecord: (id, body) => request({ url: `/api/pets/${id}/records`, method: 'POST', data: body }), - deleteRecord: (recordId) => request({ url: `/api/records/${recordId}`, method: 'DELETE' }), - petInsights: (id) => request({ url: `/api/pets/${id}/insights` }), - weightTrend: (id) => request({ url: `/api/pets/${id}/records/weight-trend` }), - - // 任务 - getTasks: (id, date) => request({ url: `/api/pets/${id}/tasks${date ? `?date=${date}` : ''}` }), - createTask: (id, body) => request({ url: `/api/pets/${id}/tasks`, method: 'POST', data: body }), - updateTask: (taskId, body) => request({ url: `/api/tasks/${taskId}`, method: 'PUT', data: body }), - deleteTask: (taskId) => request({ url: `/api/tasks/${taskId}`, method: 'DELETE' }), - toggleTask: (taskId) => request({ url: `/api/tasks/${taskId}/toggle`, method: 'POST' }), - completeAllTasks: (id) => request({ url: `/api/pets/${id}/tasks/complete-all`, method: 'POST' }), - - // 计划 - getPlan: (id) => request({ url: `/api/pets/${id}/plan` }), - planCalendar: (id, month) => - request({ url: `/api/pets/${id}/plan/calendar${month ? `?month=${month}` : ''}` }), - dayPlan: (id, date) => request({ url: `/api/pets/${id}/day-plan?date=${date}` }), - // AI 相关:用户端入口已全部下掉,后端 10 个路由和配额配置都还在。 - // 想开回来把这几行解注释、再把入口接上就行,不用重写。 - // createAIPlan: (id, input) => request({ url: `/api/pets/${id}/ai-plan`, method: 'POST', data: { input } }), - // applyAIPlan: (planId) => request({ url: `/api/ai-plan/${planId}/apply`, method: 'POST' }), - // 用户自己管理计划节点 - planMonthStatus: (petId) => request({ url: `/api/pets/${petId}/plan/month-status` }), - addPlanTask: (petId, body) => request({ url: `/api/pets/${petId}/plan-tasks`, method: 'POST', data: body }), - updatePlanTask: (taskId, body) => request({ url: `/api/plan-tasks/${taskId}`, method: 'PUT', data: body }), - deletePlanTask: (taskId) => request({ url: `/api/plan-tasks/${taskId}`, method: 'DELETE' }), - togglePlanTask: (taskId) => request({ url: `/api/plan-tasks/${taskId}/toggle`, method: 'POST' }), - - // 提醒 - getReminders: (id) => request({ url: `/api/pets/${id}/reminders` }), - createReminder: (id, body) => request({ url: `/api/pets/${id}/reminders`, method: 'POST', data: body }), - updateReminder: (remId, body) => request({ url: `/api/reminders/${remId}`, method: 'PUT', data: body }), - deleteReminder: (remId) => request({ url: `/api/reminders/${remId}`, method: 'DELETE' }), - - // 报告 - weeklyReport: (id) => request({ url: `/api/pets/${id}/report/weekly` }), - getBill: (id, period) => request({ url: `/api/pets/${id}/bill?period=${period || 'month'}` }), - healthSummary: (id) => request({ url: `/api/pets/${id}/health-summary` }), - getPoster: (id) => request({ url: `/api/pets/${id}/poster` }), - - // 社区 - listPosts: (tab, page) => - request({ url: `/api/posts?tab=${encodeURIComponent(tab || '')}&page=${page || 1}&page_size=10` }), - createPost: (body) => request({ url: '/api/posts', method: 'POST', data: body }), - likePost: (id) => request({ url: `/api/posts/${id}/like`, method: 'POST' }), - userCard: (userId) => request({ url: `/api/users/${userId}/profile` }), - userPosts: (userId, page) => request({ url: `/api/users/${userId}/posts?page=${page || 1}&page_size=10` }), - relations: (userId, kind, page) => - request({ url: `/api/users/${userId}/relations?kind=${kind}&page=${page || 1}&page_size=20` }), - followUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'POST' }), - unfollowUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'DELETE' }), - deletePost: (id) => request({ url: `/api/posts/${id}`, method: 'DELETE' }), - deleteComment: (id) => request({ url: `/api/comments/${id}`, method: 'DELETE' }), - listComments: (id) => request({ url: `/api/posts/${id}/comments` }), - createComment: (id, content, images, parentId) => - request({ - url: `/api/posts/${id}/comments`, - method: 'POST', - data: { content, images: images || [], parent_id: parentId || '' }, - }), - listReplies: (commentId) => request({ url: `/api/comments/${commentId}/replies` }), - - // 文章 - listArticles: () => request({ url: '/api/articles' }), - submitFeedback: (body) => request({ url: '/api/feedback', method: 'POST', data: body }), - getArticle: (id) => request({ url: `/api/articles/${id}` }), - - // Pro - getPro: () => request({ url: '/api/pro' }), - activatePro: () => request({ url: '/api/pro/activate', method: 'POST' }), - - // AI - // aiMessages: (session, limit) => - // request({ url: `/api/ai/messages?session=${session || ''}&limit=${limit || 30}` }), - // aiChat: (body) => request({ url: '/api/ai/chat', method: 'POST', data: body }), - // assessSymptom: (id, body) => - // request({ url: `/api/pets/${id}/ai/assess-symptom`, method: 'POST', data: body }), -}; - -module.exports = api; +function toast(error) { + wx.showToast({ title: error.message || "操作失败", icon: "none" }); +} +function upload(path) { + return new Promise((resolve, reject) => { + const token = getApp().globalData.token; + wx.uploadFile({ + url: baseURL + "/files", + filePath: path, + name: "file", + header: { Authorization: "Bearer " + token }, + success(res) { + try { + const body = JSON.parse(res.data); + if (body.code === 0) resolve(body.data); + else reject(new Error(body.message)); + } catch (e) { + reject(e); + } + }, + fail: reject, + }); + }); +} +module.exports = { request, secure, toast, upload }; diff --git a/pets-fe/utils/config.js b/pets-fe/utils/config.js index afef976..dd901fc 100644 --- a/pets-fe/utils/config.js +++ b/pets-fe/utils/config.js @@ -1,7 +1,4 @@ -// 后端地址。本地联调:微信开发者工具需在「详情 → 本地设置」勾选「不校验合法域名」。 -// 上线时改为你的 https 域名,并在微信公众平台配置 request 合法域名。 -//const BASE_URL = 'http://192.168.31.4:9090'; -const BASE_URL = 'https://pet.sundynix.cn'; +const baseURL = 'http://192.168.100.3:9090/api' +//const baseURL = 'https://pet.sundynix.cn' - -module.exports = { BASE_URL }; +module.exports = { baseURL}; diff --git a/pets-fe/utils/petmeta.js b/pets-fe/utils/petmeta.js deleted file mode 100644 index 3a3c7aa..0000000 --- a/pets-fe/utils/petmeta.js +++ /dev/null @@ -1,45 +0,0 @@ -// 宠物档案里那些「有数据但没露出」的东西。字段后端早就返回了,只是没人用。 -// 单独放一个文件而不是塞在 home.js 里:身份卡、宠友主页以后都要用同一套算法。 - -// 星座。按公历日期分段,边界日取「这一天起算下一个星座」 -const ZODIAC = [ - [1, 20, '水瓶座'], [2, 19, '双鱼座'], [3, 21, '白羊座'], [4, 20, '金牛座'], - [5, 21, '双子座'], [6, 22, '巨蟹座'], [7, 23, '狮子座'], [8, 23, '处女座'], - [9, 23, '天秤座'], [10, 24, '天蝎座'], [11, 23, '射手座'], [12, 22, '摩羯座'], -]; -function zodiac(birthday) { - if (!birthday) return ''; - const p = String(birthday).slice(0, 10).split('-'); - if (p.length < 3) return ''; - const m = +p[1], d = +p[2]; - if (!m || !d) return ''; - // 12 月 22 日之后到 1 月 19 日都是摩羯 - for (let i = 0; i < ZODIAC.length; i++) { - const [zm, zd, name] = ZODIAC[i]; - if (m === zm) return d >= zd ? name : ZODIAC[(i + 11) % 12][2]; - } - return ''; -} - -// 一起生活了多少天。优先用到家日期——「我们一起生活的第 N 天」说的是 -// 它到你家之后的日子,不是它出生之后的日子。没填到家日就退回建档日。 -// 一起生活了多少天,只从「到家日期」算 —— 没填到家日就返回 null(首页显示「-」)。 -// 原来会退回建档日兜底,但那是「在这个 App 上建档的日子」,不是「它到你家的日子」, -// 领养多年后才装 App 的会算出个很小的数,反而误导 -function daysTogether(pet) { - const from = (pet && pet.arrived_at) || ''; - if (!from) return null; - const d = new Date(String(from).slice(0, 10) + 'T00:00:00'); - if (isNaN(d.getTime())) return null; - const days = Math.floor((Date.now() - d.getTime()) / 86400000) + 1; - return days > 0 ? days : null; -} - -// 性别徽章。后端存的是「男孩 / 女孩 / 不确定」 -function genderBadge(gender) { - if (gender === '男孩') return { icon: '♂', tone: 'male' }; - if (gender === '女孩') return { icon: '♀', tone: 'female' }; - return { icon: '', tone: '' }; -} - -module.exports = { zodiac, daysTogether, genderBadge }; diff --git a/pets-fe/utils/recordTypes.js b/pets-fe/utils/recordTypes.js deleted file mode 100644 index e0c16a9..0000000 --- a/pets-fe/utils/recordTypes.js +++ /dev/null @@ -1,78 +0,0 @@ -// 记录类型缓存。类型从后端来(后台可配),记录页要拿它渲染分组宫格, -// 弹层要拿它判断走专用表单还是通用简易表单。两边必须是同一份数据—— -// 各自请求一次的话,后台改完配置两边会短暂不一致。 -const api = require('./api.js'); - -// 四个分组对应的色系。这是纯表现层的东西,不进后端配置: -// 后端存 group key,色值留在前端,改配色不用动数据 -const GROUP_TONE = { - daily: 'tone-1', - health: 'tone-2', - care: 'tone-4', - clean: 'tone-3', -}; - -let groups = null; // [{ key, label, items: [...] }] -let byCode = {}; -let loading = null; - -function index(gs) { - groups = (gs || []).map((g) => ({ - ...g, - tone: GROUP_TONE[g.key] || 'tone-1', - items: (g.items || []).map((t) => ({ ...t, tone: GROUP_TONE[g.key] || 'tone-1' })), - })); - byCode = {}; - groups.forEach((g) => g.items.forEach((t) => (byCode[t.code] = t))); - return groups; -} - -// load 只真正请求一次;并发调用共用同一个 promise -function load() { - if (groups) return Promise.resolve(groups); - if (loading) return loading; - loading = api - .recordTypes() - .then((gs) => { - loading = null; - return index(gs); - }) - .catch((e) => { - loading = null; - throw e; - }); - return loading; -} - -// get 是同步的,给弹层用——弹层打开时不该再等一次网络请求。 -// 记录页/首页先 load 过,缓存已经热了;万一没热,返回 undefined, -// 调用方按「专用表单」处理(那 9 种的分支是写死在 wxml 里的,不依赖这份缓存) -function get(code) { - return byCode[code]; -} - -// isSimple 走通用简易表单的类型。缓存没热时返回 false, -// 宁可打不开也不要用错的表单存下一条字段不全的记录 -function isSimple(code) { - const t = byCode[code]; - return !!t && t.form === 'simple'; -} - -function label(code) { - const t = byCode[code]; - return (t && t.label) || ''; -} - -// 后台改了配置之后强制重取 -function reset() { - groups = null; - byCode = {}; - loading = null; -} - -// groups 同步读缓存,给「任务关联记录类型」那个下拉用 -function all() { - return groups; -} - -module.exports = { load, get, isSimple, label, reset, groups: all, GROUP_TONE }; diff --git a/pets-fe/utils/request.js b/pets-fe/utils/request.js deleted file mode 100644 index 4a62872..0000000 --- a/pets-fe/utils/request.js +++ /dev/null @@ -1,167 +0,0 @@ -const { BASE_URL } = require('./config.js'); - -const TOKEN_KEY = 'pets_token'; -const REFRESH_KEY = 'pets_refresh_token'; - -function getToken() { - return wx.getStorageSync(TOKEN_KEY) || ''; -} -function setToken(t) { - wx.setStorageSync(TOKEN_KEY, t); -} -function getRefreshToken() { - return wx.getStorageSync(REFRESH_KEY) || ''; -} -function setRefreshToken(t) { - if (t) wx.setStorageSync(REFRESH_KEY, t); -} -function clearToken() { - wx.removeStorageSync(TOKEN_KEY); - wx.removeStorageSync(REFRESH_KEY); -} - -// token 失效时的重新登录钩子,由 store 注册(放在这里避免 request ← api ← store 循环依赖) -let reAuth = null; -function setReAuthHandler(fn) { - reAuth = fn; -} - -const TIMEOUT = 15000; - -// access token 只有 2 小时,过期后先拿 refresh token 换新的(用户无感), -// 换不到(刷新令牌也过期/被吊销)才退回 wx.login 重新登录。 -// 单飞:并发请求同时 401 时只发一次刷新,否则刷新令牌会被并发轮换掉。 -let refreshing = null; - -function refreshAccessToken() { - if (!refreshing) { - const rt = getRefreshToken(); - refreshing = (rt - ? request({ url: '/api/auth/refresh', method: 'POST', data: { refresh_token: rt }, noAuthRetry: true }) - : Promise.reject(new Error('没有刷新令牌')) - ).then((data) => { - setToken(data.token); - setRefreshToken(data.refresh_token); - return data.token; - }); - const done = () => { - refreshing = null; - }; - refreshing.then(done, done); - } - return refreshing; -} - -// 恢复登录态:先续期,续不了就整套重新登录 -function recoverAuth() { - return refreshAccessToken().catch(() => { - clearToken(); - if (!reAuth) throw new Error('登录已过期'); - return reAuth(); - }); -} - -// 统一请求:注入 token,解析 {code,message,data},成功返回 data,失败 reject(Error)。 -// token 失效(40100)时自动续期/重新登录并重试一次,避免 JWT 过期后小程序静默白屏。 -function request(options, _retried) { - const { url, method = 'GET', data, header = {}, noAuthRetry } = options; - return new Promise((resolve, reject) => { - const token = getToken(); - wx.request({ - url: BASE_URL + url, - method, - data, - timeout: TIMEOUT, - header: Object.assign( - { 'Content-Type': 'application/json' }, - token ? { Authorization: 'Bearer ' + token } : {}, - header, - ), - success(res) { - const body = res.data; - if (body && typeof body === 'object' && 'code' in body) { - if (body.code === 0) return resolve(body.data); - if (body.code === 40100) { - // 只重试一次,防止刷新/登录接口本身出问题时无限循环 - if (_retried || noAuthRetry) { - clearToken(); - return reject(new Error(body.message || '登录已过期,请重进小程序')); - } - return recoverAuth() - .then(() => request(options, true).then(resolve, reject)) - .catch(() => { - clearToken(); - reject(new Error(body.message || '登录已过期,请重进小程序')); - }); - } - return reject(new Error(body.message || '请求失败')); - } - resolve(body); - }, - fail(err) { - const msg = (err && err.errMsg) || '网络错误'; - reject(new Error(/timeout/i.test(msg) ? '网络超时,请稍后重试' : msg)); - }, - }); - }); -} - -// 文件上传(multipart)。三重兜底,应对「第一次报错、重试就好」的偶发: -// 1) 上传前若还没 token(冷启动竞态),先续期拿到再传; -// 2) token 过期(40100)→ 续期后重试; -// 3) 网络 fail / 网关 5xx(响应非 JSON)等瞬时错误 → 自动重试一次。 -// 后端按 MD5 去重,重复上传同一张图返回同一条记录,重试是安全的。 -function uploadFile(filePath, _retried) { - return new Promise((resolve, reject) => { - // 瞬时错误:还没重试过就延后再传一次,否则直接失败 - const softRetry = (err) => { - if (_retried) return reject(err); - setTimeout(() => uploadFile(filePath, true).then(resolve, reject), 500); - }; - const fire = () => { - const token = getToken(); - wx.uploadFile({ - url: BASE_URL + '/api/upload', - filePath, - name: 'file', - timeout: 60000, - header: token ? { Authorization: 'Bearer ' + token } : {}, - success(res) { - let body; - try { - body = JSON.parse(res.data); - } catch (e) { - // 非 JSON 多为网关 5xx / 服务重启窗口,按瞬时错误重试 - return softRetry(new Error('上传失败,请重试')); - } - if (body.code === 0) return resolve(body.data); - if (body.code === 40100 && !_retried) { - return recoverAuth() - .then(() => uploadFile(filePath, true).then(resolve, reject)) - .catch(() => reject(new Error('登录已过期,请重进小程序'))); - } - reject(new Error(body.message || '上传失败')); - }, - fail(err) { - softRetry(new Error((err && err.errMsg) || '上传失败')); - }, - }); - }; - // 冷启动竞态:点得太快、登录还没完成就上传,先把 token 拿到再传 - if (!getToken() && !_retried) { - return recoverAuth().then(fire, fire); - } - fire(); - }); -} - -module.exports = { - request, - uploadFile, - getToken, - setToken, - getRefreshToken, - setRefreshToken, - clearToken, - setReAuthHandler, -}; diff --git a/pets-fe/utils/store.js b/pets-fe/utils/store.js deleted file mode 100644 index 90037c1..0000000 --- a/pets-fe/utils/store.js +++ /dev/null @@ -1,107 +0,0 @@ -// 全局状态:登录、宠物列表、当前宠物;服务端驱动 + 发布订阅 -const api = require('./api.js'); -const { setReAuthHandler } = require('./request.js'); - -const state = { - user: null, - pets: [], - currentId: 0, -}; - -const listeners = new Set(); -let bootstrapPromise = null; - -function getPets() { - return state.pets; -} -function getPet() { - return state.pets.find((p) => p.id === state.currentId) || state.pets[0] || {}; -} -function currentPetId() { - const p = getPet(); - return p && p.id; -} - -function notify() { - const pet = getPet(); - listeners.forEach((fn) => { - try { - fn(pet); - } catch (e) { - /* noop */ - } - }); -} - -function subscribe(fn) { - listeners.add(fn); - return () => listeners.delete(fn); -} - -// 登录 + 拉宠物列表(只跑一次) -// token 失效时清掉引导缓存并重新登录一次,让 request 层能自动重试 -setReAuthHandler(async () => { - bootstrapPromise = null; - await ready(); -}); - -function ready() { - if (!bootstrapPromise) { - bootstrapPromise = (async () => { - const res = await api.login(); - state.user = res.user; - await loadPets(); - })().catch((e) => { - bootstrapPromise = null; // 允许重试 - throw e; - }); - } - return bootstrapPromise; -} - -async function loadPets() { - const pets = await api.getPets(); - state.pets = pets || []; - if (!state.currentId && state.pets[0]) { - state.currentId = state.pets[0].id; - } - // 当前宠物被删或不存在时回退第一只 - if (!state.pets.find((p) => p.id === state.currentId) && state.pets[0]) { - state.currentId = state.pets[0].id; - } - notify(); - return state.pets; -} - -function switchPet(id) { - if (state.currentId === id) return; - state.currentId = id; - notify(); -} - -// 本地更新当前宠物字段(服务端已改动后同步 UI) -function patchCurrent(patch) { - const pet = getPet(); - Object.assign(pet, patch); - notify(); -} - -function setUser(u) { - state.user = u; -} -function getUser() { - return state.user; -} - -module.exports = { - ready, - loadPets, - getPets, - getPet, - currentPetId, - switchPet, - patchCurrent, - subscribe, - setUser, - getUser, -}; diff --git a/pets-fe/utils/tabbar.js b/pets-fe/utils/tabbar.js deleted file mode 100644 index 6bea258..0000000 --- a/pets-fe/utils/tabbar.js +++ /dev/null @@ -1,14 +0,0 @@ -// tabBar 高亮同步。 -// -// 本来想让 custom-tab-bar 自己在 pageLifetimes.show 里算,页面一行都不用写。 -// 实测不行:自定义 tabBar 不在页面节点树里,pageLifetimes 拿不到可靠的时机, -// 结果是切到「我的」时高亮还留在「首页」。只能回到官方那套「页面 onShow 里推一把」。 -// -// 但索引仍然由 tabBar 按当前路由自己算,页面这边不写死 0/1/2/3/4—— -// 调 tab 顺序时改一个 LIST 就够了,五个页面一行都不用动。 -function syncTabBar(page) { - const bar = page.getTabBar && page.getTabBar(); - if (bar && bar.syncSelected) bar.syncSelected(); -} - -module.exports = { syncTabBar }; diff --git a/pets-fe/utils/ui.js b/pets-fe/utils/ui.js deleted file mode 100644 index 3793767..0000000 --- a/pets-fe/utils/ui.js +++ /dev/null @@ -1,15 +0,0 @@ -// 轻量 UI 提示:统一错误反馈,避免请求失败时页面一片空白、用户不知道发生了什么。 -let lastMsg = ''; -let lastAt = 0; - -// 提示错误。相同文案 3 秒内不重复弹,避免多个并发请求同时失败时刷屏。 -function toastErr(e, fallback) { - const msg = (e && e.message) || fallback || '加载失败'; - const now = Date.now(); - if (msg === lastMsg && now - lastAt < 3000) return; - lastMsg = msg; - lastAt = now; - wx.showToast({ title: msg, icon: 'none', duration: 2000 }); -} - -module.exports = { toastErr }; diff --git a/pets-fe/utils/upload.js b/pets-fe/utils/upload.js deleted file mode 100644 index fd7f274..0000000 --- a/pets-fe/utils/upload.js +++ /dev/null @@ -1,55 +0,0 @@ -// 公共图片上传:选图(仅图片)→ 上传后端(/api/upload,MD5 去重)→ 返回 {id,url,md5} -const { uploadFile } = require('./request.js'); - -const IMG_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif']; - -// 仅允许图片:按扩展名二次校验(chooseMedia mediaType 已限制,这里双保险) -function isImagePath(p) { - const m = /\.([a-zA-Z0-9]+)(\?.*)?$/.exec(p || ''); - return !!m && IMG_EXT.indexOf(m[1].toLowerCase()) >= 0; -} - -// 选择并上传图片,返回 Promise<[{id,url,md5}]>。用户取消时 reject({canceled:true})。 -function chooseAndUploadImages(count) { - count = count || 1; - return new Promise((resolve, reject) => { - wx.chooseMedia({ - count, - mediaType: ['image'], // 仅图片,不允许视频/文件 - sizeType: ['compressed'], - sourceType: ['album', 'camera'], - success(res) { - const files = (res.tempFiles || []).filter((f) => isImagePath(f.tempFilePath)); - if (!files.length) { - reject(new Error('只能上传图片文件')); - return; - } - wx.showLoading({ title: '上传中...', mask: true }); - Promise.all(files.map((f) => uploadFile(f.tempFilePath))) - .then((list) => { - wx.hideLoading(); - resolve(list); - }) - .catch((e) => { - wx.hideLoading(); - reject(e); - }); - }, - fail(err) { - const msg = (err && err.errMsg) || ''; - if (/cancel/i.test(msg)) { - reject({ canceled: true }); - return; - } - reject(new Error(msg || '选择图片失败')); - }, - }); - }); -} - -// 单张图片,返回 Promise<{id,url,md5}> -function chooseAndUploadImage() { - return chooseAndUploadImages(1).then((list) => list[0]); -} - -module.exports = { chooseAndUploadImages, chooseAndUploadImage, isImagePath }; diff --git a/小程序资料.md b/小程序资料.md index 8e3a658..5ddb8f2 100644 --- a/小程序资料.md +++ b/小程序资料.md @@ -1,71 +1,10 @@ -# 肉垫计划 · 小程序后台资料 +# 肉垫计划 · 小程序资料 -## 一、基础信息 +- 名称:肉垫计划 +- 头像:`pets-fe/assets/logo-corgi-v2.png` +- 简介:为养狗家庭记录每次遛狗,和朋友约遛,主动分享遛狗位置,并管理多只宠物的喂食、喝水等日常任务和档案。 +- 业务域名:`pet.sundynix.cn` +- 图片域名:`s3.sundynix.cn` +- 小程序页面:首页、约遛、遛狗、任务、宠物档案。 -- **名称**:肉垫计划 -- **头像**:`pets-fe/assets/logo-512.png`(512×512 透明 PNG) -- **英文/副标**:PAW PAD · CARE PLAN - ---- - -## 二、小程序简介(限 4–120 字,选一个填) - -### 主推版(约 115 字,信息最全) - -> 肉垫计划是为新手养宠人打造的记录与提醒工具。为猫狗建立健康档案,按品类和成长阶段生成每日照护任务与三十天养护计划;随手记录体重、饮食、排便、用药、疫苗与开支,自动生成体重趋势和成长报告;疫苗驱虫到期自动提醒。还可在宠友圈交流养宠经验。 - -### 精简版(约 70 字) - -> 肉垫计划是新手养宠的记录与提醒工具。为猫狗建立健康档案,生成每日照护任务和养护计划,记录体重、饮食、排便、疫苗与开支,并在疫苗驱虫到期前提醒你。 - -### 极简版(约 40 字) - -> 面向新手养宠人的猫狗照护记录工具:每日任务、健康记录、体重趋势、疫苗驱虫提醒。 - ---- - -## 三、一句话介绍(分享标题 / 宣传语备用) - -- 新手养宠,从每天一件小事开始 -- 猫狗的每日照护清单和健康档案 -- 记录它的每一天,别漏掉每一针 - ---- - -## 四、功能点(填「服务内容」或做详情页时用) - -1. **建档** —— 按猫/狗和成长阶段(刚到家 / 幼年 / 成年 / 老年)建立专属档案 -2. **每日任务** —— 自动生成当天照护清单,可自行增删改,未完成自动顺延 -3. **养护计划** —— 三十天路线图 + 日历视图,到期自动续期 -4. **快速记录** —— 体重、便便、饮食、异常、用药、疫苗、消费、照片 -5. **体重趋势** —— 自动生成曲线,点任意节点查看当次数据与备注 -6. **到期提醒** —— 疫苗、驱虫、称重、月度报告 -7. **成长报告** —— 周报、月度账单、健康摘要 -8. **宠友圈** —— 发布图文、点赞评论、经验交流 - ---- - -## 五、关键词建议(后台可设,影响搜索) - -养宠 · 宠物 · 猫 · 狗 · 宠物记录 · 养猫 · 养狗 · 疫苗提醒 · 宠物health档案 · 新手养宠 - -> 优先保证「养宠」「宠物记录」「疫苗提醒」这几个真实用户会搜的词。 - ---- - -## 六、服务类目建议 - -- 主类目:**工具 → 效率**(记录/提醒工具,审核最稳) -- 或:**生活服务 → 宠物**(若平台开放该类目) -- ⚠️ 社区(发帖/评论)属于 UGC,通常需要单独选「社交 → 社区」类目,**个人主体一般无法开通**。若你是个人主体,建议首版先隐藏社区入口,等企业主体资质下来再开。 - ---- - -## 七、审核避雷(重要) - -1. **绝不能出现医疗表述**:诊断、治疗、确诊、疗效、替代就医。 - 本项目的 AI 只做「观察建议 + 就医提示」,简介和页面文案统一用**记录、观察、提醒、建议**这类词。 -2. **不能用极限词**:最好、第一、唯一、领先、国内首个。 -3. **不能承诺效果**:如「保证宠物健康」「不再生病」。 -4. **AI 相关**:如涉及大模型对话功能,部分类目需补充说明,简介里描述为「养宠知识问答/观察建议」较稳妥。 -5. 简介里不要放联系方式、网址、二维码。 +小程序的合法 request 域名需包含业务域名;图片下载域名需包含图片域名。使用位置功能前,需要在微信小程序后台完成隐私信息声明与相关权限配置。