Merge pull request 'feat(auth): access token 缩到 2 小时 + refresh token 机制' (#3) from feat/dev into main
deploy / deploy (push) Successful in 1m59s

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-07-30 10:18:07 +00:00
190 changed files with 11768 additions and 2955 deletions
+5 -1
View File
@@ -32,7 +32,11 @@ MINIO_PUBLIC_BASE_URL=https://res.sundynix.cn
# ── 登录鉴权 ──
JWT_SECRET=改成随机长字符串
JWT_EXPIRE_HOURS=168
# 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
+4 -1
View File
@@ -38,7 +38,10 @@ services:
PETS_MINIO_PUBLIC_BASE_URL: ${MINIO_PUBLIC_BASE_URL:?}
PETS_JWT_SECRET: ${JWT_SECRET:?}
PETS_JWT_EXPIRE_HOURS: ${JWT_EXPIRE_HOURS:-168}
# 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:?}
+15
View File
@@ -47,3 +47,18 @@ make admin-dev # vite dev server :5173,已代理 /api → :8080
## 登录鉴权
默认 `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` 两个令牌:
- **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` 作废该刷新令牌。
安全约定:
- **每次续期都轮换**刷新令牌,旧的立即作废;作废后 60 秒内再到达算并发重试(放行),超过则判定泄露,**吊销该账号全部会话**。
- 主动退出、被连坐吊销的令牌不享受宽限期。
- 禁用用户时一并吊销其刷新令牌,最多 2 小时后彻底失去访问。
前端两端都做了单飞续期:并发请求同时 401 只会发一次 refresh(否则刷新令牌会被并发轮换掉)。小程序续期失败会回退到 `wx.login` 重新登录。
-3
View File
@@ -40,9 +40,6 @@ func main() {
log.Printf("AI provider: %s", engine.ProviderName())
svc := service.New(db, st, cfg, engine)
if err := svc.SeedCareTemplates(); err != nil {
log.Fatalf("seed care templates: %v", err)
}
if err := svc.SeedCommunityBot(); err != nil {
log.Fatalf("seed community bot: %v", err)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title>
<script type="module" crossorigin src="/admin/assets/index-BmWfHKYa.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BjLxu16U.css">
<script type="module" crossorigin src="/admin/assets/index-BH08rQ_o.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-PPcOOdtF.css">
</head>
<body>
<div id="root"></div>
+9 -3
View File
@@ -64,9 +64,13 @@ type MinIOConfig struct {
PublicBaseURL string `mapstructure:"public_base_url"`
}
// JWTConfig access token 短、refresh token 长:
// access 过期由前端拿 refresh 静默续期,refresh 每次使用都轮换。
type JWTConfig struct {
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"`
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"` // access token 有效期
RefreshExpireHours int `mapstructure:"refresh_expire_hours"` // 小程序用户刷新令牌有效期
AdminRefreshExpireHours int `mapstructure:"admin_refresh_expire_hours"` // 后台刷新令牌有效期(更短)
}
type WeChatConfig struct {
@@ -105,7 +109,9 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("minio.public_base_url", "")
v.SetDefault("jwt.secret", "")
v.SetDefault("jwt.expire_hours", 168)
v.SetDefault("jwt.expire_hours", 2)
v.SetDefault("jwt.refresh_expire_hours", 720) // 30 天
v.SetDefault("jwt.admin_refresh_expire_hours", 168)
v.SetDefault("wechat.app_id", "")
v.SetDefault("wechat.app_secret", "")
+233
View File
@@ -0,0 +1,233 @@
package database
import (
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// 养护方案预置:5 个阶段 × 2 物种,各一套「标准照护」。
//
// 内容按各阶段的真实生理重点配,不是把同一份东西换个名字:
// 幼年期 疫苗序列 + 驱虫 + 社会化,大型犬还要控制生长速度
// 青年期 绝育窗口 + 换成年粮
// 成年期 维持期,重点是把基线数据记下来(体重趋势、年度体检)
// 中年期 年度血检开始有意义,体重和牙结石重点看
// 老年期 体检频率加倍,猫看肾和甲状腺、狗看关节和心脏
//
// 和 record_types / breeds 一样只在表为空时铺一次,之后一切以数据库为准。
//
// 这些是「结构」不是「内容」:方案表空着的话建档最后一步没东西可挑,
// 用户建完档只能自己从零排计划。
func plan(title, desc, repeat, sheet string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindPlan, Repeat: repeat,
Title: title, Description: desc, SheetType: sheet}
}
func weekly(title, desc, weekdays, sheet string) model.CareTemplateItem {
it := plan(title, desc, model.RepeatWeekly, sheet)
it.Weekdays = weekdays
return it
}
func monthly(title, desc string, day int, sheet string) model.CareTemplateItem {
it := plan(title, desc, model.RepeatMonthly, sheet)
it.MonthDay = day
return it
}
func once(title, desc string, day int, sheet string) model.CareTemplateItem {
it := plan(title, desc, model.RepeatOnce, sheet)
it.Day = day
return it
}
func remind(rtype, title string, offset int, freq string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindReminder, ReminderType: rtype,
Title: title, OffsetDays: offset, Frequency: freq}
}
func important(it model.CareTemplateItem) model.CareTemplateItem {
it.Priority = "重要"
return it
}
// TplDef 一套方案的定义(迁移程序要读它)
type tplDef struct {
Species string
Stage string
Name string
Summary string
Items []model.CareTemplateItem
}
// CareTemplateDefs 导出给一次性迁移程序用
func CareTemplateDefs() []tplDef { return careTemplateDefs() }
// SeedCareTemplatesForce 不检查表空,直接铺。只给迁移程序用
func SeedCareTemplatesForce(db *gorm.DB) error { return insertCareTemplates(db) }
func careTemplateDefs() []tplDef {
const (
J = "幼年期"
Y = "青年期"
A = "成年期"
M = "中年期"
S = "老年期"
)
return []tplDef{
// ───────────────── 猫 ─────────────────
{model.SpeciesCat, J, "幼猫标准照护", "少量多餐、疫苗序列和社会化,六月龄前把基础打好", []model.CareTemplateItem{
important(plan("喂食 3-4 次", "幼猫胃容量小,一天分 3-4 顿,用幼猫粮", model.RepeatDaily, "food")),
plan("清理猫砂盆", "每天铲一次,顺手看便便形状和颜色", model.RepeatDaily, "litterbox"),
plan("陪玩 15 分钟", "逗猫棒模拟狩猎,消耗精力也是社会化", model.RepeatDaily, ""),
weekly("称一次体重", "幼猫每周都在长,体重曲线比单次数值有用", "1", "weight"),
weekly("检查耳朵和眼睛", "幼猫耳螨和眼部分泌物高发", "4", "ear"),
once("核对疫苗进度", "对照月龄看第几针,三针核心疫苗要在六月龄前打完", 2, "vaccine"),
once("首次体内驱虫", "幼猫驱虫比成猫频繁,按体重给药", 5, "deworm"),
remind("vaccine", "下一针疫苗", 21, "幼猫期每 3-4 周一针,共三针"),
remind("deworm", "体内外驱虫", 30, "幼猫每月一次"),
remind("weight", "记录体重", 7, "每周一次"),
}},
{model.SpeciesCat, Y, "青年猫标准照护", "绝育窗口、换成猫粮,开始把体重控住", []model.CareTemplateItem{
plan("喂食 2 次", "六月龄后可以改成早晚两顿", model.RepeatDaily, "food"),
plan("清理猫砂盆", "每天铲一次", model.RepeatDaily, "litterbox"),
plan("陪玩 15 分钟", "性成熟后精力旺盛,玩够了才不拆家", model.RepeatDaily, ""),
weekly("称一次体重", "绝育前后代谢会变,体重要盯紧", "1", "weight"),
weekly("梳毛", "换毛期减少毛球", "4", "brush"),
once("咨询绝育时间", "多数猫 6-8 月龄可做,术前要空腹和体检", 3, "vet"),
once("换成猫粮", "一岁前后用 7 天时间按比例过渡,别一次换完", 14, "food"),
remind("vaccine", "年度加强疫苗", 60, "一年一次"),
remind("deworm", "体内外驱虫", 60, "每 2-3 个月一次"),
}},
{model.SpeciesCat, A, "成年猫标准照护", "维持期,重点是把基线数据记下来", []model.CareTemplateItem{
plan("喂食和换水", "定时定量,水每天换", model.RepeatDaily, "food"),
plan("清理猫砂盆", "每天铲一次,便便异常是最早的信号", model.RepeatDaily, "litterbox"),
weekly("梳毛", "长毛猫每天,短毛猫每周两次", "2,5", "brush"),
weekly("刷牙", "牙结石是成年猫最常被忽略的问题", "6", "tooth"),
monthly("称一次体重", "成年后体重该是平的,涨跌超过 5% 要留意", 1, "weight"),
monthly("洗猫砂盆", "整盆倒空清洗一次", 15, "litterbox"),
once("安排年度体检", "成年期一年一次,做基础血检建立基线", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
{model.SpeciesCat, M, "中年猫标准照护", "七岁起代谢变慢,年度血检开始能查出早期问题", []model.CareTemplateItem{
plan("喂食和换水", "中年后容易发胖,按体重控制热量", model.RepeatDaily, "food"),
plan("清理猫砂盆", "尿量和尿块大小要留意,肾病最早从这里看出来", model.RepeatDaily, "litterbox"),
weekly("梳毛", "自我梳理会减少,需要帮忙", "2,5", "brush"),
weekly("刷牙", "牙结石到这个年龄已经开始影响进食", "6", "tooth"),
monthly("称一次体重", "不明原因掉体重是中年猫要警惕的信号", 1, "weight"),
monthly("检查口腔", "看牙龈颜色和有没有结石", 15, ""),
once("安排年度体检", "血检 + 尿检,重点看肾功能和甲状腺", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("weight", "记录体重", 30, "每月一次"),
}},
{model.SpeciesCat, S, "老年猫标准照护", "体检半年一次,肾和甲状腺是重点", []model.CareTemplateItem{
important(plan("记录饮水量", "多饮多尿是慢性肾病最典型的早期表现", model.RepeatDaily, "water")),
plan("喂食", "老年猫可能需要处方粮,少量多次更好消化", model.RepeatDaily, "food"),
plan("清理猫砂盆", "尿块变大或变多要立刻记下来", model.RepeatDaily, "litterbox"),
weekly("梳毛", "老年猫自己舔不到的地方要帮它梳", "2,5", "brush"),
weekly("称一次体重", "老年期体重变化要按周盯,不是按月", "1", "weight"),
monthly("检查关节活动", "跳不上原来能跳的地方就是信号", 1, ""),
once("安排半年体检", "血检 + 尿检 + 甲状腺,老年期半年一次", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次(可与医生商量减免)"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
// ───────────────── 狗 ─────────────────
{model.SpeciesDog, J, "幼犬标准照护", "疫苗序列、社会化,大型犬还要控制生长速度", []model.CareTemplateItem{
important(plan("喂食 3-4 次", "幼犬一天分 3-4 顿;大型犬别过量补钙,长太快伤关节", model.RepeatDaily, "food")),
plan("外出排便和散步", "疫苗打齐前避免去流浪动物多的地方", model.RepeatDaily, ""),
plan("基础训练 10 分钟", "坐、等待、唤回。幼犬期是社会化窗口,错过很难补", model.RepeatDaily, ""),
weekly("称一次体重", "幼犬长得快,体重曲线也是喂食量的依据", "1", "weight"),
weekly("检查爪垫和耳朵", "散步后看有没有扎伤,垂耳犬要重点看耳道", "4", "ear"),
once("核对疫苗进度", "对照月龄看第几针,狂犬疫苗按当地规定单独安排", 2, "vaccine"),
once("首次体内驱虫", "按体重给药,幼犬比成犬频繁", 5, "deworm"),
remind("vaccine", "下一针疫苗", 21, "幼犬期每 3-4 周一针"),
remind("deworm", "体内外驱虫", 30, "幼犬每月一次"),
remind("weight", "记录体重", 7, "每周一次"),
}},
{model.SpeciesDog, Y, "青年犬标准照护", "骨骼刚闭合,可以加运动量了;绝育窗口也在这里", []model.CareTemplateItem{
plan("喂食 2 次", "改成早晚两顿", model.RepeatDaily, "food"),
plan("散步 30 分钟以上", "骨骼闭合后可以正常运动,之前不宜长距离跑跳", model.RepeatDaily, ""),
plan("训练 10 分钟", "把幼犬期学的固化下来", model.RepeatDaily, ""),
weekly("称一次体重", "换成犬粮期间容易长胖", "1", "weight"),
weekly("梳毛", "换毛期每天,平时每周两次", "2,5", "brush"),
once("咨询绝育时间", "大型犬建议等骨骼闭合后再做", 3, "vet"),
once("换成犬粮", "用 7 天按比例过渡,别一次换完", 14, "food"),
remind("vaccine", "年度加强疫苗", 60, "一年一次"),
remind("deworm", "体内外驱虫", 60, "每 2-3 个月一次"),
}},
{model.SpeciesDog, A, "成年犬标准照护", "维持期,重点是把基线数据记下来", []model.CareTemplateItem{
plan("喂食和换水", "定时定量,水每天换", model.RepeatDaily, "food"),
plan("散步 30-60 分钟", "运动量不够是成年犬发胖和行为问题的主因", model.RepeatDaily, ""),
weekly("梳毛", "顺手摸一遍有没有肿块", "2,5", "brush"),
weekly("刷牙", "牙结石是成年犬最常被忽略的问题", "6", "tooth"),
monthly("称一次体重", "成年后体重该是平的,涨跌超过 5% 要留意", 1, "weight"),
monthly("剪指甲", "听到走路哒哒响就该剪了", 15, "nail"),
once("安排年度体检", "一年一次,做基础血检建立基线", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
{model.SpeciesDog, M, "中年犬标准照护", "体重和关节是重点,年度血检开始有意义", []model.CareTemplateItem{
plan("喂食和换水", "代谢变慢,按体重下调热量", model.RepeatDaily, "food"),
plan("散步 30 分钟", "保持规律但降低强度,避免急停急转", model.RepeatDaily, ""),
weekly("梳毛并摸一遍身体", "中年后肿块发生率上升,每周摸一遍是最便宜的筛查", "2,5", "brush"),
weekly("刷牙", "牙结石到这个年龄已经开始影响进食", "6", "tooth"),
monthly("称一次体重", "中年犬超重会直接加重关节负担", 1, "weight"),
monthly("检查关节活动", "起身变慢、不愿上楼就是信号", 15, ""),
once("安排年度体检", "血检 + 关节检查,大型犬加拍髋关节", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("weight", "记录体重", 30, "每月一次"),
}},
{model.SpeciesDog, S, "老年犬标准照护", "体检半年一次,关节和心脏是重点", []model.CareTemplateItem{
important(plan("记录精神和食欲", "老年犬变化来得快,每天一句话记下来", model.RepeatDaily, "")),
plan("喂食", "老年犬可能需要处方粮,少量多次更好消化", model.RepeatDaily, "food"),
plan("散步 15-20 分钟", "短而多次,比一次走久对关节友好", model.RepeatDaily, ""),
weekly("梳毛并摸一遍身体", "重点摸有没有新长的肿块", "2,5", "brush"),
weekly("称一次体重", "老年期体重变化要按周盯", "1", "weight"),
monthly("检查关节和牙齿", "起身、上楼、咀嚼有没有变吃力", 1, ""),
once("安排半年体检", "血检 + 心脏听诊 + 关节,老年期半年一次", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次(可与医生商量减免)"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
}
}
// seedCareTemplates 只在方案表为空时铺一次
func seedCareTemplates(db *gorm.DB) error {
var count int64
if err := db.Model(&model.CareTemplate{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
return insertCareTemplates(db)
}
func insertCareTemplates(db *gorm.DB) error {
for i, d := range careTemplateDefs() {
tpl := model.CareTemplate{
Species: d.Species, Stage: d.Stage, Name: d.Name,
Summary: d.Summary, Sort: (i + 1) * 10, Enabled: true,
}
if err := db.Create(&tpl).Error; err != nil {
return err
}
items := make([]model.CareTemplateItem, 0, len(d.Items))
for j, it := range d.Items {
it.TemplateID = tpl.ID
it.Sort = j
items = append(items, it)
}
if len(items) > 0 {
if err := db.Create(&items).Error; err != nil {
return err
}
}
}
return nil
}
+4
View File
@@ -25,6 +25,10 @@ func New(cfg *config.Config) (*gorm.DB, error) {
TablePrefix: "sundynix_",
SingularTable: false,
},
// 关联在 Go 层管,不靠库级外键。迁移期建外键时 MySQL 会为外键列自动
// 补一个索引,和字段上的 `index` tag 撞成同名索引,父子表同迁时第二次
// 建索引报 1061 duplicate。关掉外键约束创建,索引就只由 index tag 建一次
DisableForeignKeyConstraintWhenMigrating: true,
Logger: logger.Default.LogMode(logLevel),
})
if err != nil {
+188 -161
View File
@@ -8,16 +8,200 @@ import (
"github.com/sundynix/pets-be/internal/model"
)
// Seed 初始化种子数据:管理员账号 + 新手文章
// Seed 初始化种子数据:管理员账号 + 记录类型。
//
// 文章、养护模板这类**内容**数据不放代码里——正文几百字塞进 Go 源文件,既看不清
// 也没法在后台改完再同步回来。上线时直接同步数据库。
//
// 记录类型是另一码事,它是**结构**:表空着的话记录页一个可记事项都没有,
// App 直接不能用。所以和管理员账号一样,只在表完全为空时铺一次底,
// 之后一切以数据库为准,代码里这份不再参与。
func Seed(db *gorm.DB, cfg *config.Config) error {
if err := seedAdmin(db, cfg); err != nil {
return err
}
if err := seedArticles(db); err != nil {
if err := seedRecordTypes(db); err != nil {
return err
}
// 早期版本 seed 出来的文章没有正文,这里补上(不覆盖已在后台编辑过的)
return backfillArticleContent(db)
if err := seedBreeds(db); err != nil {
return err
}
return seedCareTemplates(db)
}
// seedBreeds 品种预置。同 record_types:只在表完全为空时铺一次。
//
// 这也是「结构」不是「内容」——品种列表空着,建档第二步就选不了品种。
// 首字母存字段不运行时算:Go 没有标准拼音库,而多音字(藏獒、柴犬)
// 自动转常出错,人来定比库来猜靠谱。
func seedBreeds(db *gorm.DB) error {
var count int64
if err := db.Model(&model.Breed{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
breeds := []model.Breed{
// 猫 34 种
{Species: model.SpeciesCat, Initial: "A", Name: "阿比西尼亚猫", Sort: 10},
{Species: model.SpeciesCat, Initial: "A", Name: "奥西猫", Sort: 20},
{Species: model.SpeciesCat, Initial: "B", Name: "巴厘猫", Sort: 30},
{Species: model.SpeciesCat, Initial: "B", Name: "波斯猫", Sort: 40},
{Species: model.SpeciesCat, Initial: "B", Name: "布偶猫", Sort: 50},
{Species: model.SpeciesCat, Initial: "B", Name: "伯曼猫", Sort: 60},
{Species: model.SpeciesCat, Initial: "C", Name: "长毛折耳猫", Sort: 70},
{Species: model.SpeciesCat, Initial: "D", Name: "德文卷毛猫", Sort: 80},
{Species: model.SpeciesCat, Initial: "D", Name: "东方短毛猫", Sort: 90},
{Species: model.SpeciesCat, Initial: "D", Name: "东奇尼猫", Sort: 100},
{Species: model.SpeciesCat, Initial: "E", Name: "俄罗斯蓝猫", Sort: 110},
{Species: model.SpeciesCat, Initial: "H", Name: "哈瓦那棕猫", Sort: 120},
{Species: model.SpeciesCat, Initial: "K", Name: "科拉特猫", Sort: 130},
{Species: model.SpeciesCat, Initial: "L", Name: "拉波猫", Sort: 140},
{Species: model.SpeciesCat, Initial: "M", Name: "缅因猫", Sort: 150},
{Species: model.SpeciesCat, Initial: "M", Name: "美国短毛猫", Sort: 160},
{Species: model.SpeciesCat, Initial: "M", Name: "美国卷耳猫", Sort: 170},
{Species: model.SpeciesCat, Initial: "M", Name: "曼赤肯猫", Sort: 180},
{Species: model.SpeciesCat, Initial: "M", Name: "孟买猫", Sort: 190},
{Species: model.SpeciesCat, Initial: "M", Name: "孟加拉豹猫", Sort: 200},
{Species: model.SpeciesCat, Initial: "N", Name: "挪威森林猫", Sort: 210},
{Species: model.SpeciesCat, Initial: "R", Name: "日本短尾猫", Sort: 220},
{Species: model.SpeciesCat, Initial: "S", Name: "苏格兰折耳猫", Sort: 230},
{Species: model.SpeciesCat, Initial: "S", Name: "索马里猫", Sort: 240},
{Species: model.SpeciesCat, Initial: "S", Name: "塞尔凯克卷毛猫", Sort: 250},
{Species: model.SpeciesCat, Initial: "S", Name: "斯芬克斯无毛猫", Sort: 260},
{Species: model.SpeciesCat, Initial: "S", Name: "沙特尔猫", Sort: 270},
{Species: model.SpeciesCat, Initial: "T", Name: "土耳其安哥拉猫", Sort: 280},
{Species: model.SpeciesCat, Initial: "T", Name: "土耳其梵猫", Sort: 290},
{Species: model.SpeciesCat, Initial: "X", Name: "暹罗猫", Sort: 300},
{Species: model.SpeciesCat, Initial: "X", Name: "新加坡猫", Sort: 310},
{Species: model.SpeciesCat, Initial: "Y", Name: "异国短毛猫", Sort: 320},
{Species: model.SpeciesCat, Initial: "Y", Name: "英国短毛猫", Sort: 330},
{Species: model.SpeciesCat, Initial: "Z", Name: "中华田园猫", Sort: 340},
// 狗 46 种
{Species: model.SpeciesDog, Initial: "A", Name: "阿拉斯加雪橇犬", SizeClass: "large", Sort: 10},
{Species: model.SpeciesDog, Initial: "A", Name: "阿富汗猎犬", SizeClass: "large", Sort: 20},
{Species: model.SpeciesDog, Initial: "A", Name: "澳洲牧羊犬", SizeClass: "medium", Sort: 30},
{Species: model.SpeciesDog, Initial: "B", Name: "比熊犬", SizeClass: "small", Sort: 40},
{Species: model.SpeciesDog, Initial: "B", Name: "比格犬", SizeClass: "medium", Sort: 50},
{Species: model.SpeciesDog, Initial: "B", Name: "边境牧羊犬", SizeClass: "medium", Sort: 60},
{Species: model.SpeciesDog, Initial: "B", Name: "巴哥犬", SizeClass: "small", Sort: 70},
{Species: model.SpeciesDog, Initial: "B", Name: "博美犬", SizeClass: "small", Sort: 80},
{Species: model.SpeciesDog, Initial: "B", Name: "巴吉度猎犬", SizeClass: "medium", Sort: 90},
{Species: model.SpeciesDog, Initial: "B", Name: "北京犬", SizeClass: "small", Sort: 100},
{Species: model.SpeciesDog, Initial: "C", Name: "柴犬", SizeClass: "medium", Sort: 110},
{Species: model.SpeciesDog, Initial: "C", Name: "川东猎犬", SizeClass: "medium", Sort: 120},
{Species: model.SpeciesDog, Initial: "D", Name: "德国牧羊犬", SizeClass: "large", Sort: 130},
{Species: model.SpeciesDog, Initial: "D", Name: "杜宾犬", SizeClass: "large", Sort: 140},
{Species: model.SpeciesDog, Initial: "D", Name: "大丹犬", SizeClass: "giant", Sort: 150},
{Species: model.SpeciesDog, Initial: "D", Name: "斗牛犬", SizeClass: "medium", Sort: 160},
{Species: model.SpeciesDog, Initial: "F", Name: "法国斗牛犬", SizeClass: "medium", Sort: 170},
{Species: model.SpeciesDog, Initial: "G", Name: "贵宾犬(泰迪)", SizeClass: "small", Sort: 180},
{Species: model.SpeciesDog, Initial: "G", Name: "古代牧羊犬", SizeClass: "medium", Sort: 190},
{Species: model.SpeciesDog, Initial: "H", Name: "哈士奇", SizeClass: "large", Sort: 200},
{Species: model.SpeciesDog, Initial: "H", Name: "蝴蝶犬", SizeClass: "small", Sort: 210},
{Species: model.SpeciesDog, Initial: "J", Name: "金毛寻回犬", SizeClass: "large", Sort: 220},
{Species: model.SpeciesDog, Initial: "J", Name: "吉娃娃", SizeClass: "small", Sort: 230},
{Species: model.SpeciesDog, Initial: "K", Name: "柯基犬", SizeClass: "small", Sort: 240},
{Species: model.SpeciesDog, Initial: "K", Name: "可卡犬", SizeClass: "medium", Sort: 250},
{Species: model.SpeciesDog, Initial: "K", Name: "苏格兰柯利犬", SizeClass: "medium", Sort: 260},
{Species: model.SpeciesDog, Initial: "L", Name: "拉布拉多寻回犬", SizeClass: "large", Sort: 270},
{Species: model.SpeciesDog, Initial: "L", Name: "罗威纳犬", SizeClass: "large", Sort: 280},
{Species: model.SpeciesDog, Initial: "L", Name: "灵缇犬", SizeClass: "large", Sort: 290},
{Species: model.SpeciesDog, Initial: "L", Name: "腊肠犬", SizeClass: "small", Sort: 300},
{Species: model.SpeciesDog, Initial: "M", Name: "马尔济斯犬", SizeClass: "small", Sort: 310},
{Species: model.SpeciesDog, Initial: "M", Name: "迷你雪纳瑞", SizeClass: "small", Sort: 320},
{Species: model.SpeciesDog, Initial: "N", Name: "牛头梗", SizeClass: "medium", Sort: 330},
{Species: model.SpeciesDog, Initial: "Q", Name: "秋田犬", SizeClass: "large", Sort: 340},
{Species: model.SpeciesDog, Initial: "S", Name: "萨摩耶", SizeClass: "large", Sort: 350},
{Species: model.SpeciesDog, Initial: "S", Name: "松狮犬", SizeClass: "medium", Sort: 360},
{Species: model.SpeciesDog, Initial: "S", Name: "圣伯纳犬", SizeClass: "giant", Sort: 370},
{Species: model.SpeciesDog, Initial: "X", Name: "雪纳瑞", SizeClass: "medium", Sort: 390},
{Species: model.SpeciesDog, Initial: "X", Name: "西高地白梗", SizeClass: "small", Sort: 400},
{Species: model.SpeciesDog, Initial: "X", Name: "喜乐蒂牧羊犬", SizeClass: "small", Sort: 410},
{Species: model.SpeciesDog, Initial: "Y", Name: "约克夏梗", SizeClass: "small", Sort: 420},
{Species: model.SpeciesDog, Initial: "Y", Name: "银狐犬", SizeClass: "medium", Sort: 430},
{Species: model.SpeciesDog, Initial: "Z", Name: "中华田园犬", SizeClass: "medium", Sort: 440},
{Species: model.SpeciesDog, Initial: "Z", Name: "中亚牧羊犬", SizeClass: "large", Sort: 450},
{Species: model.SpeciesDog, Initial: "Z", Name: "藏獒", SizeClass: "giant", Sort: 460},
}
for i := range breeds {
breeds[i].Enabled = true
}
return db.Create(&breeds).Error
}
// seedRecordTypes 只在表为空时跑。已经有数据(哪怕只有一条)就完全不碰,
// 否则后台删掉的类型会在每次重启后自己长回来
func seedRecordTypes(db *gorm.DB) error {
var count int64
if err := db.Model(&model.RecordType{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
// locked=true 的这 9 种在 record.go / insight.go / report.go / home.go 里有真实
// 代码分支(体重回写档案、食便相关性、消费统计、疫苗完成度…),并且被
// care_template_items.sheet_type 引用,后台不许删,只能停用。
// form=full 表示 bottom-sheet 里有它专用的 wx:elif 分支。
// fields 是表单额外字段的配置,一行一个(写法见 model.RecordType.Fields 的注释)。
// 空 = 只要「时间 + 描述 + 照片」。
//
// 原有 9 种的配置必须和现在的统计口径严丝合缝:
// weight number → num_valueCreateRecord 靠它回写宠物档案
// cost 金额→num_value + 类别→category,账单是按 category 分组聚合的
// poop 状态→category,食便相关性分析排除 category='正常'
// symptom 两个单选:症状不占 category,严重程度占——周报按 category='高' 数高风险
mk := func(code, label, icon, group string, sort int, locked bool, fields string) model.RecordType {
form := model.RecordFormSimple
if locked {
form = model.RecordFormFull
}
return model.RecordType{Code: code, Label: label, Icon: icon, GroupKey: group,
Sort: sort, Enabled: true, Form: form, Locked: locked, Fields: fields}
}
full := func(code, label, icon, group string, sort int, fields string) model.RecordType {
return mk(code, label, icon, group, sort, true, fields)
}
simple := func(code, label, icon, group string, sort int, fields string) model.RecordType {
return mk(code, label, icon, group, sort, false, fields)
}
d, h, c, cl := model.RecordGroupDaily, model.RecordGroupHealth, model.RecordGroupCare, model.RecordGroupClean
types := []model.RecordType{
// 日常
full("weight", "体重", "weight", d, 10, "number:体重:kg"),
full("poop", "排便", "poop", d, 20, "options:状态:正常|软便|拉稀"),
full("food", "饮食", "food", d, 30, "text:吃了什么\noptions:食欲:正常|偏少|不吃"),
simple("water", "喝水", "water", d, 40, "number:饮水量:ml"),
full("cost", "记账", "cost", d, 50, "number:金额:元\noptions:类别:医疗|食品|用品|美容|其他"),
full("photo", "照片", "photo", d, 60, ""),
// 健康
full("symptom", "异常", "symptom", h, 10,
"options:发生了什么:呕吐|拉稀|精神差|皮肤红|其他:-\noptions:严重程度:低|中|高"),
full("vaccine", "疫苗", "vaccine", h, 20, "text:疫苗名称"),
full("deworm", "驱虫", "deworm", h, 30, "options:类型:体内|体外|体内外同驱"),
simple("checkup", "体检", "checkup", h, 40, "text:在哪做的"),
simple("vet", "看病", "vet", h, 50, "text:诊断\nnumber:花费:元"),
full("medicine", "给药", "medicine", h, 60, "text:药品名称\ntext:剂量"),
simple("supplement", "保健品", "supplement", h, 70, "text:名称\ntext:剂量"),
// 洗护
simple("bath", "洗澡", "bath", c, 10, ""),
simple("nail", "剪指甲", "nail", c, 20, ""),
simple("ear", "洗耳朵", "ear", c, 30, ""),
simple("tooth", "刷牙", "tooth", c, 40, ""),
simple("brush", "梳毛", "brush", c, 50, ""),
simple("groom", "美容", "groom", c, 60, "number:花费:元"),
// 清洁
simple("litter", "换猫砂", "litter", cl, 10, ""),
simple("litterbox", "洗猫砂盆", "litterbox", cl, 20, ""),
simple("bowl", "洗食盆", "bowl", cl, 30, ""),
simple("waterbowl", "洗水盆", "waterbowl", cl, 40, ""),
simple("clean", "消毒", "clean", cl, 50, ""),
}
return db.Create(&types).Error
}
func seedAdmin(db *gorm.DB, cfg *config.Config) error {
@@ -38,160 +222,3 @@ func seedAdmin(db *gorm.DB, cfg *config.Config) error {
Role: "admin",
}).Error
}
func seedArticles(db *gorm.DB) error {
var count int64
if err := db.Model(&model.Article{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
return db.Create(builtinArticles()).Error
}
// backfillArticleContent 给早期 seed 出来的空正文文章补内容(按标题匹配,只补空的,不覆盖已编辑过的)
func backfillArticleContent(db *gorm.DB) error {
for _, a := range builtinArticles() {
if err := db.Model(&model.Article{}).
Where("title = ? AND (content IS NULL OR content = '')", a.Title).
Updates(map[string]any{"content": a.Content, "description": a.Description}).Error; err != nil {
return err
}
}
return nil
}
// builtinArticles 内置新手文章。内容遵循与 AI 助手一致的安全护栏:
// 不做诊断、不给药物剂量,异常一律说明何时就医。
func builtinArticles() []model.Article {
return []model.Article{
{
Icon: "💉", Title: "幼猫疫苗和驱虫怎么安排?",
Description: "疫苗针次、驱虫间隔和常见误区,看完顺手设个提醒。",
Category: "vaccine", RelatedSheetType: "vaccine", Published: true,
Content: `刚接回家的猫,疫苗和驱虫是最容易漏掉、也最不能漏的两件事。
【核心疫苗:猫三联】
· 一般从 8 周龄左右开始,共打 3 针,每针间隔 3-4 周。
· 之后每年加强 1 针。
· 狂犬疫苗按当地规定接种,多数地区建议满 3 月龄后接种。
【接种前后注意】
· 只有健康的猫才能打疫苗。有腹泻、打喷嚏、精神差时先缓一缓。
· 接种前后一周不要洗澡、不要换粮、不要长途出行,减少应激。
· 打完在医院观察 20-30 分钟,回家后再留意 24 小时。
· 少数猫会精神略差或轻微发热,通常一两天缓解。
【驱虫】
· 体内驱虫:幼猫期约每月 1 次,成年后每 3 个月 1 次。
· 体外驱虫:出门或家里有狗时更要做,按产品说明周期。
· 完全室内、不接触其他动物的成年猫可适当拉长间隔,但不要长期不做。
【常见误区】
· 「猫不出门就不用打疫苗」——你的鞋底、衣物都可能带病原回家。
· 「三针打完就一劳永逸」——每年仍需加强。
· 「驱虫药随便买」——务必按体重选择,剂量以医生或说明书为准。
【什么时候要去医院】
接种后出现持续呕吐、面部肿胀、呼吸急促、精神明显萎靡,请立即就医。
小程序里可以直接把下一针疫苗和驱虫时间设成提醒,到期会通知你。`,
},
{
Icon: "💩", Title: "猫咪软便要不要去医院?",
Description: "先分清程度,学会记录观察,明确什么情况必须就医。",
Category: "symptom", RelatedSheetType: "symptom", Published: true,
Content: `软便很常见,但「常见」不等于「不用管」。关键是分清程度和持续时间。
【先看程度】
· 成形但偏软、能捡起来:多数是饮食或轻微应激,可以先观察。
· 糊状、不成形:需要留意,记录次数和颜色。
· 水样、喷射状:脱水风险高,别拖。
【常见诱因】
· 换粮太快(最常见):新旧粮要 7 天以上过渡。
· 应激:刚到家、搬家、家里来客人。
· 吃多了、吃了人食或零食过量。
· 寄生虫:尤其是没做过驱虫的幼猫。
· 着凉、毛球、食物不耐受。
【在家可以做的】
· 记录:什么时候开始、一天几次、颜色形状、有没有带血或黏液。
· 暂停新食物和零食,回到之前吃惯的粮。
· 保证饮水,注意保暖。
· 不要自行喂人用止泻药。
【出现这些情况,尽快就医】
· 持续超过 24 小时没有好转;
· 便中带血、发黑,或有大量黏液;
· 伴随呕吐、不吃不喝、精神明显变差;
· 幼猫、老年猫或本身有基础病的猫——它们脱水很快,标准要更严。
【就医时带上什么】
排便照片、开始时间、次数、近期饮食变化、驱虫和疫苗记录。小程序里的「异常观察」可以直接生成一份就医前摘要。
本文只提供观察思路,不能替代医生诊断。`,
},
{
Icon: "🍽️", Title: "7 天换粮计划怎么做?",
Description: "按比例过渡,配合排便记录,把换粮的肠胃风险降到最低。",
Category: "food", RelatedSheetType: "applyPlan", Published: true,
Content: `直接换粮是软便的头号原因。正确做法是按比例、分阶段过渡。
【标准 7 天方案】
· 第 1-2 天:新粮 25% + 旧粮 75%
· 第 3-4 天:新粮 50% + 旧粮 50%
· 第 5-6 天:新粮 75% + 旧粮 25%
· 第 7 天起:新粮 100%
肠胃敏感、幼猫或老年猫,把周期拉长到 10-14 天更稳。
【过渡期要盯什么】
· 每天记录排便状态:成形 / 软便 / 拉稀。
· 食欲有没有下降,有没有呕吐。
· 一旦出现软便:退回上一个比例,稳定 2-3 天再往前推,别硬推。
【几个细节】
· 一次只换一种变量。别同时换粮又换猫砂、又带去洗澡。
· 新旧粮要拌匀,不然它会挑出爱吃的。
· 换粮期间别加新零食,否则出问题分不清是谁的锅。
· 湿粮开封后冷藏并尽快吃完,别放到下一顿还常温摆着。
【什么时候暂停换粮去看医生】
拉稀持续超过 24 小时、便中带血、频繁呕吐或精神变差,先就医,别继续过渡。
在小程序里每天记一次「饮食」和「便便」,7 天后能直接看到这次换粮顺不顺。`,
},
{
Icon: "💰", Title: "一个月养猫大概花多少钱?",
Description: "拆开算固定支出和一次性支出,重点是留出医疗备用金。",
Category: "cost", RelatedSheetType: "cost", Published: true,
Content: `养猫的花销差异很大,但结构是固定的。看清结构再决定预算。
【每月固定支出】
· 猫粮:口粮占大头,按体重和粮的档次浮动。
· 猫砂:用量比想象中大,膨润土和豆腐砂消耗速度不同。
· 零食 / 营养品:可选,不是必需。
【周期性支出】
· 驱虫:体内体外,按周期。
· 疫苗:幼猫第一年针次多,之后每年一次。
· 体检:成年猫每年 1 次,老年猫建议每半年 1 次。
【一次性支出(第一年)】
猫砂盆、猫窝、food/水碗、猫爬架、航空箱、指甲剪、梳子;绝育是第一年最大的一笔。
【最容易被低估的一项:医疗】
真正让预算失控的从来不是猫粮,是突发医疗。常见的口炎、尿闭、异物、肾病,一次就可能超过全年口粮开销。
建议:单独留一笔医疗备用金,或了解一下宠物医疗保险。这比省几十块钱的粮重要得多。
【怎么省得聪明】
· 该省:花哨玩具、频繁换的网红零食。
· 不该省:口粮质量、驱虫疫苗、该做的体检——省这些通常是把钱挪到医疗账单上。
在小程序里每次消费顺手记一笔,月底就能看到自己的真实结构,比拍脑袋估算准得多。`,
},
}
}
+211 -18
View File
@@ -27,12 +27,66 @@ func (h *Handler) AdminLogin(c *gin.Context) {
response.Fail(c, 40100, "账号或密码错误")
return
}
token, err := h.jwt.Generate(admin.ID, appjwt.KindAdmin, admin.Username, admin.Role)
data, err := h.adminTokens(admin)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"token": token, "admin": admin})
response.OK(c, data)
}
// adminTokens 签发后台的「access + refresh」令牌对。access token 2 小时。
func (h *Handler) adminTokens(admin *model.Admin) (gin.H, error) {
token, err := h.jwt.Generate(admin.ID, appjwt.KindAdmin, admin.Username, admin.Role)
if err != nil {
return nil, err
}
refresh, refreshExp, err := h.svc.IssueRefreshToken(string(appjwt.KindAdmin), admin.ID)
if err != nil {
return nil, err
}
return gin.H{
"token": token,
"refresh_token": refresh,
"expires_in": h.jwt.ExpireSeconds(),
"refresh_expires_at": refreshExp,
"admin": admin,
}, nil
}
// AdminRefresh POST /api/admin/refresh
func (h *Handler) AdminRefresh(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
adminID, err := h.svc.ConsumeRefreshToken(req.RefreshToken, string(appjwt.KindAdmin))
if err != nil {
response.Fail(c, 40100, err.Error())
return
}
admin, err := h.svc.GetAdmin(adminID)
if err != nil {
response.Fail(c, 40100, "登录已失效,请重新登录")
return
}
if admin.Disabled {
response.Fail(c, 40300, "账号已被禁用")
return
}
data, err := h.adminTokens(admin)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
// AdminLogout POST /api/admin/logout 作废当前刷新令牌
func (h *Handler) AdminLogout(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
h.svc.RevokeRefreshToken(req.RefreshToken, string(appjwt.KindAdmin))
response.OK(c, gin.H{"ok": true})
}
// AdminMe GET /api/admin/me
@@ -173,7 +227,7 @@ func (h *Handler) AdminListArticles(c *gin.Context) {
}
type articleReq struct {
ID string `json:"id"`
ID string `json:"id"`
Icon string `json:"icon"`
Title string `json:"title"`
Description string `json:"description"`
@@ -226,49 +280,168 @@ func (h *Handler) AdminListPro(c *gin.Context) {
// AdminCareTemplateMeta GET /api/admin/care-templates/meta
// 给后台下拉用:物种、阶段、可选关联记录/弹层类型、提醒类型
// ── 品种 ──
type breedReq struct {
ID string `json:"id"`
Species string `json:"species"`
Name string `json:"name"`
Initial string `json:"initial"`
Sort int `json:"sort"`
Enabled bool `json:"enabled"`
}
// AdminListBreeds GET /api/admin/breeds?species=cat 连停用的一起返回
func (h *Handler) AdminListBreeds(c *gin.Context) {
response.OK(c, h.svc.AdminListBreeds(c.Query("species")))
}
// AdminSaveBreed POST /api/admin/breeds
func (h *Handler) AdminSaveBreed(c *gin.Context) {
var req breedReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
b := &model.Breed{Species: req.Species, Name: req.Name,
Initial: req.Initial, Sort: req.Sort, Enabled: req.Enabled}
b.ID = req.ID
if err := h.svc.SaveBreed(b); err != nil {
respondErr(c, err)
return
}
response.OK(c, b)
}
// AdminDeleteBreed DELETE /api/admin/breeds/:id
func (h *Handler) AdminDeleteBreed(c *gin.Context) {
if err := h.svc.DeleteBreed(idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
// ── 记录类型 ──
type recordTypeReq struct {
ID string `json:"id"`
Code string `json:"code"`
Label string `json:"label"`
Icon string `json:"icon"`
Group string `json:"group"`
Sort int `json:"sort"`
Enabled bool `json:"enabled"`
Form string `json:"form"`
Fields string `json:"fields_raw"`
}
// AdminListRecordTypes GET /api/admin/record-types 连停用的一起返回
func (h *Handler) AdminListRecordTypes(c *gin.Context) {
response.OK(c, h.svc.AdminListRecordTypes())
}
// AdminSaveRecordType POST /api/admin/record-types 新增或更新
func (h *Handler) AdminSaveRecordType(c *gin.Context) {
var req recordTypeReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
t := &model.RecordType{
Code: req.Code, Label: req.Label, Icon: req.Icon,
GroupKey: req.Group, Sort: req.Sort, Enabled: req.Enabled, Form: req.Form,
Fields: req.Fields,
}
t.ID = req.ID
if err := h.svc.SaveRecordType(t); err != nil {
respondErr(c, err)
return
}
response.OK(c, t)
}
// AdminDeleteRecordType DELETE /api/admin/record-types/:id
// locked 的和已有记录的删不了,service 层硬拦
func (h *Handler) AdminDeleteRecordType(c *gin.Context) {
if err := h.svc.DeleteRecordType(idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
func (h *Handler) AdminCareTemplateMeta(c *gin.Context) {
response.OK(c, gin.H{
"species": []gin.H{
{"key": model.SpeciesCat, "label": "猫猫"},
{"key": model.SpeciesDog, "label": "狗狗"},
},
"stages": service.CareStages,
"task_sheets": []string{"", "weight", "poop", "food", "symptom", "vaccine", "medicine"},
"plan_sheets": []string{"", "weight", "vaccine", "medicine", "report", "taskDetail"},
"stages": service.CareStages,
// 「关联记录事项」的可选值读 record_types 表,不再写死。
// 原来是两个硬编码数组(task_sheets / plan_sheets),里头还有
// report / taskDetail 这种早就不存在的值 —— 选了等于点了没反应;
// 而后台新加的 24 种事项一个都选不到
"record_types": h.svc.AdminListRecordTypes(),
"repeats": []string{"once", "daily", "weekly", "monthly"},
"reminder_types": []string{"vaccine", "deworm", "weight", "monthlyReport"},
"priorities": []string{"", "重要"},
})
}
// AdminGetCareTemplate GET /api/admin/care-templates?species=&stage=
// AdminListCareTemplates GET /api/admin/care-templates 全部方案
func (h *Handler) AdminListCareTemplates(c *gin.Context) {
response.OK(c, h.svc.AdminListTemplates())
}
// AdminGetCareTemplate GET /api/admin/care-templates/:id 一套方案
func (h *Handler) AdminGetCareTemplate(c *gin.Context) {
data, err := h.svc.GetCareTemplate(c.Query("species"), c.Query("stage"))
tpl, err := h.svc.AdminGetTemplate(idParam(c, "id"))
if err != nil {
response.FailParams(c, err.Error())
respondErr(c, err)
return
}
response.OK(c, data)
response.OK(c, tpl)
}
type careTemplateReq struct {
Species string `json:"species"`
Stage string `json:"stage"`
Items []model.CareTemplateItem `json:"items"`
ID string `json:"id"`
Species string `json:"species"`
Stage string `json:"stage"`
Name string `json:"name"`
Summary string `json:"summary"`
Sort int `json:"sort"`
Enabled bool `json:"enabled"`
Items []model.CareTemplateItem `json:"items"`
}
// AdminSaveCareTemplate PUT /api/admin/care-templates
// AdminSaveCareTemplate POST /api/admin/care-templates 新增或更新一套
func (h *Handler) AdminSaveCareTemplate(c *gin.Context) {
var req careTemplateReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SaveCareTemplate(req.Species, req.Stage, req.Items); err != nil {
response.FailErr(c, err)
tpl := &model.CareTemplate{
Species: req.Species, Stage: req.Stage, Name: req.Name,
Summary: req.Summary, Sort: req.Sort, Enabled: req.Enabled,
}
tpl.ID = req.ID
if err := h.svc.SaveTemplate(tpl, req.Items); err != nil {
respondErr(c, err)
return
}
data, _ := h.svc.GetCareTemplate(req.Species, req.Stage)
response.OK(c, data)
saved, _ := h.svc.AdminGetTemplate(tpl.ID)
response.OK(c, saved)
}
// AdminDeleteCareTemplate DELETE /api/admin/care-templates/:id
func (h *Handler) AdminDeleteCareTemplate(c *gin.Context) {
if err := h.svc.DeleteTemplate(idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
type careGenReq struct {
@@ -336,3 +509,23 @@ func (h *Handler) AdminGenerateCommunityPosts(c *gin.Context) {
}
response.OK(c, gin.H{"made": made})
}
// AdminGetAIQuota GET /api/admin/ai-quota
func (h *Handler) AdminGetAIQuota(c *gin.Context) {
response.OK(c, h.svc.GetAIQuotaConfig())
}
// AdminSaveAIQuota PUT /api/admin/ai-quota
func (h *Handler) AdminSaveAIQuota(c *gin.Context) {
var req model.AIQuotaConfig
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
cfg, err := h.svc.SaveAIQuotaConfig(req)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, cfg)
}
+93 -5
View File
@@ -4,6 +4,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/model"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
"github.com/sundynix/pets-be/pkg/response"
)
@@ -12,6 +13,26 @@ type loginReq struct {
Nickname string `json:"nickname"`
}
// userTokens 签发小程序用户的「access + refresh」令牌对。
// access token 只有 2 小时,前端过期后拿 refresh_token 静默续期。
func (h *Handler) userTokens(user *model.User) (gin.H, error) {
token, err := h.jwt.Generate(user.ID, appjwt.KindUser, user.Nickname, "")
if err != nil {
return nil, err
}
refresh, refreshExp, err := h.svc.IssueRefreshToken(string(appjwt.KindUser), user.ID)
if err != nil {
return nil, err
}
return gin.H{
"token": token,
"refresh_token": refresh,
"expires_in": h.jwt.ExpireSeconds(),
"refresh_expires_at": refreshExp,
"user": user,
}, nil
}
// Login 开发态 Mock 登录
func (h *Handler) Login(c *gin.Context) {
if !h.cfg.Auth.DevLogin {
@@ -26,12 +47,12 @@ func (h *Handler) Login(c *gin.Context) {
response.FailErr(c, err)
return
}
token, err := h.jwt.Generate(user.ID, appjwt.KindUser, user.Nickname, "")
data, err := h.userTokens(user)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"token": token, "user": user})
response.OK(c, data)
}
type wechatLoginReq struct {
@@ -58,8 +79,10 @@ func (h *Handler) WechatLogin(c *gin.Context) {
}
// 可选:首次登录写入用户授权的昵称/头像
if fields := map[string]any{}; true {
if req.Nickname != "" && user.Nickname == "微信用户" {
// 只覆盖系统生成的昵称。用户自己改过的(NicknameAuto=false)不动
if req.Nickname != "" && user.NicknameAuto {
fields["nickname"] = req.Nickname
fields["nickname_auto"] = false
}
if req.Avatar != "" && user.Avatar == "" {
fields["avatar"] = req.Avatar
@@ -70,12 +93,52 @@ func (h *Handler) WechatLogin(c *gin.Context) {
}
}
}
token, err := h.jwt.Generate(user.ID, appjwt.KindUser, user.Nickname, "")
data, err := h.userTokens(user)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"token": token, "user": user})
response.OK(c, data)
}
type refreshReq struct {
RefreshToken string `json:"refresh_token"`
}
// RefreshToken POST /api/auth/refresh
// 用刷新令牌换新的 access token;刷新令牌本身也会轮换,旧的立即失效。
func (h *Handler) RefreshToken(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
userID, err := h.svc.ConsumeRefreshToken(req.RefreshToken, string(appjwt.KindUser))
if err != nil {
response.Fail(c, 40100, err.Error())
return
}
user, err := h.svc.GetUser(userID)
if err != nil {
response.Fail(c, 40100, "登录已失效,请重新登录")
return
}
if user.Disabled {
response.Fail(c, 40300, "账号已被禁用")
return
}
data, err := h.userTokens(user)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
// Logout POST /api/auth/logout 作废当前刷新令牌
func (h *Handler) Logout(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
h.svc.RevokeRefreshToken(req.RefreshToken, string(appjwt.KindUser))
response.OK(c, gin.H{"ok": true})
}
// Profile 当前用户资料
@@ -96,6 +159,30 @@ type updateProfileReq struct {
}
// UpdateProfile 更新资料
// decorationReq 主页装扮。全部用指针:区分「没传」和「传了空串」——
// 用户清空个性签名和不改个性签名是两件事
type decorationReq struct {
Bio *string `json:"bio"`
BgFileID *string `json:"bg_file_id"`
Theme *string `json:"theme"`
ShowPets *bool `json:"show_pets"`
}
// UpdateDecoration PUT /api/user/decoration 装扮自己的主页
func (h *Handler) UpdateDecoration(c *gin.Context) {
var req decorationReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
user, err := h.svc.UpdateDecoration(middleware.UserID(c), req.Bio, req.BgFileID, req.Theme, req.ShowPets)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, user)
}
func (h *Handler) UpdateProfile(c *gin.Context) {
var req updateProfileReq
if err := c.ShouldBindJSON(&req); err != nil {
@@ -105,6 +192,7 @@ func (h *Handler) UpdateProfile(c *gin.Context) {
fields := map[string]any{}
if req.Nickname != nil {
fields["nickname"] = *req.Nickname
fields["nickname_auto"] = false // 用户自己设过就别再被微信授权覆盖
}
if req.Avatar != nil {
fields["avatar"] = *req.Avatar
+51 -2
View File
@@ -113,7 +113,9 @@ func (h *Handler) ListComments(c *gin.Context) {
}
type commentReq struct {
Content string `json:"content"`
Content string `json:"content"`
Images []string `json:"images"`
ParentID string `json:"parent_id"` // 回复某条评论时传它的 id
}
// CreateComment POST /api/posts/:id/comments
@@ -127,7 +129,7 @@ func (h *Handler) CreateComment(c *gin.Context) {
response.FailParams(c, "评论内容不能为空")
return
}
comment, err := h.svc.CreateComment(middleware.UserID(c), idParam(c, "id"), req.Content)
comment, err := h.svc.CreateComment(middleware.UserID(c), idParam(c, "id"), req.Content, req.Images, req.ParentID)
if err != nil {
respondErr(c, err)
return
@@ -170,3 +172,50 @@ func (h *Handler) DeleteOwnComment(c *gin.Context) {
}
response.OK(c, gin.H{"deleted": true})
}
// ListReplies GET /api/comments/:id/replies 展开某条评论的全部回复
func (h *Handler) ListReplies(c *gin.Context) {
list, err := h.svc.ListReplies(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, list)
}
// UserCard GET /api/users/:id/profile 某人的公开资料
func (h *Handler) UserCard(c *gin.Context) {
card, err := h.svc.GetUserCard(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, card)
}
// UserPosts GET /api/users/:id/posts 某人发布的帖子
func (h *Handler) UserPosts(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
posts, total, err := h.svc.ListUserPosts(middleware.UserID(c), idParam(c, "id"), req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(posts, total, req.PageQuery))
}
// Relations GET /api/users/:id/relations?kind=following|followers
func (h *Handler) Relations(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
kind := c.Query("kind")
list, total, err := h.svc.ListRelations(middleware.UserID(c), idParam(c, "id"), kind, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(list, total, req.PageQuery))
}
+4
View File
@@ -31,5 +31,9 @@ func respondErr(c *gin.Context, err error) {
response.Fail(c, errcode.ErrForbidden, err.Error())
return
}
if errors.Is(err, service.ErrInvalidParam) {
response.FailParams(c, err.Error())
return
}
response.FailErr(c, err)
}
+12 -3
View File
@@ -57,9 +57,9 @@ func (h *Handler) ActivatePro(c *gin.Context) {
}
type aiChatReq struct {
PetID *string `json:"pet_id"`
Session string `json:"session"`
Text string `json:"text"`
PetID *string `json:"pet_id"`
Session string `json:"session"`
Text string `json:"text"`
}
// AIChat POST /api/ai/chat
@@ -75,6 +75,10 @@ func (h *Handler) AIChat(c *gin.Context) {
}
reply, err := h.svc.AIChat(middleware.UserID(c), req.PetID, req.Session, req.Text)
if err != nil {
if errors.Is(err, service.ErrAIQuotaExceeded) {
response.Fail(c, 42900, err.Error())
return
}
response.FailErr(c, err)
return
}
@@ -188,3 +192,8 @@ func (h *Handler) ListAIMessages(c *gin.Context) {
}
response.OK(c, msgs)
}
// AIQuota GET /api/ai/quota 今日剩余次数
func (h *Handler) AIQuota(c *gin.Context) {
response.OK(c, h.svc.AIQuotaLeft(middleware.UserID(c)))
}
+83 -12
View File
@@ -7,19 +7,21 @@ import (
"gorm.io/datatypes"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/model"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/pkg/response"
)
type petReq struct {
Name string `json:"name"`
Emoji string `json:"emoji"`
Type string `json:"type"`
Gender string `json:"gender"`
Birthday string `json:"birthday"` // YYYY-MM-DD
Weight string `json:"weight"`
Stage string `json:"stage"`
Age string `json:"age"`
Name string `json:"name"`
Emoji string `json:"emoji"`
Type string `json:"type"`
Gender string `json:"gender"`
Birthday string `json:"birthday"` // YYYY-MM-DD
ArrivedAt string `json:"arrived_at"` // YYYY-MM-DD,到家日期
Weight string `json:"weight"`
Stage string `json:"stage"`
Age string `json:"age"`
Color string `json:"color"`
Breed string `json:"breed"`
AvatarFileID string `json:"avatar_file_id"`
@@ -33,10 +35,20 @@ func (r petReq) toInput() service.PetInput {
AvatarFileID: r.AvatarFileID,
}
if r.Birthday != "" {
if t, err := time.Parse("2006-01-02", r.Birthday); err == nil {
in.Birthday = &t
if t, err := time.ParseInLocation("2006-01-02", r.Birthday, time.Local); err == nil {
// 生日不接受未来日期:前端限制过一道,服务端不能只信前端
if !t.After(time.Now()) {
in.Birthday = &t
}
}
}
if r.ArrivedAt != "" {
if t, err := time.ParseInLocation("2006-01-02", r.ArrivedAt, time.Local); err == nil && !t.After(time.Now()) {
in.ArrivedAt = &t
}
}
// 阶段归一化不在这里做:狗要按品种查体型才能定阈值,需要访问 DB。
// 交给 handler 里 h.svc.NormalizeStage 处理。
if r.Goals != nil {
if b, err := jsonMarshal(r.Goals); err == nil {
in.Goals = datatypes.JSON(b)
@@ -62,7 +74,9 @@ func (h *Handler) CreatePet(c *gin.Context) {
response.FailParams(c, err.Error())
return
}
pet, err := h.svc.CreatePet(middleware.UserID(c), req.toInput())
in := req.toInput()
in.Stage = h.svc.NormalizeStage(service.SpeciesOf(req.Type), req.Breed, in.Stage, in.Birthday)
pet, err := h.svc.CreatePet(middleware.UserID(c), in)
if err != nil {
response.FailErr(c, err)
return
@@ -112,6 +126,18 @@ func (h *Handler) UpdatePet(c *gin.Context) {
if req.Breed != "" {
fields["breed"] = req.Breed
}
// 生日和到家日期是日期列,得先解析成 time。之前漏了这两个,
// 导致编辑档案时改到家日期不落库(建档走 toInput 才正常)
if req.Birthday != "" {
if t, err := time.ParseInLocation("2006-01-02", req.Birthday, time.Local); err == nil {
fields["birthday"] = t
}
}
if req.ArrivedAt != "" {
if t, err := time.ParseInLocation("2006-01-02", req.ArrivedAt, time.Local); err == nil && !t.After(time.Now()) {
fields["arrived_at"] = t
}
}
if req.AvatarFileID != "" {
fields["avatar_file_id"] = req.AvatarFileID
}
@@ -139,10 +165,55 @@ func (h *Handler) Onboarding(c *gin.Context) {
response.FailParams(c, err.Error())
return
}
pet, err := h.svc.Onboarding(middleware.UserID(c), req.toInput())
in := req.toInput()
in.Stage = h.svc.NormalizeStage(service.SpeciesOf(req.Type), req.Breed, in.Stage, in.Birthday)
pet, err := h.svc.Onboarding(middleware.UserID(c), in)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, pet)
}
// Breeds GET /api/breeds?species=cat 品种,按首字母分组。
// 建档第二步的选择器直接照这个渲染,前端不做分组也不做排序
func (h *Handler) Breeds(c *gin.Context) {
response.OK(c, h.svc.ListBreeds(c.Query("species")))
}
// LifeStages GET /api/life-stages?species=dog&size=large
// 生命阶段的划分标准。建档时选阶段那里、和「该换方案了」的提示里都要展示依据,
// 所以直接把驱动判定的那张表吐出来 —— 不另写一份说明文案,免得两边漂
func (h *Handler) LifeStages(c *gin.Context) {
species := c.Query("species")
if species == "" {
species = model.SpeciesCat
}
response.OK(c, gin.H{
"species": species,
"size": c.Query("size"),
"stages": service.StageRules(species, c.Query("size")),
"sizes": service.SizeLabels,
})
}
// PetStageDrift GET /api/pets/:id/stage-drift
// 存的阶段和按生日算的对不上时,告诉前端可以提示用户换一套方案
func (h *Handler) PetStageDrift(c *gin.Context) {
pet, err := h.svc.GetPet(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, h.svc.CheckStageDrift(pet))
}
// PetIDCard GET /api/pets/:id/id-card 宠物身份卡内容(含小程序码,未发布时无码)
func (h *Handler) PetIDCard(c *gin.Context) {
card, err := h.svc.GetIDCard(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, card)
}
+98 -61
View File
@@ -14,7 +14,7 @@ import (
func (h *Handler) ListTasks(c *gin.Context) {
var date *time.Time
if q := c.Query("date"); q != "" {
if t, err := time.Parse("2006-01-02", q); err == nil {
if t, err := time.ParseInLocation("2006-01-02", q, time.Local); err == nil {
date = &t
}
}
@@ -26,66 +26,9 @@ func (h *Handler) ListTasks(c *gin.Context) {
response.OK(c, tasks)
}
type taskReq struct {
Title string `json:"title"`
Description string `json:"description"`
Priority string `json:"priority"`
SheetType string `json:"sheet_type"`
Date string `json:"date"` // 仅新增用,YYYY-MM-DD,默认今天
}
func (r taskReq) toInput() service.TaskInput {
in := service.TaskInput{Title: r.Title, Description: r.Description, Priority: r.Priority, SheetType: r.SheetType}
if r.Date != "" {
if t, err := time.Parse("2006-01-02", r.Date); err == nil {
in.Date = &t
}
}
return in
}
// CreateTask POST /api/pets/:id/tasks
func (h *Handler) CreateTask(c *gin.Context) {
var req taskReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if req.Title == "" {
response.FailParams(c, "任务标题不能为空")
return
}
t, err := h.svc.CreateTask(middleware.UserID(c), idParam(c, "id"), req.toInput())
if err != nil {
respondErr(c, err)
return
}
response.OK(c, t)
}
// UpdateTask PUT /api/tasks/:id
func (h *Handler) UpdateTask(c *gin.Context) {
var req taskReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
t, err := h.svc.UpdateTask(middleware.UserID(c), idParam(c, "id"), req.toInput())
if err != nil {
respondErr(c, err)
return
}
response.OK(c, t)
}
// DeleteTask DELETE /api/tasks/:id
func (h *Handler) DeleteTask(c *gin.Context) {
if err := h.svc.DeleteTask(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
// 任务的增删改统一走 /plan-tasks 那套 —— 「今日任务」和「计划节点」合并之后,
// /tasks 这边只保留「读某天的」和「勾选」,重复的 CRUD 删掉了。
// 留着两套会出现「首页管理弹层加的任务」和「计划页加的节点」进不同的表。
// ToggleTask POST /api/tasks/:id/toggle
func (h *Handler) ToggleTask(c *gin.Context) {
@@ -181,3 +124,97 @@ func (h *Handler) TogglePlanTask(c *gin.Context) {
}
response.OK(c, gin.H{"ok": true})
}
// planTaskReq 用户自己加/改的计划节点
type planTaskReq struct {
Date string `json:"date"`
Title string `json:"title"`
Description string `json:"description"`
SheetType string `json:"sheet_type"`
Daily bool `json:"daily"`
}
func (r planTaskReq) input() service.PlanTaskInput {
return service.PlanTaskInput{Date: r.Date, Title: r.Title,
Description: r.Description, SheetType: r.SheetType, Daily: r.Daily}
}
// AddPlanTask POST /api/pets/:id/plan-tasks 往 30 天计划里加一条
func (h *Handler) AddPlanTask(c *gin.Context) {
var req planTaskReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
list, err := h.svc.AddPlanTask(middleware.UserID(c), idParam(c, "id"), req.input())
if err != nil {
respondErr(c, err)
return
}
// daily 会一次建多条,统一返回数组,前端不用分两种情况处理
response.OK(c, list)
}
// UpdatePlanTask PUT /api/plan-tasks/:id
func (h *Handler) UpdatePlanTask(c *gin.Context) {
var req planTaskReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.UpdatePlanTask(middleware.UserID(c), idParam(c, "id"), req.input()); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
// DeletePlanTask DELETE /api/plan-tasks/:id
func (h *Handler) DeletePlanTask(c *gin.Context) {
if err := h.svc.DeletePlanTask(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
// PlanMonthStatus GET /api/pets/:id/plan/month-status
// 首页「管理计划」那个提醒角标要的:本月剩几件、下月排了没有
func (h *Handler) PlanMonthStatus(c *gin.Context) {
st, err := h.svc.PlanMonthStatus(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, st)
}
// CareTemplateOptions GET /api/care-templates?species=cat&stage=幼年期
// 建档最后一步和计划页的「套用方案」都读这个
func (h *Handler) CareTemplateOptions(c *gin.Context) {
response.OK(c, h.svc.ListTemplateOptions(c.Query("species"), c.Query("stage")))
}
type applyTemplateReq struct {
TemplateID string `json:"template_id"`
MonthOffset int `json:"month_offset"` // 0 本月,1 下月
}
// ApplyTemplate POST /api/pets/:id/apply-template
func (h *Handler) ApplyTemplate(c *gin.Context) {
var req applyTemplateReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if req.TemplateID == "" {
response.FailParams(c, "缺少方案 id")
return
}
res, err := h.svc.ApplyTemplate(middleware.UserID(c), idParam(c, "id"), req.TemplateID, req.MonthOffset)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, res)
}
+17
View File
@@ -79,3 +79,20 @@ func (h *Handler) WeightTrend(c *gin.Context) {
}
response.OK(c, points)
}
// PetInsights GET /api/pets/:id/insights
// 把散落的记录变成结论:体重趋势、换粮与软便的关联、异常扎堆、逾期提醒、同龄对比。
func (h *Handler) PetInsights(c *gin.Context) {
list, err := h.svc.PetInsights(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, list)
}
// RecordTypes GET /api/record-types 记录页可记事项,按分组归好。
// 类型从表里来而不是前端写死,后台加一种不用发版
func (h *Handler) RecordTypes(c *gin.Context) {
response.OK(c, h.svc.ListRecordTypes())
}
+2 -2
View File
@@ -36,7 +36,7 @@ func (h *Handler) CreateReminder(c *gin.Context) {
}
in := service.ReminderInput{Type: req.Type, Title: req.Title, Frequency: req.Frequency}
if req.NextDueDate != "" {
if t, err := time.Parse("2006-01-02", req.NextDueDate); err == nil {
if t, err := time.ParseInLocation("2006-01-02", req.NextDueDate, time.Local); err == nil {
in.NextDueDate = &t
}
}
@@ -66,7 +66,7 @@ func (h *Handler) UpdateReminder(c *gin.Context) {
fields["frequency"] = req.Frequency
}
if req.NextDueDate != "" {
if t, err := time.Parse("2006-01-02", req.NextDueDate); err == nil {
if t, err := time.ParseInLocation("2006-01-02", req.NextDueDate, time.Local); err == nil {
fields["next_due_date"] = t
}
}
+20
View File
@@ -0,0 +1,20 @@
package model
// AIQuotaConfig 每日 AI 次数上限(单例,固定 id=1)。
// AI 调用是真金白银,不设上限等于把钱包交给用户。
type AIQuotaConfig struct {
Base
Enabled bool `json:"enabled"` // 关掉即不限次
DailyChat int `json:"daily_chat"` // 问问 AI,每人每天
DailySymptom int `json:"daily_symptom"` // 异常观察评估
DailyPlan int `json:"daily_plan"` // AI 生成计划
}
// AIUsage 某人某天某类 AI 的用量。按天存,天然过期,不需要清理任务。
type AIUsage struct {
Base
UserID string `gorm:"size:24;uniqueIndex:idx_user_day_kind" json:"user_id"`
Day string `gorm:"size:10;uniqueIndex:idx_user_day_kind" json:"day"` // YYYY-MM-DD
Kind string `gorm:"size:16;uniqueIndex:idx_user_day_kind" json:"kind"`
Count int `json:"count"`
}
+5
View File
@@ -29,6 +29,8 @@ func AllModels() []any {
&User{},
&Pet{},
&HealthRecord{},
&RecordType{},
&Breed{},
&DailyTask{},
&Plan{},
&PlanTask{},
@@ -47,5 +49,8 @@ func AllModels() []any {
&File{},
&Feedback{},
&Follow{},
&RefreshToken{},
&AIQuotaConfig{},
&AIUsage{},
}
}
+26
View File
@@ -0,0 +1,26 @@
package model
// Breed 品种。原来建档时品种是个自由文本输入框,用户手打「柯基」「柯基犬」
// 「威尔士柯基」都算不同品种,后面想按品种做体重基准、常见病提示这类事就没法做。
type Breed struct {
Base
Species string `gorm:"size:16;index" json:"species"` // cat | dog
Name string `gorm:"size:64" json:"name"`
// Initial 首字母,A-Z,用来做侧边索引。
//
// 存字段而不是运行时算:Go 标准库没有拼音,引一个拼音库只为了取首字母不值得,
// 而且多音字(「柴犬」的柴、「藏獒」的藏)自动转常出错——「藏獒」按 cang
// 还是 zang 分组,人来定比库来猜靠谱。后台新增品种时手填这一位。
Initial string `gorm:"size:1;index" json:"initial"`
// SizeClass 成年体型,只有犬类用:small(<10kg) / medium(10-25) / large(25-40) / giant(>40)。
//
// 生命阶段的判定要靠它。狗的发育完全取决于体型——大丹犬 8 个月还在长骨头,
// 吉娃娃 8 个月已经性成熟;大丹犬 7 岁进老年,吉娃娃 7 岁还是中年。
// 不能靠当前体重判断(幼犬体重说明不了成年后多大),只能靠品种。
SizeClass string `gorm:"size:16;index" json:"size_class"`
Sort int `json:"sort"`
Enabled bool `gorm:"default:true" json:"enabled"`
}
+48 -19
View File
@@ -1,7 +1,9 @@
package model
// 养护模板:按「物种 × 阶段」配置建档时生成的今日任务 / 30天计划 / 提醒
// 管理员可 AI 生成草稿后修改保存;建档时按宠物物种阶段套用,查不到回退内置默认。
// 养护方案:一套「这个物种、这个阶段该做哪些事」的配置
// 后台可以给同一个 (species, stage) 配多套,建档时让用户挑一套套用,
// 也可以不套用、自己排。原来是一个组合只能有一套(唯一索引卡着),
// 「让用户选模版」这件事在数据模型上做不到。
// 物种取值
const (
@@ -9,37 +11,64 @@ const (
SpeciesDog = "dog"
)
// 模板明细的类别
// 明细的类别。原来还有个 task(今日任务),已经和 plan 合并——
// 「今天要做的事」就是「今天那一天的计划节点」,两套并存的结果是
// 用户在计划页加的「每天喂药」在首页看不到。
const (
CareKindTask = "task" // 今日任务
CareKindPlan = "plan" // 30天计划节点
CareKindReminder = "reminder" // 提醒
CareKindPlan = "plan" // 计划节点(含每天/每周/每月重复)
CareKindReminder = "reminder" // 到期提醒(疫苗、驱虫这类有固定周期的)
)
// CareTemplate 一套模板 = 一个 (species, stage) 组合
// 重复规则。月度计划要的是「多久做一次」,不是「第几天做」——
// 原来只有 Day 偏移,月底建档的话 day=14/21/30 全落到下个月,
// 用户建完档打开计划页发现本月是空的。
const (
RepeatOnce = "once" // 一次性,按 Day 偏移(到家第 N 天,疫苗序列要用)
RepeatDaily = "daily" // 每天
RepeatWeekly = "weekly" // 每周某几天,看 Weekdays
RepeatMonthly = "monthly" // 每月某天,看 MonthDay
)
// CareTemplate 一套方案
type CareTemplate struct {
Base
Species string `gorm:"size:16;uniqueIndex:idx_species_stage" json:"species"` // cat | dog
Stage string `gorm:"size:48;uniqueIndex:idx_species_stage" json:"stage"` // 与 onboarding 阶段一致
Items []CareTemplateItem `gorm:"foreignKey:TemplateID" json:"items"`
Species string `gorm:"size:16;index" json:"species"` // cat | dog
Stage string `gorm:"size:48;index" json:"stage"` // 与 onboarding 阶段一致
// Name 方案名。建档时用户看到的就是这个,要能一眼分辨
// (「幼猫标准照护」/「幼猫省心版」)
Name string `gorm:"size:64" json:"name"`
// Summary 一句话摘要,选择列表里展示
Summary string `gorm:"size:255" json:"summary"`
Sort int `json:"sort"`
Enabled bool `gorm:"default:true" json:"enabled"`
Items []CareTemplateItem `gorm:"foreignKey:TemplateID" json:"items"`
}
// CareTemplateItem 模板明细,kind 区分类;不同类只用到部分字段
// CareTemplateItem 明细,kind 区分类;不同类只用到部分字段
type CareTemplateItem struct {
Base
TemplateID string `gorm:"size:24;index" json:"template_id"`
Kind string `gorm:"size:16;index" json:"kind"` // task | plan | reminder
TemplateID string `gorm:"size:24;index" json:"template_id"`
Kind string `gorm:"size:16;index" json:"kind"` // plan | reminder
Sort int `json:"sort"`
Title string `gorm:"size:128" json:"title"`
Description string `gorm:"size:512" json:"description"`
// task 专用
Priority string `gorm:"size:16" json:"priority"` // 重要 / 普通(空)
SheetType string `gorm:"size:32" json:"sheet_type"` // 关联记录弹层:weight/poop/food/vaccine...
// plan 专用
Day int `json:"day"` // 第 N 天(0=今天
DayLabel string `gorm:"size:32" json:"day_label"` // 展示标签
Priority string `gorm:"size:16" json:"priority"` // 重要 / 普通(空
SheetType string `gorm:"size:32" json:"sheet_type"` // 关联记录类型 codeweight/poop/bath...
// Repeat 列名用 repeat_rulerepeat 是 MySQL 保留字,GORM 虽然会加反引号,
// 但手写 SQL 排查时很容易踩(和 group_key 同样的理由)
Repeat string `gorm:"column:repeat_rule;size:16" json:"repeat"` // once | daily | weekly | monthly
Day int `json:"day"` // once:从套用日起第 N 天(0=当天)
// DayLabel 留着:后台的 AI 生成模板那条链路在产出它。月度计划里日期是绝对的
// 不需要「第 N 天」标签,但删它要连着改 AI 解析,不在这次范围内
DayLabel string `gorm:"size:32" json:"day_label"`
// Weekdays weekly 用:逗号分隔的 1-7(1=周一)。
// 存字符串不另开表——一条模板明细最多 7 个数字,开表换来的只是查询灵活性,
// 而这份数据从来只整条读出来展开
Weekdays string `gorm:"size:16" json:"weekdays"`
MonthDay int `json:"month_day"` // monthly 用:几号(1-31),超出当月天数按月末算
// reminder 专用
ReminderType string `gorm:"size:32" json:"reminder_type"` // vaccine/deworm/weight/monthlyReport
+16 -6
View File
@@ -1,13 +1,23 @@
package model
import "gorm.io/datatypes"
// Comment 帖子评论
type Comment struct {
Base
PostID string `gorm:"size:24;index" json:"post_id"`
UserID string `gorm:"size:24;index" json:"user_id"`
AuthorName string `gorm:"size:64" json:"author_name"`
Content string `gorm:"size:512" json:"content"`
Status string `gorm:"size:16;default:published" json:"status"`
PostID string `gorm:"size:24;index" json:"post_id"`
UserID string `gorm:"size:24;index" json:"user_id"`
AuthorName string `gorm:"size:64" json:"author_name"`
Content string `gorm:"size:512" json:"content"`
Images datatypes.JSON `json:"images"`
// 两级评论:ParentID 为空是一级评论,否则是某条一级评论下的回复。
// 不做无限层级——手机上层层缩进读到第四层就没法看了,
// 主流产品(微信、小红书、B站)都是两级 + @某人 表达指向。
ParentID string `gorm:"size:24;index" json:"parent_id"`
ReplyToName string `gorm:"size:64" json:"reply_to_name"`
Status string `gorm:"size:16;default:published" json:"status"`
IsSelf bool `gorm:"-" json:"is_self"` // 计算字段:是不是我自己发的
IsSelf bool `gorm:"-" json:"is_self"` // 计算字段:是不是我自己发的
Replies []Comment `gorm:"-" json:"replies"` // 计算字段:一级评论下挂的回复
ReplyN int `gorm:"-" json:"reply_n"` // 回复总数(可能多于返回的条数)
}
+5 -2
View File
@@ -17,9 +17,12 @@ type Pet struct {
Type string `gorm:"size:16" json:"type"` // 猫猫 / 狗狗
Gender string `gorm:"size:16" json:"gender"` // 男孩 / 女孩 / 不确定
Birthday *time.Time `json:"birthday"`
ArrivedAt *time.Time `json:"arrived_at"` // 到家日期。30 天内会额外叠加一套安置期任务
Weight string `gorm:"size:16" json:"weight"` // 如 "2.8kg"
Stage string `gorm:"size:32" json:"stage"` // 刚到家 0-30 天 / 幼年期 / 成年期 / 老年期
Age string `gorm:"size:32" json:"age"`
Stage string `gorm:"size:32" json:"stage"` // 年龄阶段:幼年期 / 成年期 / 老年期(由生日推断,可手改)
// Age 计算字段,不入库。原来是建档时算好存下来的字符串,
// 养半年后首页还显示「2个月」—— 存一个会过期的展示值本身就是 bug
Age string `gorm:"-" json:"age"`
Color string `gorm:"size:32" json:"color"` // 毛色,如 橘白 / 奶牛
Breed string `gorm:"size:64" json:"breed"` // 品种
HealthStatus string `gorm:"size:16;default:正常" json:"health_status"`
+3 -1
View File
@@ -37,6 +37,8 @@ type PlanTask struct {
Title string `gorm:"size:128" json:"title"`
Description string `gorm:"size:512" json:"description"`
SheetType string `gorm:"size:32" json:"sheet_type"`
Done bool `json:"done"`
// Priority 从 DailyTask 合并过来的:首页那份「今天要做的事」要能标出哪条重要
Priority string `gorm:"size:16" json:"priority"`
Done bool `json:"done"`
Date string `gorm:"-" json:"date"` // 计算字段:计划开始日 + Day
}
+82
View File
@@ -0,0 +1,82 @@
package model
// 记录类型。原来这 9 种是写在 health_record.go 里的常量,加一种就得发版;
// 挪到表里之后后台可以直接增删改。
//
// 注意这里**没有**替换掉那 9 个常量:weight / poop / food / symptom / cost /
// vaccine 在 record.go、insight.go、report.go、home.go 里有真实的代码分支
// (体重回写宠物档案、食便相关性分析、消费统计、疫苗完成度……),那些分支
// 按具体常量过滤,不是 switch 全枚举。所以:
// - 表负责「展示什么、能记什么」
// - 常量负责「哪几种参与分析」
// 两者各管一段,新增类型不会碰到任何分析逻辑。
// 记录类型分组
const (
RecordGroupDaily = "daily" // 日常
RecordGroupHealth = "health" // 健康
RecordGroupCare = "care" // 洗护
RecordGroupClean = "clean" // 清洁
)
// 弹层形态
const (
RecordFormFull = "full" // 有专用表单(原来那 9 种,各自一个 wx:elif 分支)
RecordFormSimple = "simple" // 通用简易表单:发生时间 + 备注 + 可选照片
)
// RecordType 一种可记录的事项
type RecordType struct {
Base
// Code 就是小程序 bottom-sheet 的 type,也是 health_records.type 落库的值。
// 建过记录之后改 Code 会让历史数据对不上,后台不允许改(只能删了重建)。
Code string `gorm:"size:32;uniqueIndex" json:"code"`
Label string `gorm:"size:32" json:"label"`
Icon string `gorm:"size:32" json:"icon"` // pt-icon 名,见 styles/iconfont.wxss
// GroupKey 存成 group_key 而不是 groupgroup 是 MySQL 保留字,
// GORM 虽然会加反引号,但手写 SQL 排查时很容易踩
GroupKey string `gorm:"column:group_key;size:16;index" json:"group"`
Sort int `json:"sort"`
Enabled bool `gorm:"default:true" json:"enabled"`
Form string `gorm:"size:16" json:"form"`
// Locked 有代码分支或模板引用依赖它,后台不许删。
// 硬拦在 service 层,不是靠前端隐藏按钮。
Locked bool `json:"locked"`
// Fields 表单字段配置,一行一个。后台用紧凑写法编辑:
//
// number:体重:kg 数值,落 num_value
// options:状态:正常|软便|拉稀 单选,落 category
// options:症状:呕吐|拉稀|精神差:- 单选,末尾 - 表示不落 category(只进标题)
// text:吃了什么 文本,只进标题
//
// 空 = 这个事项只要「时间 + 描述 + 照片」(洗澡、换猫砂这类)。
//
// 为什么要有「不落 category」这个开关:异常观察有两个单选(症状 + 严重程度),
// 而 category 只有一个坑,周报的高风险数按 category='高' 统计,
// 所以必须能指定是哪一个占这个坑。
Fields string `gorm:"type:text" json:"fields_raw"`
// ParsedFields 由 Fields 解析出来,前端直接照它渲染表单,不用自己写解析器
ParsedFields []RecordField `gorm:"-" json:"fields"`
}
// 字段类型
const (
FieldNumber = "number"
FieldOptions = "options"
FieldText = "text"
)
// RecordField 一个表单字段
type RecordField struct {
Kind string `json:"kind"`
Label string `json:"label"`
Unit string `json:"unit,omitempty"`
Options []string `json:"options,omitempty"`
// ToCategory 这个字段的值是否落到 health_records.category。
// 同一个事项里最多一个字段能占它
ToCategory bool `json:"to_category"`
}
+19
View File
@@ -0,0 +1,19 @@
package model
import "time"
// RefreshToken 刷新令牌。
// access token(JWT) 只有 2 小时,过期后用它换新的,用户不必重新登录;
// 库里只存 sha256,即使数据库泄露也换不出可用令牌。
// 每次刷新都会轮换(旧的立即作废),旧令牌被再次使用视为泄露。
type RefreshToken struct {
Base
TokenHash string `gorm:"size:64;uniqueIndex" json:"-"`
Kind string `gorm:"size:16;index" json:"kind"` // user | admin
SubjectID string `gorm:"size:24;index" json:"subject_id"` // 用户 ID 或管理员 ID
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
// 作废原因:rotate=正常轮换(有宽限期)/ logout=主动退出 / reuse=检测到泄露被连坐吊销。
// 只有 rotate 允许宽限期内再用一次,否则「吊销全部会话」会被宽限期架空。
RevokedReason string `gorm:"size:16" json:"revoked_reason"`
}
+19 -6
View File
@@ -3,13 +3,26 @@ package model
// User 小程序用户(铲屎官)
type User struct {
Base
OpenID string `gorm:"size:64;index" json:"openid"`
Nickname string `gorm:"size:64" json:"nickname"`
OpenID string `gorm:"size:64;index" json:"openid"`
Nickname string `gorm:"size:64" json:"nickname"`
// 昵称是系统随机生成的(还没被用户或微信授权覆盖过)。
// 有这个标记才能安全地在用户授权时覆盖,而不会把他改过的昵称冲掉。
NicknameAuto bool `json:"nickname_auto"`
PetCount int `gorm:"-" json:"pet_count"` // 计算字段:后台列表用
Avatar string `gorm:"size:512" json:"avatar"`
AvatarFileID string `gorm:"size:24" json:"avatar_file_id"` // 关联 files 表;优先于 Avatar
AvatarURL string `gorm:"-" json:"avatar_url"` // 计算字段:由 AvatarFileID 解析
Phone string `gorm:"size:32" json:"phone"`
Onboarded bool `json:"onboarded"`
Disabled bool `json:"disabled"`
IsBot bool `gorm:"size:24;index" json:"is_bot"` // 虚拟宠友账号(AI 社区运营用)
Phone string `gorm:"size:32" json:"phone"`
Onboarded bool `json:"onboarded"`
Disabled bool `json:"disabled"`
IsBot bool `gorm:"size:24;index" json:"is_bot"` // 虚拟宠友账号(AI 社区运营用)
// —— 个人主页装扮 ——
Bio string `gorm:"size:120" json:"bio"` // 个性签名
BgFileID string `gorm:"size:24" json:"bg_file_id"` // 主页头图,关联 files 表
BgURL string `gorm:"-" json:"bg_url"` // 计算字段:由 BgFileID 解析
Theme string `gorm:"size:16" json:"theme"` // 主题色 key,见 service.UserThemes
// 存「隐藏」而不是「展示」,为的是零值即默认:老数据 AutoMigrate 出来是 0
// 正好等于「展示宠物墙」,不需要给存量用户跑一次刷值
HidePets bool `json:"hide_pets"`
}
+40 -5
View File
@@ -35,6 +35,9 @@ func registerAuth(api *gin.RouterGroup, h *handler.Handler) {
auth := api.Group("/auth")
auth.POST("/login", h.Login)
auth.POST("/wechat", h.WechatLogin)
// 刷新/退出走刷新令牌,不需要(也不可能带)有效的 access token
auth.POST("/refresh", h.RefreshToken)
auth.POST("/logout", h.Logout)
}
// 小程序用户接口(需 user JWT)
@@ -45,6 +48,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.GET("/user/profile", h.Profile)
g.PUT("/user/profile", h.UpdateProfile)
g.GET("/user/summary", h.UserSummary)
g.PUT("/user/decoration", h.UpdateDecoration)
g.GET("/pets", h.ListPets)
g.POST("/pets", h.CreatePet)
@@ -53,26 +57,37 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.PUT("/pets/:id", h.UpdatePet)
g.DELETE("/pets/:id", h.DeletePet)
g.GET("/record-types", h.RecordTypes)
g.GET("/breeds", h.Breeds)
g.GET("/life-stages", h.LifeStages)
g.GET("/pets/:id/records", h.ListRecords)
g.POST("/pets/:id/records", h.CreateRecord)
g.GET("/pets/:id/records/weight-trend", h.WeightTrend)
g.GET("/pets/:id/insights", h.PetInsights)
g.DELETE("/records/:id", h.DeleteRecord)
g.GET("/pets/:id/tasks", h.ListTasks)
g.POST("/pets/:id/tasks", h.CreateTask)
g.POST("/pets/:id/tasks/complete-all", h.CompleteAllTasks)
g.POST("/tasks/:id/toggle", h.ToggleTask)
g.PUT("/tasks/:id", h.UpdateTask)
g.DELETE("/tasks/:id", h.DeleteTask)
g.GET("/pets/:id/home-summary", h.HomeSummary)
g.GET("/pets/:id/plan", h.GetPlan)
g.GET("/pets/:id/plan/calendar", h.PlanCalendar)
g.GET("/pets/:id/stage-drift", h.PetStageDrift)
g.GET("/pets/:id/id-card", h.PetIDCard)
g.GET("/pets/:id/plan/month-status", h.PlanMonthStatus)
// 养护方案:列出可选的、套用一套到某个月
g.GET("/care-templates", h.CareTemplateOptions)
g.POST("/pets/:id/apply-template", h.ApplyTemplate)
g.GET("/pets/:id/day-plan", h.DayPlan)
g.POST("/pets/:id/ai-plan", h.CreateAIPlan)
g.POST("/ai-plan/:id/apply", h.ApplyAIPlan)
g.POST("/plan-tasks/:id/toggle", h.TogglePlanTask)
// 用户自己管理计划节点(原来只能勾选内置模板生成的那些)
g.POST("/pets/:id/plan-tasks", h.AddPlanTask)
g.PUT("/plan-tasks/:id", h.UpdatePlanTask)
g.DELETE("/plan-tasks/:id", h.DeletePlanTask)
g.GET("/pets/:id/reminders", h.ListReminders)
g.POST("/pets/:id/reminders", h.CreateReminder)
@@ -84,6 +99,9 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.GET("/pets/:id/health-summary", h.HealthSummary)
g.GET("/pets/:id/poster", h.Poster)
g.GET("/users/:id/profile", h.UserCard)
g.GET("/users/:id/posts", h.UserPosts)
g.GET("/users/:id/relations", h.Relations)
g.POST("/users/:id/follow", h.FollowUser)
g.DELETE("/users/:id/follow", h.UnfollowUser)
@@ -96,6 +114,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.POST("/posts/:id/comments", h.CreateComment)
g.DELETE("/posts/:id", h.DeleteOwnPost)
g.DELETE("/comments/:id", h.DeleteOwnComment)
g.GET("/comments/:id/replies", h.ListReplies)
g.GET("/articles", h.ListArticles)
g.POST("/feedback", h.CreateFeedback)
@@ -106,6 +125,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.POST("/ai/chat", h.AIChat)
g.GET("/ai/messages", h.ListAIMessages)
g.GET("/ai/quota", h.AIQuota)
g.POST("/pets/:id/ai/assess-symptom", h.AssessSymptom)
g.POST("/upload", h.Upload)
}
@@ -113,6 +133,8 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
// 后台接口(登录开放,其余需 admin JWT)
func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manager) {
api.POST("/admin/login", h.AdminLogin)
api.POST("/admin/refresh", h.AdminRefresh)
api.POST("/admin/logout", h.AdminLogout)
g := api.Group("/admin")
g.Use(middleware.AuthAdmin(jm))
@@ -135,11 +157,24 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag
g.POST("/articles", h.AdminSaveArticle)
g.DELETE("/articles/:id", h.AdminDeleteArticle)
g.GET("/breeds", h.AdminListBreeds)
g.POST("/breeds", h.AdminSaveBreed)
g.DELETE("/breeds/:id", h.AdminDeleteBreed)
g.GET("/record-types", h.AdminListRecordTypes)
g.POST("/record-types", h.AdminSaveRecordType)
g.DELETE("/record-types/:id", h.AdminDeleteRecordType)
g.GET("/care-templates/meta", h.AdminCareTemplateMeta)
g.GET("/care-templates", h.AdminGetCareTemplate)
g.PUT("/care-templates", h.AdminSaveCareTemplate)
g.GET("/care-templates", h.AdminListCareTemplates)
g.GET("/care-templates/:id", h.AdminGetCareTemplate)
g.POST("/care-templates", h.AdminSaveCareTemplate)
g.DELETE("/care-templates/:id", h.AdminDeleteCareTemplate)
g.POST("/care-templates/generate", h.AdminGenerateCareTemplate)
g.GET("/ai-quota", h.AdminGetAIQuota)
g.PUT("/ai-quota", h.AdminSaveAIQuota)
g.GET("/community-bot", h.AdminGetCommunityBot)
g.PUT("/community-bot", h.AdminSaveCommunityBot)
g.POST("/community-bot/generate", h.AdminGenerateCommunityPosts)
+40 -1
View File
@@ -67,12 +67,51 @@ func (s *Service) ListUsers(keyword, userType string, offset, limit int) ([]mode
q.Count(&total)
var users []model.User
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&users).Error
if err == nil {
s.fillPetCounts(users)
}
return users, total, err
}
// fillPetCounts 一次 GROUP BY 查出这一页用户各建了几个宠物档,
// 避免每行一次 count 把列表打成 N+1 查询
func (s *Service) fillPetCounts(users []model.User) {
if len(users) == 0 {
return
}
ids := make([]string, 0, len(users))
for i := range users {
ids = append(ids, users[i].ID)
}
var rows []struct {
UserID string
N int
}
if err := s.db.Model(&model.Pet{}).
Select("user_id, count(*) as n").
Where("user_id IN ?", ids).
Group("user_id").Scan(&rows).Error; err != nil {
return
}
m := make(map[string]int, len(rows))
for _, r := range rows {
m[r.UserID] = r.N
}
for i := range users {
users[i].PetCount = m[users[i].ID]
}
}
// SetUserDisabled 启用/禁用用户
func (s *Service) SetUserDisabled(id string, disabled bool) error {
return s.db.Model(&model.User{}).Where("id = ?", id).Update("disabled", disabled).Error
if err := s.db.Model(&model.User{}).Where("id = ?", id).Update("disabled", disabled).Error; err != nil {
return err
}
if disabled {
// 顺手吊销刷新令牌,否则禁用后对方还能一直续期
s.RevokeAllRefreshTokens("user", id)
}
return nil
}
// ListPetsAdmin 宠物分页
+4
View File
@@ -8,6 +8,10 @@ import (
// AIChat 记录一问一答:启用模型则真调(注入宠物档案),否则规则化文案
func (s *Service) AIChat(userID string, petID *string, session, text string) (string, error) {
// 先扣额度再请求大模型:失败也算用掉一次,否则刷接口空转照样烧钱
if err := s.consumeAIQuota(userID, aiKindChat); err != nil {
return "", err
}
reply := "我会先判断风险等级,再建议你记录关键观察项。若出现频繁呕吐、便血、精神明显变差或持续超过 24 小时,建议尽快就医。"
if s.ai != nil && s.ai.Enabled() {
if r, err := s.llmChat(petID, text); err == nil && r != "" {
+3
View File
@@ -68,6 +68,9 @@ type SymptomResult struct {
// AssessSymptom 异常风险评估:启用模型则结构化输出,否则规则化
func (s *Service) AssessSymptom(userID, petID string, in SymptomInput) (*SymptomResult, error) {
if err := s.consumeAIQuota(userID, aiKindSymptom); err != nil {
return nil, err
}
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
+110
View File
@@ -0,0 +1,110 @@
package service
import (
"errors"
"fmt"
"time"
"github.com/sundynix/pets-be/internal/model"
)
// ErrAIQuotaExceeded 今日 AI 次数用完
var ErrAIQuotaExceeded = errors.New("今日 AI 次数已用完")
// AI 调用是真金白银,不设上限等于把钱包交给用户。
// 额度按「用户 + 自然日」计,从后台配置读,改完立刻生效。
const (
aiKindChat = "chat" // 问问 AI
aiKindSymptom = "symptom" // 异常观察评估
aiKindPlan = "plan" // AI 生成计划
)
// aiQuotaConfig 从后台配置取每日额度。取不到就用保守默认值,
// 绝不「取不到就不限制」——那正是配置出问题时最不该发生的事。
func (s *Service) aiQuotaConfig() model.AIQuotaConfig {
var c model.AIQuotaConfig
if err := s.db.First(&c, "id = ?", "1").Error; err != nil {
return model.AIQuotaConfig{DailyChat: 20, DailySymptom: 10, DailyPlan: 5, Enabled: true}
}
return c
}
// aiDailyLimit 某类 AI 功能的每日上限;<=0 表示不限
func (s *Service) aiDailyLimit(kind string) (int, bool) {
c := s.aiQuotaConfig()
if !c.Enabled {
return 0, false
}
switch kind {
case aiKindChat:
return c.DailyChat, true
case aiKindSymptom:
return c.DailySymptom, true
case aiKindPlan:
return c.DailyPlan, true
}
return 0, false
}
// consumeAIQuota 扣一次额度。超限返回 ErrAIQuotaExceeded,调用方要在真正
// 请求大模型之前调用它——扣完再调,失败也算用掉一次,避免刷接口空转烧钱。
func (s *Service) consumeAIQuota(userID, kind string) error {
limit, on := s.aiDailyLimit(kind)
if !on || limit <= 0 {
return nil
}
day := time.Now().Format("2006-01-02")
var u model.AIUsage
err := s.db.Where("user_id = ? AND day = ? AND kind = ?", userID, day, kind).First(&u).Error
if err == nil {
if u.Count >= limit {
return fmt.Errorf("%w(每天 %d 次,明天恢复)", ErrAIQuotaExceeded, limit)
}
return s.db.Model(&model.AIUsage{}).Where("id = ?", u.ID).
UpdateColumn("count", u.Count+1).Error
}
return s.db.Create(&model.AIUsage{UserID: userID, Day: day, Kind: kind, Count: 1}).Error
}
// AIQuotaLeft 返回各类今日剩余次数,给小程序显示
func (s *Service) AIQuotaLeft(userID string) map[string]any {
c := s.aiQuotaConfig()
day := time.Now().Format("2006-01-02")
var rows []model.AIUsage
s.db.Where("user_id = ? AND day = ?", userID, day).Find(&rows)
used := map[string]int{}
for _, r := range rows {
used[r.Kind] = r.Count
}
left := func(limit int, kind string) int {
if !c.Enabled || limit <= 0 {
return -1 // -1 表示不限
}
if n := limit - used[kind]; n > 0 {
return n
}
return 0
}
return map[string]any{
"enabled": c.Enabled,
"chat": left(c.DailyChat, aiKindChat),
"symptom": left(c.DailySymptom, aiKindSymptom),
"plan": left(c.DailyPlan, aiKindPlan),
}
}
// GetAIQuotaConfig / SaveAIQuotaConfig 后台读写
func (s *Service) GetAIQuotaConfig() model.AIQuotaConfig { return s.aiQuotaConfig() }
func (s *Service) SaveAIQuotaConfig(in model.AIQuotaConfig) (model.AIQuotaConfig, error) {
c := s.aiQuotaConfig()
c.ID = "1"
c.Enabled = in.Enabled
c.DailyChat = maxInt(in.DailyChat, 0)
c.DailySymptom = maxInt(in.DailySymptom, 0)
c.DailyPlan = maxInt(in.DailyPlan, 0)
if err := s.db.Save(&c).Error; err != nil {
return c, err
}
return c, nil
}
+9 -4
View File
@@ -16,13 +16,14 @@ import (
// MockLogin 开发态登录:按昵称找回或新建用户
func (s *Service) MockLogin(nickname string) (*model.User, error) {
if nickname == "" {
nickname = "毛孩子用户"
autoName := nickname == ""
if autoName {
nickname = s.UniqueNickname()
}
var u model.User
err := s.db.Where("nickname = ?", nickname).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
u = model.User{Nickname: nickname}
u = model.User{Nickname: nickname, NicknameAuto: autoName}
if e := s.db.Create(&u).Error; e != nil {
return nil, e
}
@@ -89,7 +90,7 @@ func (s *Service) WechatLogin(code string) (*model.User, error) {
var u model.User
err = s.db.Where("open_id = ?", sess.OpenID).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
u = model.User{OpenID: sess.OpenID, Nickname: "微信用户"}
u = model.User{OpenID: sess.OpenID, Nickname: s.UniqueNickname(), NicknameAuto: true}
if e := s.db.Create(&u).Error; e != nil {
return nil, e
}
@@ -111,6 +112,10 @@ func (s *Service) GetUser(userID string) (*model.User, error) {
return nil, err
}
u.AvatarURL = s.fileURL(u.AvatarFileID)
u.BgURL = s.fileURL(u.BgFileID)
if !UserThemes[u.Theme] {
u.Theme = defaultUserTheme // 老用户 theme 是空串,别让前端拿空 key 去查表
}
return &u, nil
}
+114
View File
@@ -0,0 +1,114 @@
package service
import (
"errors"
"log"
"sort"
"strconv"
"strings"
"github.com/sundynix/pets-be/internal/model"
)
// BreedGroup 同一首字母下的品种。小程序端的 A-Z 索引直接照这个渲染
type BreedGroup struct {
Initial string `json:"initial"`
Items []model.Breed `json:"items"`
}
// ListBreeds 某个物种的品种,按首字母分组。只返回启用的
func (s *Service) ListBreeds(species string) []BreedGroup {
if species != model.SpeciesCat && species != model.SpeciesDog {
return []BreedGroup{}
}
var all []model.Breed
s.db.Where("species = ? AND enabled = ?", species, true).
Order("initial asc, sort asc, id asc").Find(&all)
if len(all) == 0 {
// 不硬编码兜底一份:那等于把「后台可配」又变回代码常量,
// 改了配置的人会发现改了没用。宁可让选择器明显是空的
log.Printf("[warn] %s 没有任何启用的品种,建档第二步会选不了品种", species)
return []BreedGroup{}
}
byInitial := map[string][]model.Breed{}
for _, b := range all {
k := strings.ToUpper(b.Initial)
if k == "" {
k = "#" // 没填首字母的兜到最后,别让它凭空消失
}
byInitial[k] = append(byInitial[k], b)
}
keys := make([]string, 0, len(byInitial))
for k := range byInitial {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]BreedGroup, 0, len(keys))
for _, k := range keys {
out = append(out, BreedGroup{Initial: k, Items: byInitial[k]})
}
return out
}
// AdminListBreeds 后台用:连停用的一起返回
func (s *Service) AdminListBreeds(species string) []model.Breed {
q := s.db.Order("species asc, initial asc, sort asc, id asc")
if species == model.SpeciesCat || species == model.SpeciesDog {
q = q.Where("species = ?", species)
}
var all []model.Breed
q.Find(&all)
if all == nil {
return []model.Breed{}
}
return all
}
// SaveBreed 新增或更新
func (s *Service) SaveBreed(in *model.Breed) error {
in.Name = strings.TrimSpace(in.Name)
in.Initial = strings.ToUpper(strings.TrimSpace(in.Initial))
if in.Name == "" {
return ErrInvalidParam
}
if in.Species != model.SpeciesCat && in.Species != model.SpeciesDog {
return errors.New("物种只能是 cat 或 dog")
}
if len(in.Initial) != 1 || in.Initial[0] < 'A' || in.Initial[0] > 'Z' {
return errors.New("首字母要填一个 A-Z 的字母(用来做侧边索引)")
}
// 同物种下不允许同名:品种是拿来做统计口径的,重名会把同一种猫劈成两半
var n int64
q := s.db.Model(&model.Breed{}).Where("species = ? AND name = ?", in.Species, in.Name)
if in.ID != "" {
q = q.Where("id <> ?", in.ID)
}
q.Count(&n)
if n > 0 {
return errors.New("这个物种下已经有「" + in.Name + "」了")
}
if in.ID == "" {
return s.db.Create(in).Error
}
return s.db.Model(&model.Breed{}).Where("id = ?", in.ID).Updates(map[string]any{
"species": in.Species, "name": in.Name, "initial": in.Initial,
"sort": in.Sort, "enabled": in.Enabled,
}).Error
}
// DeleteBreed 已经有宠物在用的品种不许删——删了那些档案的品种就变成一个
// 查不到的字符串。想让它不再被选到应该用 enabled=false
func (s *Service) DeleteBreed(id string) error {
var b model.Breed
if err := s.db.First(&b, "id = ?", id).Error; err != nil {
return ErrNotFound
}
var used int64
s.db.Model(&model.Pet{}).Where("breed = ?", b.Name).Count(&used)
if used > 0 {
return errors.New("已经有 " + strconv.FormatInt(used, 10) + " 个档案填了「" +
b.Name + "」,删了它们的品种就查不到了。想隐藏请改成停用")
}
return s.db.Delete(&model.Breed{}, "id = ?", id).Error
}
+260
View File
@@ -0,0 +1,260 @@
package service
import (
"errors"
"strconv"
"strings"
"time"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
)
// 套用养护方案:把模板里的重复规则展开成某个月的计划节点。
//
// 为什么要展开成一条条实体行、而不是「存规则、读时展开」:
// 勾选状态是按天的。存规则的话得再开一张表记「哪天打过勾」,
// 而且用户改一条(比如把「每周二梳毛」这周挪到周三)就要引入例外记录。
// 一次展开成行,增删改和打勾全都免费。
//
// 代价是套用之后模板和计划就断开了:后台改模板不会影响已套用的宠物。
// 这是有意的——用户已经在自己的计划上勾了几十个勾,后台一改就被覆盖才是灾难。
// TemplateOption 建档/计划页上给用户挑的一套方案
type TemplateOption struct {
ID string `json:"id"`
Name string `json:"name"`
Summary string `json:"summary"`
// 按重复方式分开报数。只给一个总数会骗人:10 条明细里有 4 条是「每天」,
// 套用一整月会展开成 130 条节点,用户看到「10 条」再看到 130 条会以为出 bug 了
Daily int `json:"daily"` // 每天做的
Weekly int `json:"weekly"` // 每周做的
Once int `json:"once"` // 一次性的
Monthly int `json:"monthly"` // 每月做的
Reminds int `json:"reminders"` // 会建的提醒
}
// ListTemplateOptions 某物种阶段下可选的方案。空列表意味着后台没配,
// 前端要显示「暂无方案,可以自己排」而不是转圈
func (s *Service) ListTemplateOptions(species, stage string) []TemplateOption {
var tpls []model.CareTemplate
s.db.Preload("Items").
Where("species = ? AND stage = ? AND enabled = ?", species, stage, true).
Order("sort asc, id asc").Find(&tpls)
out := make([]TemplateOption, 0, len(tpls))
for _, t := range tpls {
o := TemplateOption{ID: t.ID, Name: t.Name, Summary: t.Summary}
if o.Name == "" {
o.Name = t.Stage + "养护方案" // 老数据没名字,兜一个别显示空白
}
for _, it := range t.Items {
switch {
case it.Kind == model.CareKindReminder:
o.Reminds++
case it.Repeat == model.RepeatDaily:
o.Daily++
case it.Repeat == model.RepeatWeekly:
o.Weekly++
case it.Repeat == model.RepeatMonthly:
o.Monthly++
default:
o.Once++
}
}
out = append(out, o)
}
return out
}
// weekdaysOf 解析 "1,3,5" → {1:true,3:true,5:true}1=周一
func weekdaysOf(s string) map[int]bool {
out := map[int]bool{}
for _, p := range strings.Split(s, ",") {
n, err := strconv.Atoi(strings.TrimSpace(p))
if err == nil && n >= 1 && n <= 7 {
out[n] = true
}
}
return out
}
// isoWeekday 把 Go 的 Sunday=0 换成 1=周一 … 7=周日
func isoWeekday(t time.Time) int {
d := int(t.Weekday())
if d == 0 {
return 7
}
return d
}
// expandItem 把一条明细展开成若干个日期。
// from/to 是套用窗口(通常是「今天」到「本月最后一天」)
func expandItem(it model.CareTemplateItem, from, to time.Time) []time.Time {
var out []time.Time
switch it.Repeat {
case model.RepeatDaily:
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
out = append(out, d)
}
case model.RepeatWeekly:
wd := weekdaysOf(it.Weekdays)
if len(wd) == 0 {
return nil // 配了每周却没选星期几,展开不出东西,跳过比排错天好
}
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
if wd[isoWeekday(d)] {
out = append(out, d)
}
}
case model.RepeatMonthly:
md := it.MonthDay
if md < 1 {
md = 1
}
// 逐月走,因为窗口可能跨月
for cur := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, from.Location()); !cur.After(to); cur = cur.AddDate(0, 1, 0) {
last := cur.AddDate(0, 1, -1).Day()
day := md
if day > last {
day = last // 「每月 31 号」在 2 月按月末算,不然这个月直接没有
}
d := time.Date(cur.Year(), cur.Month(), day, 0, 0, 0, 0, from.Location())
if !d.Before(from) && !d.After(to) {
out = append(out, d)
}
}
default: // once:从窗口起点偏移 N 天
d := from.AddDate(0, 0, it.Day)
// 偏移出窗口的直接丢掉。硬塞到窗口末尾会让「到家第 14 天驱虫」
// 变成「月底驱虫」,日期错了比没有更糟
if !d.Before(from) && !d.After(to) {
out = append(out, d)
}
}
return out
}
// ApplyTemplateResult 套用结果,给前端一句反馈用
type ApplyTemplateResult struct {
Plans int `json:"plans"` // 排进了几条计划
Reminders int `json:"reminders"` // 建了几条提醒
From string `json:"from"`
To string `json:"to"`
}
// ApplyTemplate 把一套方案套用到某只宠物的某个月。
// monthOffset: 0 本月,1 下月。本月只从今天算起——给用户排一堆昨天的事没意义
func (s *Service) ApplyTemplate(userID, petID, templateID string, monthOffset int) (*ApplyTemplateResult, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
var tpl model.CareTemplate
if e := s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("sort, id") }).
First(&tpl, "id = ?", templateID).Error; e != nil {
return nil, ErrNotFound
}
first, last := monthBounds(monthOffset)
from := first
if monthOffset == 0 {
today := time.Now()
t := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.Local)
if t.After(from) {
from = t
}
}
if from.After(last) {
return nil, errors.New("这个月已经过完了")
}
plan, err := s.planOf(userID, petID)
if err != nil {
return nil, err
}
start := time.Date(plan.StartDate.Year(), plan.StartDate.Month(), plan.StartDate.Day(),
0, 0, 0, 0, time.Local)
// 已经有的节点按「日期+标题」记下来,套用不产生重复。
// 用户可能先自己排了「每天喂药」,再套一套含同样一条的方案
var exist []model.PlanTask
s.db.Where("plan_id = ?", plan.ID).Find(&exist)
seen := map[string]bool{}
for _, t := range exist {
d := start.AddDate(0, 0, t.Day)
seen[d.Format("2006-01-02")+"|"+t.Title] = true
}
var rows []model.PlanTask
for _, it := range tpl.Items {
if it.Kind == model.CareKindReminder {
continue
}
for _, d := range expandItem(it, from, last) {
key := d.Format("2006-01-02") + "|" + it.Title
if seen[key] {
continue
}
seen[key] = true
day := int(d.Sub(start).Hours() / 24)
if day < 0 {
continue // 早于计划开始日的排不进去
}
rows = append(rows, model.PlanTask{
PlanID: plan.ID, Day: day, DayLabel: dayLabelFor(day),
Title: it.Title, Description: it.Description, SheetType: it.SheetType,
})
}
}
res := &ApplyTemplateResult{From: from.Format("2006-01-02"), To: last.Format("2006-01-02")}
if len(rows) > 0 {
if e := s.db.Create(&rows).Error; e != nil {
return nil, e
}
res.Plans = len(rows)
}
// 提醒只在第一次套用时建:它是「到期提醒」,按物种阶段来就一份,
// 套两次方案不该出现两条疫苗提醒
res.Reminders = s.applyTemplateReminders(pet, tpl.Items)
return res, nil
}
// applyTemplateReminders 建模板里的提醒,已存在同标题的跳过
func (s *Service) applyTemplateReminders(pet *model.Pet, items []model.CareTemplateItem) int {
var exist []model.Reminder
s.db.Where("pet_id = ?", pet.ID).Find(&exist)
seen := map[string]bool{}
for _, r := range exist {
seen[r.Title] = true
}
goals := petGoals(pet)
var rows []model.Reminder
for _, it := range items {
if it.Kind != model.CareKindReminder || seen[it.Title] {
continue
}
if !goalWantsReminder(goals, it.ReminderType) {
continue
}
seen[it.Title] = true
r := model.Reminder{
PetID: pet.ID, UserID: pet.UserID, Type: it.ReminderType,
Title: it.Title, Frequency: it.Frequency,
}
if it.OffsetDays > 0 {
// dueDaysFor 会按宠物月龄调整(幼猫的「下一针」不该按成年猫的周期算)
due := time.Now().AddDate(0, 0, dueDaysFor(it, pet, time.Now()))
r.NextDueDate = &due
}
rows = append(rows, r)
}
if len(rows) == 0 {
return 0
}
if err := s.db.Create(&rows).Error; err != nil {
return 0
}
return len(rows)
}
+119 -198
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
@@ -12,9 +13,27 @@ import (
"github.com/sundynix/pets-be/internal/model"
)
// CareSpecies / CareStages 支持的物种与阶段(阶段字符串与 onboarding 一致)
// 阶段常量、CareStages 和判定逻辑都搬到 life_stage.go 了:
// 那边一张表既驱动判定又直接吐给前端当「划分依据」,两个用途一份数据。
// 阶段从 3 段(幼年/成年/老年)细分成 5 段,狗还按体型走不同阈值。
// CareSpecies 支持的物种(字符串与 onboarding 一致)
var CareSpecies = []string{model.SpeciesCat, model.SpeciesDog}
var CareStages = []string{"刚到家 0-30 天", "幼年期", "成年期", "老年期"}
// monthsSince 从某天到今天的整月数(不足一月算 0)
func monthsSince(t time.Time) int {
now := time.Now()
m := (now.Year()-t.Year())*12 + int(now.Month()) - int(t.Month())
if now.Day() < t.Day() {
m--
}
if m < 0 {
m = 0
}
return m
}
// SpeciesOf 由宠物类型(猫猫/狗狗)映射到模板物种键
func SpeciesOf(petType string) string {
@@ -34,64 +53,113 @@ func validStage(s string) bool {
return false
}
// careTemplateItems 取某组合的模板明细DB 优先,无则回退内置默认
// careTemplateItems 取某组合的模板明细。模板是后台配的内容数据,代码里不再留
// 一套内置默认——没配就是没配,这里返回空,由调用方决定怎么处理(见 seedPetDefaults)。
func (s *Service) careTemplateItems(species, stage string) []model.CareTemplateItem {
var tpl model.CareTemplate
err := s.db.
Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }).
Where("species = ? AND stage = ?", species, stage).First(&tpl).Error
if err == nil && len(tpl.Items) > 0 {
return tpl.Items
if err != nil {
return nil
}
return builtinCareItems(species, stage)
return tpl.Items
}
// GetCareTemplate 后台读取:返回某组合的明细(DB 优先,无则内置默认)
func (s *Service) GetCareTemplate(species, stage string) (map[string]any, error) {
if !validSpecies(species) || !validStage(stage) {
return nil, errors.New("invalid species or stage")
// AdminListTemplates 后台:全部方案(含停用),按物种阶段排
func (s *Service) AdminListTemplates() []model.CareTemplate {
var all []model.CareTemplate
s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }).
Order("species asc, stage asc, sort asc, id asc").Find(&all)
if all == nil {
return []model.CareTemplate{}
}
return map[string]any{
"species": species,
"stage": stage,
"items": s.careTemplateItems(species, stage),
}, nil
return all
}
// AdminGetTemplate 后台:取一套
func (s *Service) AdminGetTemplate(id string) (*model.CareTemplate, error) {
var tpl model.CareTemplate
if err := s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }).
First(&tpl, "id = ?", id).Error; err != nil {
return nil, ErrNotFound
}
return &tpl, nil
}
// DeleteTemplate 删一套方案。已经套用出去的计划节点不受影响——
// 套用时就已经展开成实体行、和模板断开了
func (s *Service) DeleteTemplate(id string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("template_id = ?", id).Delete(&model.CareTemplateItem{}).Error; err != nil {
return err
}
return tx.Delete(&model.CareTemplate{}, "id = ?", id).Error
})
}
// SaveCareTemplate 后台保存:整表替换该组合的明细
func (s *Service) SaveCareTemplate(species, stage string, items []model.CareTemplateItem) error {
if !validSpecies(species) || !validStage(stage) {
return errors.New("invalid species or stage")
// SaveTemplate 存一套方案。id 为空则新建 —— 同一个 (物种, 阶段) 可以有多套,
// 用户建档时挑一套。原来是「按 species+stage 覆盖」,那个模型下
// 「让用户选方案」根本无从选起
func (s *Service) SaveTemplate(in *model.CareTemplate, items []model.CareTemplateItem) error {
if !validSpecies(in.Species) || !validStage(in.Stage) {
return errors.New("物种或阶段不合法")
}
if strings.TrimSpace(in.Name) == "" {
return errors.New("方案要有名字 —— 用户建档时看到的就是它")
}
// 明细先校验一遍再落库:一条配错(比如「每周」没勾星期几)会让套用时
// 静默少排几条,用户看不出来是配错了
clean := make([]model.CareTemplateItem, 0, len(items))
for i := range items {
it := items[i]
it.Title = strings.TrimSpace(it.Title)
if it.Title == "" {
continue
}
if it.Kind != model.CareKindReminder {
it.Kind = model.CareKindPlan
switch it.Repeat {
case model.RepeatDaily, model.RepeatMonthly:
case model.RepeatWeekly:
if len(weekdaysOf(it.Weekdays)) == 0 {
return errors.New("「" + it.Title + "」选了每周,但没勾星期几")
}
default:
it.Repeat = model.RepeatOnce
}
if it.Repeat == model.RepeatMonthly && (it.MonthDay < 1 || it.MonthDay > 31) {
return errors.New("「" + it.Title + "」选了每月,几号要填 1-31")
}
}
it.ID = ""
it.Sort = i
clean = append(clean, it)
}
return s.db.Transaction(func(tx *gorm.DB) error {
var tpl model.CareTemplate
err := tx.Where("species = ? AND stage = ?", species, stage).First(&tpl).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
tpl = model.CareTemplate{Species: species, Stage: stage}
if err := tx.Create(&tpl).Error; err != nil {
if in.ID == "" {
if err := tx.Create(in).Error; err != nil {
return err
}
} else if err != nil {
return err
}
if err := tx.Where("template_id = ?", tpl.ID).Delete(&model.CareTemplateItem{}).Error; err != nil {
return err
}
clean := make([]model.CareTemplateItem, 0, len(items))
for i := range items {
it := items[i]
if strings.TrimSpace(it.Title) == "" {
continue
} else {
if err := tx.Model(&model.CareTemplate{}).Where("id = ?", in.ID).Updates(map[string]any{
"species": in.Species, "stage": in.Stage, "name": in.Name,
"summary": in.Summary, "sort": in.Sort, "enabled": in.Enabled,
}).Error; err != nil {
return err
}
it.ID = ""
it.TemplateID = tpl.ID
it.Sort = i
clean = append(clean, it)
if err := tx.Where("template_id = ?", in.ID).Delete(&model.CareTemplateItem{}).Error; err != nil {
return err
}
}
for i := range clean {
clean[i].TemplateID = in.ID
}
if len(clean) > 0 {
if err := tx.Create(&clean).Error; err != nil {
return err
}
return tx.Create(&clean).Error
}
return nil
})
@@ -104,7 +172,7 @@ func (s *Service) GenerateCareTemplate(species, stage string) ([]model.CareTempl
return nil, errors.New("invalid species or stage")
}
if !s.ai.Enabled() {
return builtinCareItems(species, stage), nil
return nil, errors.New("AI 未开启,无法生成草稿;请手动添加明细")
}
spName := "猫"
if species == model.SpeciesDog {
@@ -124,11 +192,11 @@ func (s *Service) GenerateCareTemplate(species, stage string) ([]model.CareTempl
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
if err != nil {
return builtinCareItems(species, stage), nil
return nil, fmt.Errorf("AI 生成失败:%w", err)
}
items, ok := parseCareItems(out)
if !ok || len(items) == 0 {
return builtinCareItems(species, stage), nil
return nil, errors.New("AI 返回的内容解析不出可用明细,请重试或手动添加")
}
return items, nil
}
@@ -140,7 +208,7 @@ func parseCareItems(out string) ([]model.CareTemplateItem, bool) {
Title, Description, Priority, SheetType string
} `json:"tasks"`
Plan []struct {
Day int
Day int
DayLabel, Title, Description, SheetType string
} `json:"plan"`
Reminders []struct {
@@ -173,165 +241,18 @@ func parseCareItems(out string) ([]model.CareTemplateItem, bool) {
return items, len(items) > 0
}
// SeedCareTemplates 启动幂等 seed:库中无任何模板时写入 8 套内置默认
func (s *Service) SeedCareTemplates() error {
var count int64
if err := s.db.Model(&model.CareTemplate{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
for _, sp := range CareSpecies {
for _, stage := range CareStages {
tpl := model.CareTemplate{Species: sp, Stage: stage, Items: builtinCareItems(sp, stage)}
if err := s.db.Create(&tpl).Error; err != nil {
return err
}
}
}
return nil
}
// ---- 明细构造小工具 ----
// ---- 明细构造小工具(供 AI 草稿解析用)----
// careTask 原来产出 kind=task(今日任务)。task 和 plan 已经合并——
// 「每天要做的事」就是「repeat=daily 的计划节点」,AI 草稿里那部分照这个走
func careTask(title, desc, priority, sheet string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindTask, Title: title, Description: desc, Priority: priority, SheetType: sheet}
return model.CareTemplateItem{Kind: model.CareKindPlan, Repeat: model.RepeatDaily,
Title: title, Description: desc, Priority: priority, SheetType: sheet}
}
func carePlan(day int, label, title, desc, sheet string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindPlan, Day: day, DayLabel: label, Title: title, Description: desc, SheetType: sheet}
return model.CareTemplateItem{Kind: model.CareKindPlan, Repeat: model.RepeatOnce,
Day: day, DayLabel: label, Title: title, Description: desc, SheetType: sheet}
}
func careReminder(rtype, title string, offset int, freq string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindReminder, ReminderType: rtype, Title: title, OffsetDays: offset, Frequency: freq}
}
// builtinCareItems 内置默认模板(猫/狗 × 4 阶段,共 8 套)。
// 兽医常识向:狗每个阶段都含遛狗/牵引,猫含猫砂盆/梳毛/饮水;疫苗驱虫节奏按物种阶段区分。
func builtinCareItems(species, stage string) []model.CareTemplateItem {
if species == model.SpeciesDog {
return builtinDog(stage)
}
return builtinCat(stage)
}
func builtinCat(stage string) []model.CareTemplateItem {
switch stage {
case "刚到家 0-30 天":
return []model.CareTemplateItem{
careTask("记录一次体重", "到家先称个底,之后每周一次看增长", "重要", "weight"),
careTask("清理猫砂盆并观察", "换环境应激常见软便,留意颜色和次数", "", "poop"),
careTask("陪玩 15 分钟", "逗猫棒建立信任,缓解应激,别强抱", "", ""),
carePlan(0, "今天", "安顿与环境适应", "猫砂盆、食盆、水碗、猫窝固定位置,给它安静空间", "taskDetail"),
carePlan(3, "第3天", "观察进食与排便", "食欲和便便恢复正常说明适应良好", "poop"),
carePlan(7, "第7天", "第一次体重记录", "作为后续趋势基线", "weight"),
carePlan(14, "第14天", "预约首次疫苗/体检", "确认医院、驱虫和免疫计划", "vaccine"),
careReminder("vaccine", "首次疫苗接种", 14, ""),
careReminder("deworm", "首次体内外驱虫", 7, ""),
careReminder("weight", "体重记录", 0, "每周一次"),
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
}
case "幼年期":
return []model.CareTemplateItem{
careTask("记录一次体重", "幼猫长得快,每周记 2 次", "重要", "weight"),
careTask("梳毛并陪玩", "每天梳毛+两次互动,帮助社会化、减少毛球", "", ""),
careTask("检查疫苗计划", "幼猫三针联苗按时打,别漏针", "重要", "vaccine"),
carePlan(0, "今天", "确认免疫与驱虫计划", "对照月龄核对疫苗针次和驱虫时间", "vaccine"),
carePlan(3, "第3天", "观察饮水与排便", "换粮期留意软便,7 天过渡换粮", "poop"),
carePlan(7, "第7天", "体重趋势检查", "每周称重,稳定增长是关键指标", "weight"),
carePlan(14, "第14天", "驱虫与饮食复盘", "确认驱虫到期、复盘换粮情况", "medicine"),
careReminder("vaccine", "第 2 针疫苗", 18, ""),
careReminder("deworm", "体内外驱虫", 30, ""),
careReminder("weight", "体重记录", 0, "每周二、周五"),
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
}
case "老年期":
return []model.CareTemplateItem{
careTask("记录一次体重", "老年猫消瘦要警惕,别忽视", "重要", "weight"),
careTask("关注饮水量与排尿", "多饮多尿常是肾病/糖尿信号,异常尽快就医", "重要", "symptom"),
careTask("清理猫砂盆并观察", "关注尿量、尿色和排尿是否困难", "", "poop"),
carePlan(0, "今天", "老年健康自查", "精神、食欲、饮水、体重整体过一遍", "taskDetail"),
carePlan(7, "第7天", "体重与饮水记录", "两项一起看,慢病早发现", "weight"),
carePlan(14, "第14天", "用药与慢病回顾", "整理长期用药和体检结果", "medicine"),
carePlan(30, "第30天", "月度报告 / 建议体检", "老年猫建议每半年一次体检", "report"),
careReminder("vaccine", "年度疫苗加强", 30, ""),
careReminder("deworm", "体内外驱虫", 90, ""),
careReminder("weight", "体重记录", 0, "每两周一次"),
careReminder("monthlyReport", "月度报告 / 半年体检", 0, "每月 1 日"),
}
default: // 成年期
return []model.CareTemplateItem{
careTask("记录一次体重", "成年猫每月一次,防止肥胖", "", "weight"),
careTask("清理猫砂盆并观察", "泌尿问题早发现,公猫尤其注意尿闭", "", "poop"),
careTask("梳毛与口腔检查", "关注毛球、牙结石和皮肤状态", "", ""),
carePlan(0, "今天", "全面健康自查", "体重、精神、食欲、饮水、被毛过一遍", "taskDetail"),
carePlan(7, "第7天", "体重趋势检查", "对比上月,稳定为宜", "weight"),
carePlan(14, "第14天", "驱虫记录复盘", "确认体内外驱虫是否到期", "medicine"),
carePlan(30, "第30天", "生成月度报告", "回顾本月记录,导出成长卡片", "report"),
careReminder("vaccine", "年度疫苗加强", 30, ""),
careReminder("deworm", "体内外驱虫", 90, ""),
careReminder("weight", "体重记录", 0, "每月一次"),
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
}
}
}
func builtinDog(stage string) []model.CareTemplateItem {
switch stage {
case "刚到家 0-30 天":
return []model.CareTemplateItem{
careTask("记录一次体重", "到家先称个底,之后每周一次", "重要", "weight"),
careTask("定点排便训练", "固定厕所位置,成功就表扬,别惩罚", "", "poop"),
careTask("室内活动 / 自家院子", "疫苗未打全前少去人多狗多的地方,先在家熟悉环境", "", ""),
carePlan(0, "今天", "安顿与环境适应", "食盆、水碗、狗窝、厕所固定位置", "taskDetail"),
carePlan(3, "第3天", "观察进食与排便", "应激期常见软便,留意精神状态", "poop"),
carePlan(7, "第7天", "第一次体重记录", "作为后续趋势基线", "weight"),
carePlan(14, "第14天", "预约疫苗 / 体检", "确认联苗、狂犬和驱虫计划", "vaccine"),
careReminder("vaccine", "首针疫苗接种", 14, ""),
careReminder("deworm", "首次体内外驱虫", 7, ""),
careReminder("weight", "体重记录", 0, "每周一次"),
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
}
case "幼年期":
return []model.CareTemplateItem{
careTask("遛狗与基础训练", "每天 2 次,牵引、坐下、召回,社会化关键期", "重要", ""),
careTask("记录一次体重", "幼犬长得快,每周记 2 次", "重要", "weight"),
careTask("观察饮水与排便", "换粮、驱虫期留意软便", "", "poop"),
carePlan(0, "今天", "确认免疫与驱虫计划", "联苗+狂犬按月龄打,别漏针", "vaccine"),
carePlan(1, "明天", "定点排便 + 牵引训练", "打好如厕和牵引基础", "taskDetail"),
carePlan(7, "第7天", "体重趋势检查", "每周称重,稳定增长", "weight"),
carePlan(14, "第14天", "驱虫与社会化复盘", "确认驱虫到期、多接触人和环境", "medicine"),
careReminder("vaccine", "下一针联苗", 18, ""),
careReminder("deworm", "体内外驱虫", 30, ""),
careReminder("weight", "体重记录", 0, "每周二、周五"),
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
}
case "老年期":
return []model.CareTemplateItem{
careTask("温和遛狗", "缩短时长、避免剧烈跳跃,保护关节", "重要", ""),
careTask("记录一次体重", "骤增骤减都要重视", "重要", "weight"),
careTask("观察关节 / 精神 / 饮水", "后腿无力、多饮多尿要及时就医", "", "symptom"),
carePlan(0, "今天", "老年健康自查", "精神、食欲、饮水、关节活动一起看", "taskDetail"),
carePlan(7, "第7天", "体重与饮水记录", "慢病早发现", "weight"),
carePlan(14, "第14天", "用药与慢病回顾", "整理长期用药和体检结果", "medicine"),
carePlan(30, "第30天", "月度报告 / 建议体检", "老年犬建议每半年一次体检", "report"),
careReminder("vaccine", "年度疫苗 + 狂犬加强", 30, ""),
careReminder("deworm", "体内外驱虫", 60, ""),
careReminder("weight", "体重记录", 0, "每两周一次"),
careReminder("monthlyReport", "月度报告 / 半年体检", 0, "每月 1 日"),
}
default: // 成年期
return []model.CareTemplateItem{
careTask("遛狗", "每天 2 次共 30-60 分钟,消耗精力、防拆家", "重要", ""),
careTask("记录一次体重", "每月一次,控制体重防肥胖", "", "weight"),
careTask("刷牙 / 口腔检查", "每周 2-3 次,狗牙结石高发", "", ""),
carePlan(0, "今天", "全面健康自查", "体重、精神、食欲、被毛、指甲过一遍", "taskDetail"),
carePlan(7, "第7天", "体重趋势检查", "对比上月,稳定为宜", "weight"),
carePlan(14, "第14天", "驱虫记录复盘", "遛狗接触多,确认驱虫是否到期", "medicine"),
carePlan(30, "第30天", "生成月度报告", "回顾本月记录,导出成长卡片", "report"),
careReminder("vaccine", "年度疫苗 + 狂犬加强", 30, ""),
careReminder("deworm", "体内外驱虫", 60, ""),
careReminder("weight", "体重记录", 0, "每月一次"),
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
}
}
}
+86 -11
View File
@@ -193,23 +193,67 @@ func (s *Service) postLikeCount(postID string) (int, error) {
// ListComments 评论分页
func (s *Service) ListComments(userID, postID string, offset, limit int) ([]model.Comment, int64, error) {
q := s.db.Model(&model.Comment{}).Where("post_id = ? AND status = ?", postID, "published")
base := func() *gorm.DB {
return s.db.Model(&model.Comment{}).Where("post_id = ? AND status = ?", postID, "published")
}
// total 是这条帖子的评论总数(含回复),和帖子上显示的数字对得上
var total int64
if err := q.Count(&total).Error; err != nil {
if err := base().Count(&total).Error; err != nil {
return nil, 0, err
}
var comments []model.Comment
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error; err != nil {
// 只分页一级评论,回复跟着它爹一起返回
var roots []model.Comment
if err := base().Where("parent_id = ? OR parent_id IS NULL", "").
Order("id desc").Offset(offset).Limit(limit).Find(&roots).Error; err != nil {
return nil, 0, err
}
for i := range comments {
comments[i].IsSelf = comments[i].UserID == userID
if len(roots) == 0 {
return []model.Comment{}, total, nil
}
return comments, total, nil
ids := make([]string, 0, len(roots))
for i := range roots {
roots[i].IsSelf = roots[i].UserID == userID
ids = append(ids, roots[i].ID)
}
// 一次查完这一页所有一级评论的回复,不要每条一次查询
var replies []model.Comment
s.db.Where("parent_id IN ? AND status = ?", ids, "published").Order("id asc").Find(&replies)
byParent := map[string][]model.Comment{}
for i := range replies {
replies[i].IsSelf = replies[i].UserID == userID
byParent[replies[i].ParentID] = append(byParent[replies[i].ParentID], replies[i])
}
for i := range roots {
list := byParent[roots[i].ID]
if list == nil {
list = []model.Comment{} // 空切片而不是 nil,否则 JSON 出来是 null
}
roots[i].ReplyN = len(list)
// 默认只带前 3 条,其余等前端点「展开」再要,避免热门评论一次拉几百条
if len(list) > 3 {
list = list[:3]
}
roots[i].Replies = list
}
return roots, total, nil
}
// ListReplies 展开某条一级评论下的全部回复
func (s *Service) ListReplies(userID, commentID string) ([]model.Comment, error) {
var list []model.Comment
if err := s.db.Where("parent_id = ? AND status = ?", commentID, "published").
Order("id asc").Find(&list).Error; err != nil {
return nil, err
}
for i := range list {
list[i].IsSelf = list[i].UserID == userID
}
return list, nil
}
// CreateComment 评论
func (s *Service) CreateComment(userID, postID string, content string) (*model.Comment, error) {
func (s *Service) CreateComment(userID, postID string, content string, images []string, parentID string) (*model.Comment, error) {
if _, err := s.GetPost(postID); err != nil {
return nil, err
}
@@ -218,7 +262,27 @@ func (s *Service) CreateComment(userID, postID string, content string) (*model.C
if err := s.db.First(&user, userID).Error; err == nil && user.Nickname != "" {
name = user.Nickname
}
comment := model.Comment{PostID: postID, UserID: userID, AuthorName: name, Content: content, Status: "published"}
// 回复的回复仍然挂到同一条一级评论下,保持两级不塌成深树
replyTo := ""
if parentID != "" {
var parent model.Comment
if err := s.db.Where("id = ? AND post_id = ?", parentID, postID).First(&parent).Error; err != nil {
return nil, ErrNotFound
}
if parent.ParentID != "" {
replyTo = parent.AuthorName
parentID = parent.ParentID
}
}
comment := model.Comment{
PostID: postID, UserID: userID, AuthorName: name, Content: content,
Status: "published", ParentID: parentID, ReplyToName: replyTo,
}
// 配图存 JSON 数组。空数组也要显式写,否则前端拿到 null 还要额外判空
if b, err := json.Marshal(images); err == nil && len(images) > 0 {
comment.Images = b
}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&comment).Error; err != nil {
return err
@@ -261,7 +325,18 @@ func (s *Service) DeleteOwnComment(userID, commentID string) error {
Update("status", "deleted").Error; err != nil {
return err
}
return tx.Model(&model.Post{}).Where("id = ? AND comment_count > 0", c.PostID).
UpdateColumn("comment_count", gorm.Expr("comment_count - 1")).Error
// 删一级评论要连它下面的回复一起删,否则回复会变成挂在空处的孤儿
n := int64(1)
if c.ParentID == "" {
res := tx.Model(&model.Comment{}).
Where("parent_id = ? AND status = ?", commentID, "published").
Update("status", "deleted")
if res.Error != nil {
return res.Error
}
n += res.RowsAffected
}
return tx.Model(&model.Post{}).Where("id = ? AND comment_count >= ?", c.PostID, n).
UpdateColumn("comment_count", gorm.Expr("comment_count - ?", n)).Error
})
}
+4
View File
@@ -216,6 +216,8 @@ type UserSummary struct {
Pets int64 `json:"pets"`
Records int64 `json:"records"`
Reminders int64 `json:"reminders"`
Following int64 `json:"following"` // 我关注了多少人
Followers int64 `json:"followers"` // 多少人关注我
ProStatus string `json:"pro_status"`
}
@@ -225,6 +227,8 @@ func (s *Service) GetUserSummary(userID string) (*UserSummary, error) {
s.db.Model(&model.Pet{}).Where("user_id = ?", userID).Count(&sum.Pets)
s.db.Model(&model.HealthRecord{}).Where("user_id = ?", userID).Count(&sum.Records)
s.db.Model(&model.Reminder{}).Where("user_id = ?", userID).Count(&sum.Reminders)
s.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Count(&sum.Following)
s.db.Model(&model.Follow{}).Where("followee_id = ?", userID).Count(&sum.Followers)
sum.ProStatus = model.ProNone
var m model.ProMembership
+89
View File
@@ -0,0 +1,89 @@
package service
import (
"log"
"strings"
"github.com/sundynix/pets-be/internal/model"
)
// IDCard 宠物身份卡的全部内容,前端拿去 canvas 上画
type IDCard struct {
Name string `json:"name"`
Species string `json:"species"` // 猫猫 / 狗狗
Gender string `json:"gender"`
Breed string `json:"breed"`
Birthday string `json:"birthday"` // "2023 年 12 月 15 日"
Intro string `json:"intro"`
AvatarURL string `json:"avatar_url"`
IDNo string `json:"id_no"` // 玩具身份号,不入库
Validity string `json:"validity"` // "2023.12.15 - 永远"
QRURL string `json:"qr_url"` // 小程序码,未发布时为空
QRReady bool `json:"qr_ready"` // 码有没有生成出来
}
// petIDNo 造一个稳定的「身份号」。纯装饰,不入库、不做校验。
// 规律照竞品那种:一串数字,扫不出真实含义但看着像证件号。
// 用 宠物雪花 id 的数字 + 生日 拼,同一只宠物每次都一样
func petIDNo(pet *model.Pet) string {
digits := make([]byte, 0, 18)
for i := 0; i < len(pet.ID) && len(digits) < 10; i++ {
if pet.ID[i] >= '0' && pet.ID[i] <= '9' {
digits = append(digits, pet.ID[i])
}
}
for len(digits) < 10 {
digits = append(digits, '0')
}
bd := "00000000"
if pet.Birthday != nil && !pet.Birthday.IsZero() {
bd = pet.Birthday.Format("20060102")
}
raw := string(digits[:10]) + bd // 18 位
// 每 4 位空一下,像证件号
var b strings.Builder
for i, c := range raw {
if i > 0 && i%4 == 0 {
b.WriteByte(' ')
}
b.WriteRune(c)
}
return b.String()
}
// GetIDCard 组装身份卡。小程序码是 best-effort:拿不到(未发布/配额)也照出卡
func (s *Service) GetIDCard(userID, petID string) (*IDCard, error) {
pet, err := s.GetPet(userID, petID)
if err != nil {
return nil, err
}
intro := "地球来了个新居民~"
switch pet.Type {
case "狗狗":
intro = "汪星人来地球啦~"
case "猫猫":
intro = "喵星人来地球啦~"
}
bd, validity := "未知", "永久有效"
if pet.Birthday != nil && !pet.Birthday.IsZero() {
bd = pet.Birthday.Format("2006 年 1 月 2 日")
validity = pet.Birthday.Format("2006.01.02") + " - 永远"
}
card := &IDCard{
Name: pet.Name, Species: pet.Type, Gender: pet.Gender, Breed: pet.Breed,
Birthday: bd, Intro: intro, AvatarURL: pet.AvatarURL,
IDNo: petIDNo(pet), Validity: validity,
}
// 小程序码取不到不算错——未发布/配额用尽时卡照样出,只是没码
if url, e := s.PetQRCode(petID); e == nil {
card.QRURL = url
card.QRReady = true
} else {
log.Printf("[info] 宠物 %s 的小程序码暂不可用(多为小程序未发布):%v", petID, e)
}
return card, nil
}
+287
View File
@@ -0,0 +1,287 @@
package service
import (
"fmt"
"sort"
"time"
"github.com/sundynix/pets-be/internal/model"
)
// Insight 一条健康洞察。
// 这是「记录」这件事的兑现方式:用户记了几十条数据,之前只换回一条折线,
// 数据没有回到他身上。洞察要说出他自己看不出来的东西。
type Insight struct {
Kind string `json:"kind"` // trend | correlation | overdue | benchmark | habit
Level string `json:"level"` // info | warn | alert
Title string `json:"title"` // 一句话结论
Detail string `json:"detail"` // 依据 + 建议
Action string `json:"action"` // 关联的记录弹层类型,前端据此给按钮
Evidence string `json:"evidence"` // 支撑这条结论的具体数据
}
const (
insightInfo = "info"
insightWarn = "warn"
insightAlert = "alert"
)
// PetInsights 汇总一只宠物当前值得说的事。按严重度排序,最多 6 条。
func (s *Service) PetInsights(userID, petID string) ([]Insight, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
// 只看最近 90 天。更早的数据对「现在怎么样」没有参考价值,
// 还会让关联分析找出一堆巧合。
since := time.Now().AddDate(0, 0, -90)
var records []model.HealthRecord
if err := s.db.Where("pet_id = ? AND occurred_at >= ?", petID, since).
Order("occurred_at asc").Find(&records).Error; err != nil {
return nil, err
}
var out []Insight
out = appendIf(out, s.weightTrendInsight(records))
out = appendIf(out, s.foodPoopCorrelation(records))
out = appendIf(out, s.symptomClusterInsight(records))
out = appendIf(out, s.overdueReminderInsight(petID))
out = appendIf(out, s.weightBenchmark(pet, records))
out = appendIf(out, s.recordHabitInsight(records))
// alert > warn > info,同级保持原顺序
rank := map[string]int{insightAlert: 0, insightWarn: 1, insightInfo: 2}
sort.SliceStable(out, func(i, j int) bool { return rank[out[i].Level] < rank[out[j].Level] })
if len(out) > 6 {
out = out[:6]
}
return out, nil
}
func appendIf(list []Insight, in *Insight) []Insight {
if in != nil {
list = append(list, *in)
}
return list
}
func byType(records []model.HealthRecord, t string) []model.HealthRecord {
var out []model.HealthRecord
for _, r := range records {
if r.Type == t {
out = append(out, r)
}
}
return out
}
// weightTrendInsight 体重趋势。单次数字没意义,连续同向变化才有。
func (s *Service) weightTrendInsight(records []model.HealthRecord) *Insight {
w := byType(records, model.RecordWeight)
if len(w) < 3 {
return nil
}
first, last := w[0], w[len(w)-1]
if first.NumValue <= 0 || last.NumValue <= 0 {
return nil
}
days := int(last.OccurredAt.Sub(first.OccurredAt).Hours() / 24)
if days < 14 {
return nil // 时间跨度太短,波动多半是称重误差
}
pct := (last.NumValue - first.NumValue) / first.NumValue * 100
ev := fmt.Sprintf("%d 天内 %.2fkg → %.2fkg%d 次记录)", days, first.NumValue, last.NumValue, len(w))
switch {
case pct <= -10:
return &Insight{
Kind: "trend", Level: insightAlert, Action: "symptom",
Title: fmt.Sprintf("体重掉了 %.0f%%,建议就医检查", -pct),
Detail: "短期内明显掉秤,常见于甲亢、慢性肾病、糖尿病、寄生虫和口腔问题。即使精神食欲看起来正常,也建议做一次血检。",
Evidence: ev,
}
case pct >= 15:
return &Insight{
Kind: "trend", Level: insightWarn, Action: "food",
Title: fmt.Sprintf("体重涨了 %.0f%%,注意控制", pct),
Detail: "超重是绝大多数慢性病的起点。摸得到肋骨但看不见明显轮廓才是标准体型,先从减少 10-20% 的喂食量开始。",
Evidence: ev,
}
case pct <= -5:
return &Insight{
Kind: "trend", Level: insightWarn, Action: "weight",
Title: fmt.Sprintf("体重在缓慢下降(%.0f%%", -pct),
Detail: "还不到紧急程度,但值得继续盯。保持每周称重,如果一个月内继续下降就去做次体检。",
Evidence: ev,
}
}
return &Insight{
Kind: "trend", Level: insightInfo, Action: "weight",
Title: "体重保持稳定",
Detail: "这段时间的波动在正常范围内,继续保持现在的喂食量和运动量。",
Evidence: ev,
}
}
// foodPoopCorrelation 换粮/饮食变化 → 排便异常的时间关联。
// 这是用户自己最看不出来的一类:两条记录隔了两三天,翻时间轴翻不出规律。
func (s *Service) foodPoopCorrelation(records []model.HealthRecord) *Insight {
foods := byType(records, model.RecordFood)
poops := byType(records, model.RecordPoop)
if len(foods) == 0 || len(poops) == 0 {
return nil
}
for _, p := range poops {
if p.Category == "" || p.Category == "正常" {
continue
}
// 往前找 3 天内的饮食记录
for i := len(foods) - 1; i >= 0; i-- {
f := foods[i]
gap := p.OccurredAt.Sub(f.OccurredAt)
if gap < 0 || gap > 72*time.Hour {
continue
}
return &Insight{
Kind: "correlation", Level: insightWarn, Action: "food",
Title: fmt.Sprintf("这次「%s」之前 %d 天有过饮食变化", p.Category, int(gap.Hours()/24)),
Detail: "换粮或加新食物引起的软便很常见。回到原来的粮,之后按 7 天过渡:每天替换约 1/7,软便就放慢速度。持续超过 3 天或带血要就医。",
Evidence: fmt.Sprintf("%s 记录「%s」→ %s 出现「%s」",
f.OccurredAt.Format("1月2日"), f.Title, p.OccurredAt.Format("1月2日"), p.Category),
}
}
}
return nil
}
// symptomClusterInsight 异常记录扎堆。单次异常不说明什么,短期内反复才是信号。
func (s *Service) symptomClusterInsight(records []model.HealthRecord) *Insight {
sym := byType(records, model.RecordSymptom)
if len(sym) < 2 {
return nil
}
recent := 0
cut := time.Now().AddDate(0, 0, -14)
var last time.Time
for _, r := range sym {
if r.OccurredAt.After(cut) {
recent++
last = r.OccurredAt
}
}
if recent < 2 {
return nil
}
return &Insight{
Kind: "habit", Level: insightAlert, Action: "symptom",
Title: fmt.Sprintf("两周内记了 %d 次异常", recent),
Detail: "反复出现的异常和偶发一次完全不是一回事。把这些记录导出成就医摘要带去医院,比口头描述有用得多。",
Evidence: fmt.Sprintf("最近一次:%s", last.Format("1月2日")),
}
}
// overdueReminderInsight 已经过期的提醒。设了不看等于没设。
func (s *Service) overdueReminderInsight(petID string) *Insight {
var rems []model.Reminder
if err := s.db.Where("pet_id = ? AND next_due_date IS NOT NULL AND next_due_date < ?",
petID, time.Now()).Order("next_due_date asc").Find(&rems).Error; err != nil {
return nil
}
if len(rems) == 0 {
return nil
}
r := rems[0]
days := int(time.Since(*r.NextDueDate).Hours() / 24)
action := "vaccine"
if r.Type == model.ReminderDeworm {
action = "medicine"
}
return &Insight{
Kind: "overdue", Level: insightAlert, Action: action,
Title: fmt.Sprintf("「%s」已经过期 %d 天", r.Title, days),
Detail: "疫苗和驱虫拖久了保护力会断档,尤其驱虫,遛弯和接触其它动物的风险是持续的。补上之后记得把下次日期也设好。",
Evidence: fmt.Sprintf("原定 %s", r.NextDueDate.Format("2006年1月2日")),
}
}
// weightBenchmark 同龄同物种对比。
// 这是单机数据做不到、只有平台侧能给的价值。样本不足就不给结论——
// 拿 3 只猫算出来的「中位数」比不给还糟。
func (s *Service) weightBenchmark(pet *model.Pet, records []model.HealthRecord) *Insight {
w := byType(records, model.RecordWeight)
if len(w) == 0 || pet.Birthday == nil || pet.Birthday.IsZero() {
return nil
}
mine := w[len(w)-1].NumValue
if mine <= 0 {
return nil
}
months := monthsSince(*pet.Birthday)
// 同物种、月龄相差 2 个月以内的其它宠物,取各自最近一次体重
lo := time.Now().AddDate(0, -(months + 2), 0)
hi := time.Now().AddDate(0, -maxInt(months-2, 0), 0)
var peers []float64
rows, err := s.db.Raw(`
SELECT r.num_value FROM sundynix_health_records r
JOIN (
SELECT pet_id, MAX(occurred_at) AS t FROM sundynix_health_records
WHERE type = ? GROUP BY pet_id
) latest ON latest.pet_id = r.pet_id AND latest.occurred_at = latest.t
JOIN sundynix_pets p ON p.id = r.pet_id
WHERE r.type = ? AND r.num_value > 0 AND p.id <> ?
AND p.type = ? AND p.birthday BETWEEN ? AND ?`,
model.RecordWeight, model.RecordWeight, pet.ID, pet.Type, lo, hi).Rows()
if err != nil {
return nil
}
defer rows.Close()
for rows.Next() {
var v float64
if rows.Scan(&v) == nil && v > 0 {
peers = append(peers, v)
}
}
// 样本太少给不出可信的参考值,宁可不显示
if len(peers) < 8 {
return nil
}
sort.Float64s(peers)
med := peers[len(peers)/2]
diff := (mine - med) / med * 100
level, title := insightInfo, "体重和同龄伙伴差不多"
if diff >= 20 {
level, title = insightWarn, fmt.Sprintf("比同龄伙伴重 %.0f%%", diff)
} else if diff <= -20 {
level, title = insightWarn, fmt.Sprintf("比同龄伙伴轻 %.0f%%", -diff)
}
return &Insight{
Kind: "benchmark", Level: level, Action: "weight",
Title: title,
Detail: "这只是同月龄的横向参考,品种和体型差异很大,不能当成标准。真正要看的还是它自己的体重曲线是否平稳。",
Evidence: fmt.Sprintf("%d 月龄 · 它 %.2fkg%d 只同龄%s的中位数 %.2fkg",
months, mine, len(peers), pet.Type, med),
}
}
// recordHabitInsight 记录习惯。没数据就没洞察,这条负责把用户拉回来记。
func (s *Service) recordHabitInsight(records []model.HealthRecord) *Insight {
if len(records) >= 5 {
return nil
}
return &Insight{
Kind: "habit", Level: insightInfo, Action: "weight",
Title: "再记几条,这里就能看出规律了",
Detail: "体重、便便、饮食三类各记几次之后,能自动帮你发现「换粮第几天开始软便」这种自己翻记录看不出来的关联。",
Evidence: fmt.Sprintf("目前共 %d 条记录", len(records)),
}
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
+250
View File
@@ -0,0 +1,250 @@
package service
import (
"fmt"
"time"
"github.com/sundynix/pets-be/internal/model"
)
// 生命阶段的划分标准。
//
// 这张表既用来判定阶段,也直接吐给前端当「划分依据」展示 —— 一份数据两个用途,
// 而不是「代码里判定 + 文档里写标准」。后者一定会漂:改了阈值忘了改文档,
// 用户看到的说明和实际行为对不上,比没有说明更糟。
//
// ── 为什么猫狗不能共用阈值 ──
//
// 猫的发育在品种间差别不大。狗完全取决于体型:
// 吉娃娃 8 个月已经性成熟、可以换成犬粮;大丹犬 8 个月还在长骨头,
// 这时候喂成犬粮(钙磷比和热量密度都不同)会加重关节负担。
// 吉娃娃 7 岁还在成年期(8 岁才进中年,能活 13-16 年);
// 大丹犬 7 岁已经是老年(平均寿命 8-10 年)。
//
// 原来代码里是一刀切「<12 月幼年,>=7 岁老年」,对猫勉强,对狗两头都错。
//
// 体型不能靠当前体重判断 —— 幼犬的体重说明不了它成年后多大。只能靠品种,
// 所以 breeds 表带 size_class。
// 五个阶段。标签故意做成物种中立(不分「幼猫期/幼犬期」):
// 物种本来就是另一个字段,分开会让 (species, stage) 的组合和模板配置都翻倍。
const (
StageJuvenile = "幼年期"
StageYoung = "青年期"
StageAdult = "成年期"
StageMature = "中年期"
StageSenior = "老年期"
// StageNewHome 和年龄正交:刚接回家的成年猫两套照护都需要。
// 它不参与年龄判定,是叠加上去的一层。
StageNewHome = "刚到家 0-30 天"
)
// CareStages 后台配方案时可选的阶段。刚到家放最前面,它是所有宠物都会经历的
var CareStages = []string{StageNewHome, StageJuvenile, StageYoung, StageAdult, StageMature, StageSenior}
// 犬类体型。分界取自成年体重,是行业通行的分法
const (
SizeSmall = "small" // < 10kg
SizeMedium = "medium" // 10-25kg
SizeLarge = "large" // 25-40kg
SizeGiant = "giant" // > 40kg
)
// SizeLabels 体型的中文名和依据,后台和小程序都用它
var SizeLabels = map[string]string{
SizeSmall: "小型犬(成年 <10kg",
SizeMedium: "中型犬(成年 10-25kg",
SizeLarge: "大型犬(成年 25-40kg",
SizeGiant: "巨型犬(成年 >40kg",
}
// StageRule 一个阶段的月龄区间和依据
type StageRule struct {
Stage string `json:"stage"`
FromMon int `json:"from_month"` // 含
ToMon int `json:"to_month"` // 不含;0 = 无上限
Range string `json:"range"` // 展示用,如「6-12 月」
Basis string `json:"basis"` // 划分依据,直接展示给用户
Focus string `json:"focus"` // 这个阶段的养护重点
}
// 猫:AAFP/AAHA 猫生命阶段指南那套,在「成年」里细分出中年
var catStages = []StageRule{
{StageJuvenile, 0, 6, "0-6 月", "断奶到六月龄是生长最快的阶段,三针核心疫苗和首次驱虫都在这里完成",
"疫苗序列、驱虫、社会化、少量多餐"},
{StageYoung, 6, 12, "6-12 月", "六月龄前后性成熟,绝育窗口;一岁前后换成猫粮",
"绝育安排、换成猫粮、开始控体重"},
{StageAdult, 12, 84, "1-7 岁", "身体状态最稳定的时期,重点是把基线数据记下来",
"每月称重、年度体检、口腔护理"},
{StageMature, 84, 132, "7-11 岁", "七岁起代谢变慢,年度血检开始能查出早期问题",
"年度血检、体重和牙结石重点看"},
{StageSenior, 132, 0, "11 岁以上", "十一岁后肾脏和甲状腺问题高发,体检频率要加倍",
"半年一次体检、肾和甲状腺、饮水量重点记"},
}
// 狗:同样五段,阈值按体型走。越大的犬种生长期越长、老得越早
var dogStages = map[string][]StageRule{
SizeSmall: {
{StageJuvenile, 0, 8, "0-8 月", "小型犬生长期短,八月龄基本定型", "疫苗序列、驱虫、社会化"},
{StageYoung, 8, 12, "8-12 月", "性成熟早,绝育窗口也早", "绝育安排、换成犬粮"},
{StageAdult, 12, 96, "1-8 岁", "小型犬寿命长,成年期也长", "每月称重、年度体检、牙结石"},
{StageMature, 96, 132, "8-11 岁", "八岁起进入中年,心脏和牙齿是重点", "年度血检、心脏听诊、洁牙"},
{StageSenior, 132, 0, "11 岁以上", "小型犬普遍能活到 13-16 岁,十一岁后算老年", "半年一次体检、关节和心脏"},
},
SizeMedium: {
{StageJuvenile, 0, 12, "0-12 月", "一岁前后完成生长", "疫苗序列、驱虫、社会化"},
{StageYoung, 12, 18, "12-18 月", "骨骼刚闭合,可以开始正常运动量", "换成犬粮、运动量上调"},
{StageAdult, 18, 84, "1.5-7 岁", "维持期", "每月称重、年度体检、牙结石"},
{StageMature, 84, 120, "7-10 岁", "七岁起体重和关节要重点看", "年度血检、体重控制、关节"},
{StageSenior, 120, 0, "10 岁以上", "中型犬平均寿命 10-13 岁", "半年一次体检、关节和肿瘤筛查"},
},
SizeLarge: {
{StageJuvenile, 0, 15, "0-15 月", "大型犬生长期长到 15 个月,这期间不能过量补钙或过早剧烈运动",
"控制生长速度、疫苗序列、避免跳跃"},
{StageYoung, 15, 24, "15-24 月", "两岁前骨骼才完全闭合", "换成犬粮、逐步加运动量"},
{StageAdult, 24, 72, "2-6 岁", "维持期较短", "每月称重、年度体检、髋关节"},
{StageMature, 72, 96, "6-8 岁", "六岁起就要按中年管,比中小型犬早", "年度血检、髋关节、体重控制"},
{StageSenior, 96, 0, "8 岁以上", "大型犬平均寿命 8-12 岁,八岁进老年", "半年一次体检、关节和心脏"},
},
SizeGiant: {
{StageJuvenile, 0, 18, "0-18 月", "巨型犬生长期最长,18 个月还在长;这阶段最忌过度补钙",
"严格控制生长速度、疫苗序列、限制跳跃"},
{StageYoung, 18, 24, "18-24 月", "两岁才算成年", "换成犬粮、关节保养开始"},
{StageAdult, 24, 60, "2-5 岁", "成年期很短", "每月称重、年度体检、心脏和关节"},
{StageMature, 60, 84, "5-7 岁", "五岁就要按中年管 —— 巨型犬老得最快", "半年一次血检、心脏、体重"},
{StageSenior, 84, 0, "7 岁以上", "巨型犬平均寿命 7-10 岁", "每半年体检、心脏和骨科肿瘤"},
},
}
// StageRules 某个物种(狗还要看体型)的完整划分标准,给前端展示用
func StageRules(species, sizeClass string) []StageRule {
if species == model.SpeciesDog {
if rs, ok := dogStages[sizeClass]; ok {
return rs
}
return dogStages[SizeMedium] // 品种没标体型时按中型算,最不容易错得离谱
}
return catStages
}
// stageOfMonths 按月龄在规则表里落位
func stageOfMonths(rules []StageRule, months int) string {
for _, r := range rules {
if months >= r.FromMon && (r.ToMon == 0 || months < r.ToMon) {
return r.Stage
}
}
return StageAdult
}
// StageFromAge 由物种、体型、月龄推断阶段
func StageFromAge(species, sizeClass string, months int) string {
if months < 0 {
return StageAdult // 没有生日只能按成年算,它是最不容易出错的默认
}
return stageOfMonths(StageRules(species, sizeClass), months)
}
// sizeClassOfBreed 查品种的体型。查不到返回空,上层按中型兜底
func (s *Service) sizeClassOfBreed(species, breed string) string {
if species != model.SpeciesDog || breed == "" {
return ""
}
var b model.Breed
if err := s.db.Where("species = ? AND name = ?", species, breed).First(&b).Error; err != nil {
return ""
}
return b.SizeClass
}
// StageOf 由生日推断阶段(狗会查品种体型)
func (s *Service) StageOf(species, breed string, birthday *time.Time) string {
if birthday == nil || birthday.IsZero() {
return StageAdult
}
return StageFromAge(species, s.sizeClassOfBreed(species, breed), monthsSince(*birthday))
}
// NormalizeStage 定下最终使用的阶段。
// 用户手选的优先(领养的猫生日是估的,主人按体型判断更准;而模型里的月龄阈值
// 对混种和边界体型都是一刀切);没选或选了不认识的值才按生日推。
func (s *Service) NormalizeStage(species, breed, picked string, birthday *time.Time) string {
for _, v := range CareStages {
if picked == v {
return picked
}
}
return s.StageOf(species, breed, birthday)
}
// AgeLabel 月龄的中文展示。不入库 —— 存下来第二天就过期了
func AgeLabel(birthday *time.Time) string {
if birthday == nil || birthday.IsZero() {
return ""
}
m := monthsSince(*birthday)
if m < 0 {
return ""
}
if m < 24 {
return fmt.Sprintf("%d个月", m)
}
if m%12 == 0 {
return fmt.Sprintf("%d岁", m/12)
}
return fmt.Sprintf("%d岁%d个月", m/12, m%12)
}
// StageDrift 「档案里存的阶段」和「按生日算出来的阶段」不一致。
//
// 为什么不自动改:
// 1. 用户可能手改过 —— 领养的猫生日是估的,主人按体型判断比月龄阈值准
// 2. 已经排好的这个月计划不该被推翻。套用方案是「往里加」,不是「替换」
// 3. 偷偷换会让用户莫名发现计划变了,找不到原因
//
// 所以系统只负责发现,用户负责决定:小程序端提示「它已经进入成年期了,
// 要不要换一套方案」,点了才套用。
type StageDrift struct {
Drifted bool `json:"drifted"`
Stored string `json:"stored"` // 档案里现在存的
Computed string `json:"computed"` // 按生日算出来的
AgeLabel string `json:"age_label"` // 「1岁3个月」
Basis string `json:"basis"` // 新阶段的划分依据,一并给出来
Focus string `json:"focus"` // 新阶段的养护重点
Size string `json:"size"` // 犬类的体型说明,猫为空
}
// CheckStageDrift 检查某只宠物的阶段有没有漂
func (s *Service) CheckStageDrift(pet *model.Pet) *StageDrift {
out := &StageDrift{Stored: pet.Stage, AgeLabel: AgeLabel(pet.Birthday)}
if pet.Birthday == nil || pet.Birthday.IsZero() {
return out // 没生日算不了,不提示
}
// 「刚到家」是叠加层不是年龄段,处在这个状态时不谈漂移
if pet.Stage == StageNewHome {
return out
}
species := SpeciesOf(pet.Type)
size := s.sizeClassOfBreed(species, pet.Breed)
out.Computed = StageFromAge(species, size, monthsSince(*pet.Birthday))
if species == model.SpeciesDog {
if l, ok := SizeLabels[size]; ok {
out.Size = l
} else {
out.Size = SizeLabels[SizeMedium] + "(品种未标体型,按中型算)"
}
}
if out.Computed == out.Stored || out.Computed == "" {
return out
}
out.Drifted = true
for _, r := range StageRules(species, size) {
if r.Stage == out.Computed {
out.Basis = r.Basis
out.Focus = r.Focus
break
}
}
return out
}
+45
View File
@@ -0,0 +1,45 @@
package service
import (
"crypto/rand"
"fmt"
"math/big"
"github.com/sundynix/pets-be/internal/model"
)
// 新用户默认昵称的前缀。微信登录拿不到昵称(要用户主动授权),
// 全都叫「微信用户」的话,社区里一屏全是同名,后台也没法区分。
var nickPrefixes = []string{
"铲屎官", "猫奴", "狗子家长", "毛孩子家长", "养宠新手",
"肉垫爱好者", "撸猫达人", "遛狗选手", "猫窝管理员", "狗窝管理员",
}
// UniqueNickname 生成一个库里没有的昵称,形如「铲屎官12138」。
// 5 位数字 × 10 个前缀 = 百万级空间,撞了就重试;连撞 20 次说明
// 随机源出问题了,退回带更长数字的形式,绝不返回空。
func (s *Service) UniqueNickname() string {
for i := 0; i < 20; i++ {
name := fmt.Sprintf("%s%05d", pick(nickPrefixes), randInt(100000))
var count int64
if err := s.db.Model(&model.User{}).Where("nickname = ?", name).Count(&count).Error; err != nil {
return name // 查不了就直接用,重名也比登录失败强
}
if count == 0 {
return name
}
}
return fmt.Sprintf("%s%09d", pick(nickPrefixes), randInt(1000000000))
}
func pick(list []string) string {
return list[randInt(int64(len(list)))]
}
func randInt(n int64) int64 {
v, err := rand.Int(rand.Reader, big.NewInt(n))
if err != nil {
return 0
}
return v.Int64()
}
+161 -48
View File
@@ -1,6 +1,8 @@
package service
import (
"encoding/json"
"log"
"strings"
"time"
@@ -12,14 +14,15 @@ import (
// PetInput 建/改宠物入参
type PetInput struct {
Name string
Emoji string
Type string
Gender string
Birthday *time.Time
Weight string
Stage string
Age string
Name string
Emoji string
Type string
Gender string
Birthday *time.Time
ArrivedAt *time.Time
Weight string
Stage string
Age string
Color string
Breed string
AvatarFileID string
@@ -48,6 +51,11 @@ func (s *Service) ListPets(userID string) ([]model.Pet, error) {
urls := s.fileURLs(ids)
for i := range pets {
pets[i].AvatarURL = urls[pets[i].AvatarFileID]
// Age 和 Stage 都是「会过期」的值。Age 直接现算(不入库);
// Stage 存着但要校准 —— 半年前建档时是幼年期,现在可能已经进青年期了。
// 校准只改返回值不写库:用户手改过的阶段不该被静默冲掉,
// 「该换一套方案了」这件事要提示他、由他决定(见 StageDrift)
pets[i].Age = AgeLabel(pets[i].Birthday)
}
return pets, err
}
@@ -57,6 +65,7 @@ func (s *Service) GetPet(userID, petID string) (*model.Pet, error) {
p, err := s.ownedPet(userID, petID)
if err == nil && p != nil {
p.AvatarURL = s.fileURL(p.AvatarFileID)
p.Age = AgeLabel(p.Birthday)
}
return p, err
}
@@ -70,6 +79,7 @@ func (s *Service) CreatePet(userID string, in PetInput) (*model.Pet, error) {
Type: in.Type,
Gender: in.Gender,
Birthday: in.Birthday,
ArrivedAt: in.ArrivedAt,
Weight: normalizeWeight(in.Weight),
Stage: in.Stage,
Age: in.Age,
@@ -87,6 +97,9 @@ func (s *Service) CreatePet(userID string, in PetInput) (*model.Pet, error) {
}); err != nil {
return nil, err
}
// Age 是计算字段,创建后的返回也得填 —— 不填前端拿到的是空字符串
pet.Age = AgeLabel(pet.Birthday)
pet.AvatarURL = s.fileURL(pet.AvatarFileID)
return &pet, nil
}
@@ -143,54 +156,154 @@ func (s *Service) Onboarding(userID string, in PetInput) (*model.Pet, error) {
}
// seedPetDefaults 为新宠按「物种 × 阶段」养护模板生成今日任务、提醒、30 天计划
// seedPetDefaults 建档时只做一件事:建「必须有」的到期提醒(疫苗、驱虫)。
//
// 原来它会自动把整套模板铺开成今日任务 + 30 天计划。改成不自动铺了——
// 用户在建档最后一步自己挑方案,或者选「先空着我自己排」。
// 自动铺的问题是:用户还没看懂这个 App 是干什么的,首页已经躺着十几条
// 他没答应过的任务,第一反应是「这些是什么」而不是「我该做什么」。
//
// 疫苗驱虫这两条例外,因为漏针是真会出事的,而且它们是「到期提醒」不是
// 「今天要做的事」——不占计划页的格子,只在到期时冒出来。
func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
today := time.Now()
items := s.careTemplateItems(SpeciesOf(pet.Type), pet.Stage)
var tasks []model.DailyTask
var reminders []model.Reminder
var planTasks []model.PlanTask
for _, it := range items {
switch it.Kind {
case model.CareKindTask:
tasks = append(tasks, model.DailyTask{
PetID: pet.ID, UserID: pet.UserID, TaskDate: today,
Title: it.Title, Description: it.Description, Priority: it.Priority, SheetType: it.SheetType,
})
case model.CareKindPlan:
planTasks = append(planTasks, model.PlanTask{
Day: it.Day, DayLabel: it.DayLabel, Title: it.Title, Description: it.Description, SheetType: it.SheetType,
})
case model.CareKindReminder:
r := model.Reminder{
PetID: pet.ID, UserID: pet.UserID, Type: it.ReminderType,
Title: it.Title, Frequency: it.Frequency,
}
if it.OffsetDays > 0 {
due := today.AddDate(0, 0, it.OffsetDays)
r.NextDueDate = &due
}
reminders = append(reminders, r)
}
species := SpeciesOf(pet.Type)
items := s.careTemplateItems(species, pet.Stage)
if len(items) == 0 {
log.Printf("care: 养护方案为空 species=%s stage=%s,新宠 %s 建档后没有可选方案,请到后台配置",
species, pet.Stage, pet.ID)
}
if len(tasks) > 0 {
if err := tx.Create(&tasks).Error; err != nil {
return err
goals := petGoals(pet)
now := time.Now()
var reminders []model.Reminder
seen := map[string]bool{}
for _, it := range items {
if it.Kind != model.CareKindReminder || seen[it.Title] {
continue
}
if !goalWantsReminder(goals, it.ReminderType) {
continue
}
seen[it.Title] = true
r := model.Reminder{
PetID: pet.ID, UserID: pet.UserID, Type: it.ReminderType,
Title: it.Title, Frequency: it.Frequency,
}
if it.OffsetDays > 0 {
due := now.AddDate(0, 0, dueDaysFor(it, pet, now))
r.NextDueDate = &due
}
reminders = append(reminders, r)
}
if len(reminders) > 0 {
if err := tx.Create(&reminders).Error; err != nil {
return err
}
}
start := today
end := today.AddDate(0, 0, 30)
plan := model.Plan{
PetID: pet.ID, UserID: pet.UserID, Kind: model.PlanThirtyDay, Stage: pet.Stage,
StartDate: &start, EndDate: &end, CompletionPct: 0, Status: "active",
Tasks: planTasks,
}
return tx.Create(&plan).Error
return nil
}
// ---- 建档默认值的几个判断 ----
// isNewHome 到家不足 30 天。没填到家日期的按「不是新到家」处理,
// 宁可少排几条安置期任务,也不要给养了三年的狗排「先只开放一个房间」。
func isNewHome(arrivedAt *time.Time, now time.Time) bool {
if arrivedAt == nil || arrivedAt.IsZero() {
return false
}
return now.Sub(*arrivedAt) < 30*24*time.Hour && !arrivedAt.After(now)
}
// petGoals 取建档时勾选的目标
func petGoals(pet *model.Pet) []string {
if len(pet.Goals) == 0 {
return nil
}
var goals []string
if err := json.Unmarshal(pet.Goals, &goals); err != nil {
return nil
}
return goals
}
func wantsGoal(goals []string, keyword string) bool {
for _, g := range goals {
if strings.Contains(g, keyword) {
return true
}
}
return false
}
// goalWantsReminder 目标 → 该不该建这类提醒。
// 一条都没勾(比如从「添加宠物」弹层建的档)时全部建,不做减法。
func goalWantsReminder(goals []string, reminderType string) bool {
if len(goals) == 0 {
return true
}
switch reminderType {
case model.ReminderVaccine, model.ReminderDeworm:
// 疫苗和驱虫不给关。狂犬是法定要求,漏驱虫是真会出事的,
// 不能因为用户建档时没勾「疫苗驱虫怕忘记」就干脆不提醒。
return true
case model.ReminderWeight:
return wantsGoal(goals, "体重") || wantsGoal(goals, "健康")
case model.ReminderMonthlyReport:
return wantsGoal(goals, "报告")
}
return true
}
// dueDaysFor 算这条提醒几天后到期。
// 模板里的 OffsetDays 是「典型情况」的天数,疫苗和驱虫必须按真实月龄修正——
// 原来不管多大都排「14 天后首针」,一只 8 月龄才接回家的猫会被安排去打首免。
func dueDaysFor(it model.CareTemplateItem, pet *model.Pet, now time.Time) int {
months := -1
if pet.Birthday != nil && !pet.Birthday.IsZero() {
months = monthsSince(*pet.Birthday)
}
switch it.ReminderType {
case model.ReminderVaccine:
if months < 0 {
return it.OffsetDays // 不知道月龄就按模板给的典型值
}
switch {
case months < 2:
// 首免 8 周龄起。还没到就等到那天,已经到了就尽快约。
if d := 56 - int(now.Sub(*pet.Birthday).Hours()/24); d > 0 {
return d
}
return 7
case months < 5:
return 21 // 幼崽针次之间隔 3-4 周
case months < 12:
return 14 // 月龄偏大但多半没打全,两周内补齐
default:
return it.OffsetDays // 成年后走模板:年度加强用频率而非到期日
}
case model.ReminderDeworm:
if months >= 0 && months < 6 {
return 30 // 6 月龄前每月一次
}
return it.OffsetDays
}
return it.OffsetDays
}
// reminderTitle 修正提醒标题。
// 阶段是用户可以手改的,改错了模板就会给一只 3 月龄的幼崽发「年度疫苗加强」。
// 到期日已经按月龄算过(见 dueDaysFor),标题也得跟上,否则用户照着标题去打错针。
func reminderTitle(it model.CareTemplateItem, pet *model.Pet) string {
if it.ReminderType != model.ReminderVaccine || pet.Birthday == nil || pet.Birthday.IsZero() {
return it.Title
}
months := monthsSince(*pet.Birthday)
if months >= 12 {
return it.Title
}
if months < 2 {
return "首针疫苗接种"
}
return "下一针疫苗(按月龄安排)"
}
+244 -11
View File
@@ -1,7 +1,9 @@
package service
import (
"errors"
"math"
"strconv"
"strings"
"time"
@@ -25,19 +27,18 @@ func (s *Service) TogglePlanTask(userID, taskID string) error {
}
// newStagePlan 按宠物当前阶段的养护模板生成一份 30 天计划(未保存)
// newStagePlan 只建一个空计划壳子(记住开始日,PlanTask.Day 是相对它算的)。
//
// 原来它顺手把整套模板铺成节点。那是第二条「自动铺」的路径——我把
// seedPetDefaults 改成不自动铺之后,建档后首页仍然躺着 5 条任务,就是这里干的。
// 惰性生成尤其难查:不是建档时发生的,是第一次读计划才发生的。
//
// 现在铺不铺由用户决定:建档最后一步挑方案,或者之后在计划页套用。
func (s *Service) newStagePlan(pet *model.Pet, start time.Time) model.Plan {
var planTasks []model.PlanTask
for _, it := range s.careTemplateItems(SpeciesOf(pet.Type), pet.Stage) {
if it.Kind == model.CareKindPlan {
planTasks = append(planTasks, model.PlanTask{
Day: it.Day, DayLabel: it.DayLabel, Title: it.Title, Description: it.Description, SheetType: it.SheetType,
})
}
}
end := start.AddDate(0, 0, 30)
return model.Plan{
PetID: pet.ID, UserID: pet.UserID, Kind: model.PlanThirtyDay, Stage: pet.Stage,
StartDate: &start, EndDate: &end, CompletionPct: 0, Status: "active", Tasks: planTasks,
StartDate: &start, EndDate: &end, CompletionPct: 0, Status: "active",
}
}
@@ -138,7 +139,7 @@ func (s *Service) Calendar(userID, petID string, year, month int) (*CalendarResu
set := s.datesWithActivity(petID, start, end)
days := make([]int, 0, len(set))
for k := range set {
if t, err := time.Parse("2006-01-02", k); err == nil {
if t, err := time.ParseInLocation("2006-01-02", k, time.Local); err == nil {
days = append(days, t.Day())
}
}
@@ -156,7 +157,7 @@ func (s *Service) DayPlan(userID, petID, dateStr string) (map[string]any, error)
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
day, err := time.ParseInLocation("2006-01-02", dateStr, time.Now().Location())
day, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
if err != nil {
return nil, ErrNotFound
}
@@ -192,6 +193,9 @@ func (s *Service) DayPlan(userID, petID, dateStr string) (map[string]any, error)
// CreateAIPlan 基于用户描述做规则化提取,生成待确认的 AI 计划
func (s *Service) CreateAIPlan(userID, petID string, input string) (*model.Plan, error) {
if err := s.consumeAIQuota(userID, aiKindPlan); err != nil {
return nil, err
}
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
@@ -271,3 +275,232 @@ func extractAIInfo(input, stage string) map[string]string {
"reminder": reminder,
}
}
// PlanTaskInput 用户自己加/改的计划节点
type PlanTaskInput struct {
Date string // "2026-08-05",落到 Day = 这天 - 计划开始日
Title string
Description string
SheetType string // 关联的记录类型 code,可空
// Daily 应用到每天:从 Date 起到窗口末尾,一天建一条。
// 不做「一条 + repeat 字段读时展开」是因为勾选状态是按天的——
// 那样得再开一张表记「哪天打过勾」,而直接建 N 条天然就有这个能力。
Daily bool
}
// planOf 取这只宠物当前的 30 天计划,顺带做归属校验
func (s *Service) planOf(userID, petID string) (*model.Plan, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
s.ensurePlan(pet)
var plan model.Plan
if e := s.db.Where("pet_id = ? AND kind = ? AND status = ?",
petID, model.PlanThirtyDay, "active").Order("id desc").First(&plan).Error; e != nil {
return nil, ErrNotFound
}
return &plan, nil
}
// 计划以「自然月」为单位管理。
//
// 不用「从计划开始日起 30 天」那种滚动窗口:计划可能是月中建的,那样窗口会
// 跨两个月,用户看着一个 7月26日—8月25日 的日历,既不像本月也不像下月,
// 「这个月还有什么没做」根本对不上。按自然月切,日历就是一眼能认的月历。
//
// 允许安排到下月末,否则「做下月计划」这件事本身做不了。
// monthBounds 某个月的首尾。off=0 本月,off=1 下月
func monthBounds(off int) (time.Time, time.Time) {
n := time.Now()
first := time.Date(n.Year(), n.Month(), 1, 0, 0, 0, 0, time.Local).AddDate(0, off, 0)
return first, first.AddDate(0, 1, -1)
}
// dayOffset 把日期换算成 PlanTask.Day(相对计划开始日)。
//
// 校验按**日期**而不是 Day 上限:Day 是相对计划开始日的,月份边界是相对今天的,
// 两者会错开。按 Day<=30 卡的话,计划建了几天之后下月末就换算成 Day=40 多,
// 用户在日历上明明看得到那格、点进去却存不了。
func dayOffset(plan *model.Plan, dateStr string) (int, error) {
if plan.StartDate == nil {
return 0, errors.New("计划还没有开始日期")
}
d, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
if err != nil {
return 0, ErrInvalidParam
}
st := time.Date(plan.StartDate.Year(), plan.StartDate.Month(), plan.StartDate.Day(),
0, 0, 0, 0, time.Local)
// 下界放到计划开始日而不是本月 1 号:模板生成的历史节点可能在上个月,
// 那些要允许原地改(改标题时日期会原样传回来)
if d.Before(st) {
return 0, errors.New("这天在计划开始之前,排不了")
}
_, nextEnd := monthBounds(1)
if d.After(nextEnd) {
return 0, errors.New("最远只能排到下个月月底(" + nextEnd.Format("1月2日") + "")
}
return int(d.Sub(st).Hours() / 24), nil
}
// AddPlanTask 用户自己往计划里加。Daily=true 时从这天起到窗口末尾每天一条
func (s *Service) AddPlanTask(userID, petID string, in PlanTaskInput) ([]model.PlanTask, error) {
title := strings.TrimSpace(in.Title)
if title == "" {
return nil, ErrInvalidParam
}
plan, err := s.planOf(userID, petID)
if err != nil {
return nil, err
}
day, err := dayOffset(plan, in.Date)
if err != nil {
return nil, err
}
// 要建几天。非 daily 就一天;daily 从这天排到窗口末尾
// 要建几天。daily 从这天排到**这一天所在那个月**的月底:
// 「应用到每天」说的是这个月每天,跨到下月去就变成用户没要求的事了
count := 1
if in.Daily {
from, e := time.ParseInLocation("2006-01-02", in.Date, time.Local)
if e != nil {
return nil, ErrInvalidParam
}
monthEnd := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, time.Local).
AddDate(0, 1, -1)
count = int(monthEnd.Sub(from).Hours()/24) + 1
if count < 1 {
count = 1
}
if count > 31 {
count = 31 // 兜一层,别让脏日期算出个巨大的数
}
}
out := make([]model.PlanTask, 0, count)
for i := 0; i < count; i++ {
d := day + i
out = append(out, model.PlanTask{
PlanID: plan.ID, Day: d, DayLabel: dayLabelFor(d),
Title: title, Description: strings.TrimSpace(in.Description),
SheetType: in.SheetType,
})
}
if err := s.db.Create(&out).Error; err != nil {
return nil, err
}
for i := range out {
out[i].Date = plan.StartDate.AddDate(0, 0, out[i].Day).Format("2006-01-02")
}
return out, nil
}
// UpdatePlanTask 改标题/描述/日期
func (s *Service) UpdatePlanTask(userID, taskID string, in PlanTaskInput) error {
plan, t, err := s.ownedPlanTask(userID, taskID)
if err != nil {
return err
}
fields := map[string]any{}
if strings.TrimSpace(in.Title) != "" {
fields["title"] = strings.TrimSpace(in.Title)
}
fields["description"] = strings.TrimSpace(in.Description)
if in.Date != "" {
day, err := dayOffset(plan, in.Date)
if err != nil {
return err
}
fields["day"] = day
fields["day_label"] = dayLabelFor(day)
}
return s.db.Model(&model.PlanTask{}).Where("id = ?", t.ID).Updates(fields).Error
}
// DeletePlanTask 删掉一条
func (s *Service) DeletePlanTask(userID, taskID string) error {
_, t, err := s.ownedPlanTask(userID, taskID)
if err != nil {
return err
}
return s.db.Delete(&model.PlanTask{}, "id = ?", t.ID).Error
}
// ownedPlanTask 校验这条节点属于当前用户的宠物。
// 不校验的话,拿到别人的 task id 就能改删别人的计划
func (s *Service) ownedPlanTask(userID, taskID string) (*model.Plan, *model.PlanTask, error) {
var t model.PlanTask
if err := s.db.First(&t, "id = ?", taskID).Error; err != nil {
return nil, nil, ErrNotFound
}
var plan model.Plan
if err := s.db.First(&plan, "id = ?", t.PlanID).Error; err != nil {
return nil, nil, ErrNotFound
}
if _, err := s.ownedPet(userID, plan.PetID); err != nil {
return nil, nil, ErrForbidden
}
return &plan, &t, nil
}
// dayLabelFor 第 N 天的展示标签,和内置模板生成的那批保持一致
func dayLabelFor(day int) string {
switch {
case day == 0:
return "今天"
case day == 1:
return "明天"
default:
return "第 " + strconv.Itoa(day+1) + " 天"
}
}
// PlanMonthStatus 首页那个提醒角标要的东西
type PlanMonthStatus struct {
Month string `json:"month"` // "2026-07"
ThisMonth int `json:"this_month"` // 本月有几件
ThisUndone int `json:"this_undone"` // 本月还剩几件
NextMonth int `json:"next_month"` // 下月有几件
// NeedNext 该提醒用户排下月计划了。
// 只在月末最后 7 天提醒:月初就催「排下个月」太早,用户会当成噪音关掉。
NeedNext bool `json:"need_next"`
NextLabel string `json:"next_label"` // "8 月"
}
// PlanMonthStatus 统计本月/下月的计划件数
func (s *Service) PlanMonthStatus(userID, petID string) (*PlanMonthStatus, error) {
plan, err := s.planOf(userID, petID)
if err != nil {
return nil, err
}
thisFirst, thisEnd := monthBounds(0)
nextFirst, nextEnd := monthBounds(1)
var tasks []model.PlanTask
s.db.Where("plan_id = ?", plan.ID).Find(&tasks)
out := &PlanMonthStatus{
Month: thisFirst.Format("2006-01"),
NextLabel: nextFirst.Format("1 月"),
}
for _, t := range tasks {
d := plan.StartDate.AddDate(0, 0, t.Day)
switch {
case !d.Before(thisFirst) && !d.After(thisEnd):
out.ThisMonth++
if !t.Done {
out.ThisUndone++
}
case !d.Before(nextFirst) && !d.After(nextEnd):
out.NextMonth++
}
}
// 距月底还剩几天。用 24h 取整会在夏令时地区差一天,这里按日期差算
left := int(thisEnd.Sub(time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(),
0, 0, 0, 0, time.Local)).Hours()/24)
out.NeedNext = out.NextMonth == 0 && left <= 7
return out, nil
}
+204
View File
@@ -0,0 +1,204 @@
package service
import (
"errors"
"log"
"strconv"
"strings"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// 分组展示顺序。前端按这个顺序渲染,不靠 group_key 的字典序
var recordGroupOrder = []struct {
Key string
Label string
}{
{model.RecordGroupDaily, "日常记录"},
{model.RecordGroupHealth, "健康记录"},
{model.RecordGroupCare, "洗护记录"},
{model.RecordGroupClean, "清洁记录"},
}
// RecordTypeGroup 一组类型,给小程序记录页直接渲染
type RecordTypeGroup struct {
Key string `json:"key"`
Label string `json:"label"`
Items []model.RecordType `json:"items"`
}
// parseFields 把后台那份紧凑写法解析成结构。
// 解析放服务端而不是前端:写错了要在后台保存时就报出来,
// 不能等用户点开表单才发现渲染不出东西
func parseFields(raw string) ([]model.RecordField, error) {
out := []model.RecordField{}
catTaken := false
for i, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
seg := strings.Split(line, ":")
no := strconv.Itoa(i + 1)
if len(seg) < 2 {
return nil, errors.New("第 " + no + " 行格式不对,至少要 类型:名称")
}
f := model.RecordField{Kind: strings.TrimSpace(seg[0]), Label: strings.TrimSpace(seg[1])}
if f.Label == "" {
return nil, errors.New("第 " + no + " 行缺名称")
}
extra := ""
if len(seg) > 2 {
extra = strings.TrimSpace(seg[2])
}
switch f.Kind {
case model.FieldNumber:
f.Unit = extra
// 数值一律落 num_value,不占 category
case model.FieldOptions:
for _, o := range strings.Split(extra, "|") {
if o = strings.TrimSpace(o); o != "" {
f.Options = append(f.Options, o)
}
}
if len(f.Options) == 0 {
return nil, errors.New("第 " + no + " 行 options 没有选项,用 | 分隔")
}
// 末段是 - 表示放弃 category
f.ToCategory = !(len(seg) > 3 && strings.TrimSpace(seg[3]) == "-")
case model.FieldText:
// 文本只进标题
default:
return nil, errors.New("第 " + no + " 行类型 " + f.Kind + " 不认识,只能是 number / options / text")
}
if f.ToCategory {
if catTaken {
return nil, errors.New("第 " + no + " 行:一个事项里只能有一个字段落 category" +
"多出来的那个末尾加 :- ")
}
catTaken = true
}
out = append(out, f)
}
return out, nil
}
// fillFields 读取时把解析结果挂上。解析失败不让整个接口挂掉——
// 那会让一条配错的数据把整个记录页打空;只让这一种事项没有额外字段
func fillFields(list []model.RecordType) {
for i := range list {
f, err := parseFields(list[i].Fields)
if err != nil {
log.Printf("[warn] 记录类型 %s 的字段配置解析失败,按无额外字段处理:%v", list[i].Code, err)
f = []model.RecordField{}
}
list[i].ParsedFields = f
}
}
// ListRecordTypes 小程序用:只返回启用的,按分组归好
func (s *Service) ListRecordTypes() []RecordTypeGroup {
var all []model.RecordType
s.db.Where("enabled = ?", true).Order("sort asc, id asc").Find(&all)
fillFields(all)
if len(all) == 0 {
// 表被清空了。这里不硬编码兜底一份——那等于把「可配置」又变回代码里的常量,
// 下次改配置的人会发现改了没用。宁可返回空让记录页明显是空的,也别静默用旧值。
log.Println("[warn] record_types 表为空,记录页会没有任何可记事项")
return []RecordTypeGroup{}
}
byGroup := map[string][]model.RecordType{}
for _, t := range all {
byGroup[t.GroupKey] = append(byGroup[t.GroupKey], t)
}
out := make([]RecordTypeGroup, 0, len(recordGroupOrder))
for _, g := range recordGroupOrder {
items := byGroup[g.Key]
if len(items) == 0 {
continue
}
out = append(out, RecordTypeGroup{Key: g.Key, Label: g.Label, Items: items})
}
return out
}
// AdminListRecordTypes 后台用:连禁用的一起返回,按分组顺序再按 sort
func (s *Service) AdminListRecordTypes() []model.RecordType {
var all []model.RecordType
s.db.Order("group_key asc, sort asc, id asc").Find(&all)
fillFields(all)
if all == nil {
return []model.RecordType{}
}
return all
}
// SaveRecordType 新增或更新。Code 一旦建过记录就不允许改——
// 改了历史记录的 type 会对不上任何类型,列表里直接变成一行没图标没名字的东西
func (s *Service) SaveRecordType(in *model.RecordType) error {
if in.Code == "" || in.Label == "" {
return ErrInvalidParam
}
if in.Form != model.RecordFormFull && in.Form != model.RecordFormSimple {
in.Form = model.RecordFormSimple
}
if in.GroupKey == "" {
in.GroupKey = model.RecordGroupDaily
}
// 字段配置写错了必须在这里挡住,不能存进库等用户点开表单才暴露
if _, err := parseFields(in.Fields); err != nil {
return err
}
if in.ID == "" {
// 新增:Code 不能和已有的撞
var n int64
s.db.Model(&model.RecordType{}).Where("code = ?", in.Code).Count(&n)
if n > 0 {
return errors.New("这个 code 已经存在了")
}
in.Locked = false // 新加的一律可删;locked 只由 bootstrap 给那 9 种
return s.db.Create(in).Error
}
var old model.RecordType
if err := s.db.First(&old, "id = ?", in.ID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return err
}
if old.Code != in.Code {
var used int64
s.db.Model(&model.HealthRecord{}).Where("type = ?", old.Code).Count(&used)
if used > 0 {
return errors.New("已经有 " + strconv.FormatInt(used, 10) + " 条记录用了这个 code,不能改,只能新建一个")
}
}
// locked 和 form 不由后台改:前者是代码依赖,后者对应弹层里真实存在的分支
return s.db.Model(&model.RecordType{}).Where("id = ?", in.ID).Updates(map[string]any{
"code": in.Code, "label": in.Label, "icon": in.Icon,
"group_key": in.GroupKey, "sort": in.Sort, "enabled": in.Enabled,
"fields": in.Fields,
}).Error
}
// DeleteRecordType locked 的删不了;已经有记录的也删不了(会让历史数据变孤儿)。
// 想让它从记录页消失应该用 enabled=false,历史记录还能正常显示
func (s *Service) DeleteRecordType(id string) error {
var t model.RecordType
if err := s.db.First(&t, "id = ?", id).Error; err != nil {
return ErrNotFound
}
if t.Locked {
return errors.New("「" + t.Label + "」有统计和分析逻辑依赖它,不能删。想隐藏请改成停用")
}
var used int64
s.db.Model(&model.HealthRecord{}).Where("type = ?", t.Code).Count(&used)
if used > 0 {
return errors.New("已经有 " + strconv.FormatInt(used, 10) + " 条「" + t.Label + "」记录,删了这些记录会变成孤儿。想隐藏请改成停用")
}
return s.db.Delete(&model.RecordType{}, "id = ?", id).Error
}
+43 -2
View File
@@ -2,6 +2,7 @@ package service
import (
"fmt"
"strings"
"time"
"github.com/sundynix/pets-be/internal/model"
@@ -47,19 +48,59 @@ func (s *Service) GetWeeklyReport(userID, petID string) (*WeeklyReport, error) {
Count(&highRisk)
status := "稳定成长"
risk := "无高风险异常记录"
if highRisk > 0 {
status = "需要关注"
// 原来这句写死成「无高风险异常记录」,和上面刚算出来的 highRisk 自相矛盾:
// 记了 3 条高风险,摘要还说没有
risk = fmt.Sprintf("有 %d 条高风险异常记录", highRisk)
}
return &WeeklyReport{
Summary: fmt.Sprintf("本周 %s 完成 %d 项任务,体重 %+.1fkg,无高风险异常记录。", pet.Name, tasksCompleted, gain),
Summary: fmt.Sprintf("本周 %s 完成 %d 项任务,体重 %+.1fkg,%s。",
pet.Name, tasksCompleted, gain, risk),
TasksCompleted: tasksCompleted,
WeightGain: gain,
HighRiskCount: highRisk,
HealthStatus: status,
NextWeekFocus: "第 2 针疫苗提醒、继续观察体重趋势、避免频繁更换食物。",
NextWeekFocus: s.nextWeekFocus(petID, highRisk, gain),
}, nil
}
// nextWeekFocus 下周重点。原来是一句写死的「第 2 针疫苗提醒、继续观察体重趋势、
// 避免频繁更换食物」——不管谁的宠物、多大年纪、有没有异常,看到的都是这句,
// 而且「第 2 针疫苗」对成年猫狗根本不适用。改成按真实数据拼。
func (s *Service) nextWeekFocus(petID string, highRisk int64, gain float64) string {
var parts []string
// 未来 7 天到期的提醒,是最具体的「下周要做什么」
now := time.Now()
var rems []model.Reminder
s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date <= ?",
petID, now, now.AddDate(0, 0, 7)).Order("next_due_date asc").Limit(2).Find(&rems)
for _, r := range rems {
parts = append(parts, r.Title)
}
if highRisk > 0 {
parts = append(parts, "继续观察上周记录的异常,必要时就医")
}
// 一周没称过体重(gain 恰好为 0 也可能是真没变,所以查条数而不是看 gain)
var weighed int64
s.db.Model(&model.HealthRecord{}).
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, now.AddDate(0, 0, -7)).
Count(&weighed)
if weighed == 0 {
parts = append(parts, "本周还没称体重,补记一次")
}
if len(parts) == 0 {
return "各项都稳定,保持现在的记录节奏就好。"
}
return strings.Join(parts, "") + "。"
}
// BillCategory 账单分类项
type BillCategory struct {
Category string `json:"category"`
+6
View File
@@ -21,6 +21,9 @@ var ErrPayNotReady = errors.New("支付功能即将开放,敬请期待")
// ErrForbidden 越权操作(如删除别人的内容)
var ErrForbidden = errors.New("无权操作")
// ErrInvalidParam 参数不合法(handler 据此返回 40000
var ErrInvalidParam = errors.New("参数不合法")
// Service 业务逻辑聚合,方法按领域分散在各文件
type Service struct {
db *gorm.DB
@@ -43,5 +46,8 @@ func (s *Service) ownedPet(userID, petID string) (*model.Pet, error) {
if err != nil {
return nil, err
}
// Age 是计算字段(gorm:"-")。在这里填而不是在每个调用点填 ——
// ownedPet 是所有「取一只宠物」的路径的收口,漏一个就会返回空年龄
pet.Age = AgeLabel(pet.Birthday)
return &pet, nil
}
+61 -138
View File
@@ -3,173 +3,96 @@ package service
import (
"time"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
func dayStart(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
// 「今日任务」就是「今天那一天的计划节点」。
//
// 原来这是两套东西:DailyTask(模板 kind=task 生成,每天把昨天那份复制到今天)
// 和 PlanTask(模板 kind=plan 生成,Day 偏移)。计划改成月度管理、并且能
// 「应用到每天」之后,两者做的是同一件事,但首页读 DailyTask、计划页管 PlanTask
// —— 用户在计划页加一条「每天喂药」,首页看不到。
//
// 所以 DailyTask 退役,这个文件只是 PlanTask 的一层「按日期取」的门面。
// 接口路径 /pets/:id/tasks 保留不变,小程序端不用为这次合并改调用。
// ensureDayTasks 惰性生成某天的任务:今天/未来若为空,则从「最近一次有任务的那天」顺延复制,
// 无历史则用养护模板。过去的日子不补(保留历史真实)。
func (s *Service) ensureDayTasks(pet *model.Pet, day time.Time) {
start := dayStart(day)
if start.Before(dayStart(time.Now())) {
return
}
end := start.AddDate(0, 0, 1)
var cnt int64
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ?", pet.ID, start, end).Count(&cnt)
if cnt > 0 {
return
}
var defs []model.DailyTask
var latest model.DailyTask
if err := s.db.Where("pet_id = ? AND task_date < ?", pet.ID, start).Order("task_date desc, id desc").First(&latest).Error; err == nil {
ls := dayStart(latest.TaskDate)
var prev []model.DailyTask
s.db.Where("pet_id = ? AND task_date >= ? AND task_date < ?", pet.ID, ls, ls.AddDate(0, 0, 1)).Order("id asc").Find(&prev)
for _, t := range prev {
defs = append(defs, model.DailyTask{
PetID: pet.ID, UserID: pet.UserID, TaskDate: start,
Title: t.Title, Description: t.Description, Priority: t.Priority, SheetType: t.SheetType,
})
}
} else {
for _, it := range s.careTemplateItems(SpeciesOf(pet.Type), pet.Stage) {
if it.Kind == model.CareKindTask {
defs = append(defs, model.DailyTask{
PetID: pet.ID, UserID: pet.UserID, TaskDate: start,
Title: it.Title, Description: it.Description, Priority: it.Priority, SheetType: it.SheetType,
})
}
}
}
if len(defs) > 0 {
s.db.Create(&defs)
}
}
// ListTasks 列出宠物某天任务(date 为空则取今天);今天/未来为空会惰性生成
func (s *Service) ListTasks(userID, petID string, date *time.Time) ([]model.DailyTask, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
target := time.Now()
if date != nil {
target = *date
}
s.ensureDayTasks(pet, target)
start := dayStart(target)
end := start.AddDate(0, 0, 1)
var tasks []model.DailyTask
err = s.db.Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).
Order("id asc").Find(&tasks).Error
return tasks, err
}
// TaskInput 增/改任务入参
// TaskInput 保留给 handler 绑定用;增删改现在统一走 /plan-tasks 那套
type TaskInput struct {
Title string
Description string
Priority string
SheetType string
Date *time.Time // 仅新增用,默认今天
}
// CreateTask 为某天新增一条任务(默认今天
func (s *Service) CreateTask(userID, petID string, in TaskInput) (*model.DailyTask, error) {
// ListTasks 某一天要做的事。不传日期就是今天
func (s *Service) ListTasks(userID, petID string, date *time.Time) ([]model.PlanTask, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
day := time.Now()
if in.Date != nil {
day = *in.Date
plan, err := s.planOf(userID, petID)
if err != nil {
// 没有计划就没有任务,返回空数组而不是错误——新建档的宠物必然走这一支
return []model.PlanTask{}, nil
}
t := model.DailyTask{
PetID: petID, UserID: userID, TaskDate: dayStart(day),
Title: in.Title, Description: in.Description, Priority: in.Priority, SheetType: in.SheetType,
target := time.Now()
if date != nil {
target = *date
}
if err := s.db.Create(&t).Error; err != nil {
return nil, err
day := dayIndexOf(plan, target)
if day < 0 {
return []model.PlanTask{}, nil
}
return &t, nil
var tasks []model.PlanTask
s.db.Where("plan_id = ? AND day = ?", plan.ID, day).Order("id asc").Find(&tasks)
if tasks == nil {
tasks = []model.PlanTask{}
}
stamp := plan.StartDate.AddDate(0, 0, day).Format("2006-01-02")
for i := range tasks {
tasks[i].Date = stamp
}
return tasks, nil
}
// UpdateTask 修改任务文案/优先级/关联弹层
func (s *Service) UpdateTask(userID, taskID string, in TaskInput) (*model.DailyTask, error) {
var t model.DailyTask
if err := s.db.Where("id = ? AND user_id = ?", taskID, userID).First(&t).Error; err != nil {
return nil, ErrNotFound
// dayIndexOf 某个日期在这份计划里是第几天;早于计划开始日返回 -1
func dayIndexOf(plan *model.Plan, t time.Time) int {
if plan.StartDate == nil {
return -1
}
if err := s.db.Model(&model.DailyTask{}).Where("id = ?", taskID).Updates(map[string]any{
"title": in.Title, "description": in.Description, "priority": in.Priority, "sheet_type": in.SheetType,
}).Error; err != nil {
return nil, err
st := time.Date(plan.StartDate.Year(), plan.StartDate.Month(), plan.StartDate.Day(),
0, 0, 0, 0, time.Local)
d := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
if d.Before(st) {
return -1
}
return s.UpdateTaskReload(taskID)
return int(d.Sub(st).Hours() / 24)
}
// UpdateTaskReload 重新读取任务
func (s *Service) UpdateTaskReload(taskID string) (*model.DailyTask, error) {
var t model.DailyTask
// ToggleTask 勾选/取消。和 TogglePlanTask 是同一件事,保留这个名字是为了
// 让 /tasks/:id/toggle 这个老路径继续可用
func (s *Service) ToggleTask(userID, taskID string) (*model.PlanTask, error) {
if err := s.TogglePlanTask(userID, taskID); err != nil {
return nil, err
}
var t model.PlanTask
if err := s.db.First(&t, "id = ?", taskID).Error; err != nil {
return nil, ErrNotFound
}
return &t, nil
}
// DeleteTask 删除任务
func (s *Service) DeleteTask(userID, taskID string) error {
res := s.db.Where("id = ? AND user_id = ?", taskID, userID).Delete(&model.DailyTask{})
if res.Error != nil {
return res.Error
// CompleteAllTasks 把今天的都勾上
func (s *Service) CompleteAllTasks(userID, petID string) ([]model.PlanTask, error) {
plan, err := s.planOf(userID, petID)
if err != nil {
return []model.PlanTask{}, nil
}
if res.RowsAffected == 0 {
return ErrNotFound
day := dayIndexOf(plan, time.Now())
if day < 0 {
return []model.PlanTask{}, nil
}
return nil
}
// ToggleTask 切换任务完成状态
func (s *Service) ToggleTask(userID, taskID string) (*model.DailyTask, error) {
var task model.DailyTask
if err := s.db.Where("id = ? AND user_id = ?", taskID, userID).First(&task).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrNotFound
}
return nil, err
}
task.Done = !task.Done
if task.Done {
now := time.Now()
task.CompletedAt = &now
} else {
task.CompletedAt = nil
}
if err := s.db.Save(&task).Error; err != nil {
return nil, err
}
return &task, nil
}
// CompleteAllTasks 完成宠物今日全部任务
func (s *Service) CompleteAllTasks(userID, petID string) ([]model.DailyTask, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
end := start.AddDate(0, 0, 1)
if err := s.db.Model(&model.DailyTask{}).
Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).
Updates(map[string]any{"done": true, "completed_at": now}).Error; err != nil {
return nil, err
}
return s.ListTasks(userID, petID, &now)
s.db.Model(&model.PlanTask{}).
Where("plan_id = ? AND day = ? AND done = ?", plan.ID, day, false).
Update("done", true)
return s.ListTasks(userID, petID, nil)
}
+145
View File
@@ -0,0 +1,145 @@
package service
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"log"
"time"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
)
// ErrRefreshInvalid 刷新令牌不存在 / 已过期 / 已被用过
var ErrRefreshInvalid = errors.New("登录已过期,请重新登录")
// 轮换宽限期:同一个刷新令牌在被消费后的这段时间内再次到达,视为「并发请求 / 网络重试」
// 而不是泄露(多标签页、小程序并发请求都会撞上)。超过这个窗口才按泄露处理。
const refreshGrace = 60 * time.Second
// 作废原因
const (
revokeRotate = "rotate"
revokeLogout = "logout"
revokeReuse = "reuse"
)
func hashRefreshToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// refreshTTL 按主体类型取有效期:后台比小程序短
func (s *Service) refreshTTL(kind string) time.Duration {
h := s.cfg.JWT.RefreshExpireHours
if kind == string(appjwt.KindAdmin) {
h = s.cfg.JWT.AdminRefreshExpireHours
}
if h <= 0 {
h = 168
}
return time.Duration(h) * time.Hour
}
// IssueRefreshToken 签发刷新令牌,返回明文(只在这一次返回,库里只存哈希)
func (s *Service) IssueRefreshToken(kind, subjectID string) (string, time.Time, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", time.Time{}, err
}
raw := base64.RawURLEncoding.EncodeToString(buf)
expires := time.Now().Add(s.refreshTTL(kind))
rt := model.RefreshToken{
TokenHash: hashRefreshToken(raw),
Kind: kind,
SubjectID: subjectID,
ExpiresAt: expires,
}
if err := s.db.Create(&rt).Error; err != nil {
return "", time.Time{}, err
}
s.gcRefreshTokens()
return raw, expires, nil
}
// ConsumeRefreshToken 校验并轮换刷新令牌,返回其所属主体 ID。
// 调用方随后应签发新的 access token + 新的刷新令牌。
func (s *Service) ConsumeRefreshToken(raw, kind string) (string, error) {
if raw == "" {
return "", ErrRefreshInvalid
}
var rt model.RefreshToken
err := s.db.Where("token_hash = ? AND kind = ?", hashRefreshToken(raw), kind).First(&rt).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", ErrRefreshInvalid
}
if err != nil {
return "", err
}
if time.Now().After(rt.ExpiresAt) {
return "", ErrRefreshInvalid
}
if rt.RevokedAt != nil {
// 正常轮换掉的令牌在宽限期内又到达:多半是并发刷新或网络重试,放行但不重复作废。
// 主动退出 / 被连坐吊销的令牌不给这个待遇,否则吊销等于没吊销。
if rt.RevokedReason == revokeRotate && time.Since(*rt.RevokedAt) <= refreshGrace {
return rt.SubjectID, nil
}
if rt.RevokedReason == revokeRotate {
// 早就轮换掉的令牌又被拿来用 —— 按泄露处理,把该主体所有令牌一起吊销
log.Printf("auth: 检测到已作废刷新令牌被重复使用 kind=%s subject=%s,吊销其全部会话", rt.Kind, rt.SubjectID)
s.revokeAll(rt.Kind, rt.SubjectID, revokeReuse)
}
return "", ErrRefreshInvalid
}
// 条件更新:只有仍未作废的那一行能被命中,天然挡住并发重复消费
res := s.db.Model(&model.RefreshToken{}).
Where("id = ? AND revoked_at IS NULL", rt.ID).
Updates(map[string]any{"revoked_at": time.Now(), "revoked_reason": revokeRotate})
if res.Error != nil {
return "", res.Error
}
if res.RowsAffected == 0 {
return "", ErrRefreshInvalid
}
return rt.SubjectID, nil
}
// RevokeRefreshToken 退出登录时作废单个令牌(找不到也当成功,不给探测口子)
func (s *Service) RevokeRefreshToken(raw, kind string) {
if raw == "" {
return
}
s.db.Model(&model.RefreshToken{}).
Where("token_hash = ? AND kind = ? AND revoked_at IS NULL", hashRefreshToken(raw), kind).
Updates(map[string]any{"revoked_at": time.Now(), "revoked_reason": revokeLogout})
}
// RevokeAllRefreshTokens 吊销某主体的全部会话(改密码、禁用账号时用)
func (s *Service) RevokeAllRefreshTokens(kind, subjectID string) {
s.revokeAll(kind, subjectID, revokeLogout)
}
func (s *Service) revokeAll(kind, subjectID, reason string) {
s.db.Model(&model.RefreshToken{}).
Where("kind = ? AND subject_id = ? AND revoked_at IS NULL", kind, subjectID).
Updates(map[string]any{"revoked_at": time.Now(), "revoked_reason": reason})
if reason == revokeReuse {
// 连坐时把「还在宽限期内」的轮换记录也一并改掉,否则它们还能再换出一副令牌
s.db.Model(&model.RefreshToken{}).
Where("kind = ? AND subject_id = ? AND revoked_reason = ?", kind, subjectID, revokeRotate).
Update("revoked_reason", revokeReuse)
}
}
// gcRefreshTokens 清掉过期很久的记录,避免表无限增长(顺带在签发时做,不另起定时任务)
func (s *Service) gcRefreshTokens() {
s.db.Where("expires_at < ?", time.Now().Add(-7*24*time.Hour)).Delete(&model.RefreshToken{})
}
+264
View File
@@ -0,0 +1,264 @@
package service
import (
"time"
"github.com/sundynix/pets-be/internal/model"
)
// UserThemes 主页可选主题色。存 key 不存色值——色值属于前端表现层,
// 存进库以后想调色就得改数据;而且 key 还能保证前端只渲染已知的几种。
var UserThemes = map[string]bool{
"warm": true, "mint": true, "sky": true, "violet": true, "ink": true,
}
const defaultUserTheme = "warm"
// PetCardBrief 主页宠物名片墙上的一只
type PetCardBrief struct {
ID string `json:"id"`
Name string `json:"name"`
Emoji string `json:"emoji"`
AvatarURL string `json:"avatar_url"`
Type string `json:"type"`
Stage string `json:"stage"`
Age string `json:"age"`
}
// KeepStats 养宠数据。三个数都按「这个人名下所有宠物」合并算,
// 不按单只——主页展示的是这个铲屎官本人,不是某一只宠物
type KeepStats struct {
Days int `json:"days"` // 养宠天数:最早那只的建档日至今
Records int `json:"records"` // 累计记录条数
ActDays int `json:"act_days"` // 有记录的天数
Streak int `json:"streak"` // 连续打卡天数(截至今天或昨天)
PetCount int `json:"pet_count"` // 在养几只
}
// UserCard 用户公开资料。关注这件事之前只做了「点一下关注」,
// 关注完既看不到对方主页、也看不到自己关注了谁——等于关注完就石沉大海。
type UserCard struct {
ID string `json:"id"`
Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url"`
IsBot bool `json:"is_bot"`
IsSelf bool `json:"is_self"`
Followed bool `json:"followed"` // 我有没有关注 TA
Posts int64 `json:"post_count"`
Following int64 `json:"following_count"`
Followers int64 `json:"follower_count"`
// —— 装扮 ——(列表接口不填,只有主页详情填)
Bio string `json:"bio,omitempty"`
BgURL string `json:"bg_url,omitempty"`
Theme string `json:"theme,omitempty"`
ShowPets bool `json:"show_pets"`
Pets []PetCardBrief `json:"pets,omitempty"`
Stats *KeepStats `json:"stats,omitempty"`
}
// GetUserCard 取某人的公开资料 + 三个计数 + 我是否已关注
func (s *Service) GetUserCard(viewerID, userID string) (*UserCard, error) {
var u model.User
if err := s.db.First(&u, "id = ?", userID).Error; err != nil {
return nil, ErrNotFound
}
card := &UserCard{
ID: u.ID, Nickname: u.Nickname, IsBot: u.IsBot,
IsSelf: u.ID == viewerID,
AvatarURL: s.fileURL(u.AvatarFileID),
}
if card.AvatarURL == "" {
card.AvatarURL = u.Avatar
}
s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, "published").Count(&card.Posts)
s.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Count(&card.Following)
s.db.Model(&model.Follow{}).Where("followee_id = ?", userID).Count(&card.Followers)
if !card.IsSelf {
var n int64
s.db.Model(&model.Follow{}).
Where("follower_id = ? AND followee_id = ?", viewerID, userID).Count(&n)
card.Followed = n > 0
}
card.Bio = u.Bio
card.BgURL = s.fileURL(u.BgFileID)
card.Theme = u.Theme
if !UserThemes[card.Theme] {
card.Theme = defaultUserTheme
}
card.ShowPets = !u.HidePets
stats, pets := s.keepStats(userID, card.ShowPets)
card.Stats = stats
card.Pets = pets
return card, nil
}
// keepStats 养宠数据 + (可选)宠物名片墙。
// 两件事合在一个方法里是因为它们查的是同一批 pets,分开写要多查一次。
func (s *Service) keepStats(userID string, withPets bool) (*KeepStats, []PetCardBrief) {
var pets []model.Pet
s.db.Where("user_id = ?", userID).Order("id asc").Find(&pets)
st := &KeepStats{PetCount: len(pets)}
cards := []PetCardBrief{}
if len(pets) == 0 {
return st, cards
}
ids := make([]string, 0, len(pets))
for _, p := range pets {
ids = append(ids, p.ID)
if withPets {
cards = append(cards, PetCardBrief{
ID: p.ID, Name: p.Name, Emoji: p.Emoji, Type: p.Type,
Stage: p.Stage, Age: p.Age,
AvatarURL: s.fileURL(p.AvatarFileID),
})
}
}
if !withPets {
cards = nil
}
// 养宠天数按最早那只的建档日算。用建档日而不是生日:这里要表达的是
// 「你在这个 App 上照顾它多久了」,不是宠物活了多久
st.Days = int(time.Since(pets[0].CreatedAt).Hours()/24) + 1
var total int64
s.db.Model(&model.HealthRecord{}).Where("pet_id IN ?", ids).Count(&total)
st.Records = int(total)
// 有记录的日期,倒序。连续打卡从今天(或昨天,当天还没记也不该断)往回数
var days []string
s.db.Model(&model.HealthRecord{}).
Where("pet_id IN ?", ids).
Distinct("DATE(occurred_at) as d").
Order("d desc").
Pluck("d", &days)
st.ActDays = len(days)
has := make(map[string]bool, len(days))
for _, d := range days {
if len(d) > 10 {
d = d[:10] // 有的驱动把 DATE() 读回来带时间部分
}
has[d] = true
}
cur := time.Now()
if !has[cur.Format("2006-01-02")] {
cur = cur.AddDate(0, 0, -1) // 今天还没记不算断签,从昨天起算
}
for has[cur.Format("2006-01-02")] {
st.Streak++
cur = cur.AddDate(0, 0, -1)
}
return st, cards
}
// UpdateDecoration 改主页装扮。只认这四个字段,nil 表示不动
func (s *Service) UpdateDecoration(userID string, bio, bgFileID, theme *string, showPets *bool) (*model.User, error) {
fields := map[string]any{}
if bio != nil {
b := []rune(*bio)
if len(b) > 60 {
b = b[:60]
}
fields["bio"] = string(b)
}
if bgFileID != nil {
fields["bg_file_id"] = *bgFileID
}
if theme != nil {
if !UserThemes[*theme] {
return nil, ErrInvalidParam
}
fields["theme"] = *theme
}
if showPets != nil {
fields["hide_pets"] = !*showPets
}
if len(fields) == 0 {
return s.GetUser(userID)
}
return s.UpdateUser(userID, fields)
}
// ListUserPosts 某人发布的帖子
func (s *Service) ListUserPosts(viewerID, userID string, offset, limit int) ([]model.Post, int64, error) {
q := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, "published")
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var posts []model.Post
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error; err != nil {
return nil, 0, err
}
s.attachPostImages(posts)
s.markFollowed(viewerID, posts)
return posts, total, nil
}
// ListRelations 关注列表 / 粉丝列表。
// kind = "following" 我关注的人;"followers" 关注我的人
func (s *Service) ListRelations(viewerID, userID, kind string, offset, limit int) ([]UserCard, int64, error) {
q := s.db.Model(&model.Follow{})
var idCol string
if kind == "followers" {
q = q.Where("followee_id = ?", userID)
idCol = "follower_id"
} else {
q = q.Where("follower_id = ?", userID)
idCol = "followee_id"
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var ids []string
if err := q.Order("id desc").Offset(offset).Limit(limit).Pluck(idCol, &ids).Error; err != nil {
return nil, 0, err
}
if len(ids) == 0 {
return []UserCard{}, total, nil
}
var users []model.User
s.db.Where("id IN ?", ids).Find(&users)
byID := map[string]model.User{}
for _, u := range users {
byID[u.ID] = u
}
// 一次查出我关注了这批人里的哪些,不要每行一次查询
var mine []string
s.db.Model(&model.Follow{}).Where("follower_id = ? AND followee_id IN ?", viewerID, ids).
Pluck("followee_id", &mine)
followed := map[string]bool{}
for _, id := range mine {
followed[id] = true
}
// 按 ids 的顺序输出,保持「最近关注的在前」
out := make([]UserCard, 0, len(ids))
for _, id := range ids {
u, ok := byID[id]
if !ok {
continue
}
avatar := s.fileURL(u.AvatarFileID)
if avatar == "" {
avatar = u.Avatar
}
out = append(out, UserCard{
ID: u.ID, Nickname: u.Nickname, IsBot: u.IsBot,
AvatarURL: avatar,
IsSelf: u.ID == viewerID,
Followed: followed[u.ID],
})
}
return out, total, nil
}
+125
View File
@@ -0,0 +1,125 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"
)
// 小程序码(wxacode)。身份卡右下角那个码,扫了能进小程序。
//
// 两步:先拿 access_token(有 2h 有效期,缓存复用),再调 getwxacodeunlimit
// 换一张 PNG,存到 MinIO 返回 URL。同一只宠物的码存一次就够——微信侧有
// 日调用配额,object 名按宠物 id 定死,存在就直接返回不重新生成。
//
// ⚠️ getwxacodeunlimit 要求 page 存在于**已发布**的小程序版本里。开发版/未发布
// 时会返回 errcode 41030(page 不存在),这时身份卡照常出,只是没有码。
// 发布之后自动就有了,不用改代码。
// accessToken 缓存。进程级,够用——多实例各自缓存不影响正确性
var (
atMu sync.Mutex
atValue string
atExpires time.Time
)
// wechatAccessToken 取 access_token,带缓存。提前 5 分钟过期,避免边界上用到失效的
func (s *Service) wechatAccessToken() (string, error) {
atMu.Lock()
defer atMu.Unlock()
if atValue != "" && time.Now().Before(atExpires) {
return atValue, nil
}
if s.cfg.WeChat.AppID == "" || s.cfg.WeChat.AppSecret == "" {
return "", errors.New("微信 app_id / app_secret 未配置")
}
q := url.Values{}
q.Set("grant_type", "client_credential")
q.Set("appid", s.cfg.WeChat.AppID)
q.Set("secret", s.cfg.WeChat.AppSecret)
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.weixin.qq.com/cgi-bin/token?"+q.Encode(), nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var out struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
if out.AccessToken == "" {
return "", fmt.Errorf("拿 access_token 失败(%d): %s", out.ErrCode, out.ErrMsg)
}
atValue = out.AccessToken
atExpires = time.Now().Add(time.Duration(out.ExpiresIn-300) * time.Second)
return atValue, nil
}
// PetQRCode 取某只宠物的小程序码 URL。存在就直接返回,不存在才向微信换。
// scene 里带宠物 id,扫码进来时小程序能拿到(暂时只用来打开小程序)
func (s *Service) PetQRCode(petID string) (string, error) {
if s.storage == nil {
return "", errors.New("对象存储未配置")
}
obj := "wxacode/" + petID + ".png"
if s.storage.Exists(obj) {
return s.storage.PublicURL(obj), nil
}
token, err := s.wechatAccessToken()
if err != nil {
return "", err
}
body, _ := json.Marshal(map[string]any{
"scene": "pet=" + petID, // 最长 32 字符,够放一个雪花 id
"page": "pages/home/home",
"check_path": true,
"width": 280,
})
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token="+token, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
// 成功返回图片二进制;失败返回一段 JSON。用 Content-Type 分辨最稳
if ct := resp.Header.Get("Content-Type"); len(ct) >= 5 && ct[:5] == "image" {
if _, err := s.storage.Upload(obj, bytes.NewReader(data), int64(len(data)), "image/png"); err != nil {
return "", err
}
return s.storage.PublicURL(obj), nil
}
var e struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
_ = json.Unmarshal(data, &e)
// 41030 = page 不在已发布版本里。开发期常见,往上层透传让它降级
return "", fmt.Errorf("微信生成小程序码失败(%d): %s", e.ErrCode, e.ErrMsg)
}
+9
View File
@@ -92,6 +92,15 @@ func (s *Storage) Upload(objectName string, reader io.Reader, size int64, conten
return s.PublicURL(objectName), nil
}
// Exists 对象在不在。小程序码有微信侧配额,同一只宠物的码存一次就够,
// 生成前先查
func (s *Storage) Exists(objectName string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
_, err := s.client.StatObject(ctx, s.bucket, objectName, minio.StatObjectOptions{})
return err == nil
}
// PublicURL 由对象名拼出可访问 URL(用当前配置的 public_base_url,避免存旧地址)
func (s *Storage) PublicURL(objectName string) string {
return fmt.Sprintf("%s/%s/%s", s.publicBaseURL, s.bucket, objectName)
+6 -1
View File
@@ -32,11 +32,16 @@ type Manager struct {
func NewManager(secret string, expireHours int) *Manager {
if expireHours <= 0 {
expireHours = 168
expireHours = 2
}
return &Manager{secret: []byte(secret), expireHours: expireHours}
}
// ExpireSeconds access token 剩余有效秒数,返给前端用于提前续期
func (m *Manager) ExpireSeconds() int {
return m.expireHours * 3600
}
// Generate 签发令牌
func (m *Manager) Generate(id string, kind Kind, name, role string) (string, error) {
now := time.Now()
+4
View File
@@ -10,6 +10,8 @@ import Posts from '@/pages/Posts'
import Comments from '@/pages/Comments'
import Articles from '@/pages/Articles'
import CareTemplates from '@/pages/CareTemplates'
import RecordTypes from '@/pages/RecordTypes'
import Breeds from '@/pages/Breeds'
import CommunityBot from '@/pages/CommunityBot'
import Feedback from '@/pages/Feedback'
import Members from '@/pages/Members'
@@ -37,6 +39,8 @@ export default function App() {
<Route path="comments" element={<Comments />} />
<Route path="articles" element={<Articles />} />
<Route path="care-templates" element={<CareTemplates />} />
<Route path="record-types" element={<RecordTypes />} />
<Route path="breeds" element={<Breeds />} />
<Route path="community-bot" element={<CommunityBot />} />
<Route path="feedback" element={<Feedback />} />
<Route path="members" element={<Members />} />
+6 -2
View File
@@ -1,6 +1,6 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { LayoutDashboard, Users, PawPrint, MessageSquare, MessagesSquare, FileText, ClipboardList, Sparkles, MessageCircle, Crown, LogOut } from 'lucide-react'
import { clearToken } from '@/lib/api'
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'
@@ -12,6 +12,8 @@ const nav = [
{ 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 },
@@ -20,6 +22,8 @@ const nav = [
export default function Layout() {
const navigate = useNavigate()
function logout() {
// 服务端作废刷新令牌,成不成都要本地清掉
api.logout().catch(() => {})
clearToken()
navigate('/login')
}
+79 -11
View File
@@ -1,15 +1,21 @@
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 setToken(t: string) {
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' })
@@ -20,15 +26,57 @@ http.interceptors.request.use((cfg) => {
return cfg
})
// access token 只有 2 小时,过期后拿 refresh token 换新的。
// 用单飞(single-flight)保证并发请求只触发一次刷新,否则刷新令牌会被并发轮换掉。
let refreshing: Promise<string> | null = null
function refreshAccessToken(): Promise<string> {
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,失败抛错
http.interceptors.response.use(
(resp) => {
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) {
clearToken()
if (location.pathname !== '/admin/login') location.href = '/admin/login'
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 || '请求失败'))
}
@@ -46,7 +94,11 @@ export interface Page<T> {
export const api = {
login: (username: string, password: string) =>
http.post<any, { token: string; admin: any }>('/admin/login', { username, password }),
http.post<any, { token: string; refresh_token: string; expires_in: number; admin: any }>(
'/admin/login',
{ username, password },
),
logout: () => http.post('/admin/logout', { refresh_token: getRefreshToken() }),
me: () => http.get<any, any>('/admin/me'),
stats: () => http.get<any, { users: number; pets: number; posts: number; records: number }>('/admin/stats'),
@@ -69,12 +121,11 @@ export const api = {
memberships: (params: any) => http.get<any, Page<any>>('/admin/memberships', { params }),
careTemplateMeta: () => http.get<any, any>('/admin/care-templates/meta'),
careTemplate: (species: string, stage: string) =>
http.get<any, { species: string; stage: string; items: any[] }>('/admin/care-templates', {
params: { species, stage },
}),
saveCareTemplate: (species: string, stage: string, items: any[]) =>
http.put<any, { items: any[] }>('/admin/care-templates', { species, stage, items }),
// 方案改成「同一个物种阶段可以多套」,所以按 id 存取,不再按 species+stage 覆盖
careTemplates: () => http.get<any, any[]>('/admin/care-templates'),
careTemplate: (id: string) => http.get<any, any>(`/admin/care-templates/${id}`),
saveCareTemplate: (t: any) => http.post<any, any>('/admin/care-templates', t),
deleteCareTemplate: (id: string) => http.delete(`/admin/care-templates/${id}`),
genCareTemplate: (species: string, stage: string) =>
http.post<any, { species: string; stage: string; items: any[] }>('/admin/care-templates/generate', {
species,
@@ -85,6 +136,23 @@ export const api = {
setFeedbackHandled: (id: string, handled: boolean) =>
http.put(`/admin/feedback/${id}/handled`, { handled }),
aiQuota: () =>
http.get<any, { enabled: boolean; daily_chat: number; daily_symptom: number; daily_plan: number }>(
'/admin/ai-quota',
),
saveAIQuota: (c: any) => http.put<any, any>('/admin/ai-quota', c),
// 记录类型(小程序「记一笔」的那些事项)
recordTypes: () => http.get<any, any[]>('/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<any, any[]>('/admin/breeds', { params: species ? { species } : {} }),
saveBreed: (b: any) => http.post('/admin/breeds', b),
deleteBreed: (id: string) => http.delete(`/admin/breeds/${id}`),
communityBot: () =>
http.get<any, { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }>(
'/admin/community-bot',
+227
View File
@@ -0,0 +1,227 @@
import { useEffect, useState } from 'react'
import { api } from '@/lib/api'
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'
type Breed = {
id: string
species: string
name: string
initial: string
sort: number
enabled: boolean
}
const empty: Breed = { id: '', species: 'cat', name: '', initial: '', sort: 0, enabled: true }
const SPECIES = [
{ key: 'cat', label: '猫' },
{ key: 'dog', label: '狗' },
]
export default function Breeds() {
const [species, setSpecies] = useState('cat')
const [list, setList] = useState<Breed[]>([])
const [open, setOpen] = useState(false)
const [form, setForm] = useState<Breed>(empty)
const [err, setErr] = useState('')
function reload() {
api
.breeds(species)
.then((r) => setList(r || []))
.catch(() => setList([]))
}
useEffect(reload, [species])
function create() {
setForm({ ...empty, species })
setErr('')
setOpen(true)
}
function edit(b: Breed) {
setForm({ ...b })
setErr('')
setOpen(true)
}
async function save() {
setErr('')
try {
await api.saveBreed(form)
setOpen(false)
reload()
} catch (e: any) {
// 后端会明确说是重名还是首字母不合法,直接透出来比一句「保存失败」有用
setErr(e?.message || '保存失败')
}
}
async function del(b: Breed) {
if (!confirm(`删除「${b.name}」?`)) return
try {
await api.deleteBreed(b.id)
reload()
} catch (e: any) {
alert(e?.message || '删除失败')
}
}
const set = (k: keyof Breed) => (e: any) => setForm({ ...form, [k]: e.target.value })
// 按首字母分段展示,和小程序端的 A-Z 索引对得上
const groups: Record<string, Breed[]> = {}
list.forEach((b) => {
const k = (b.initial || '#').toUpperCase()
;(groups[k] = groups[k] || []).push(b)
})
const letters = Object.keys(groups).sort()
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">
</p>
</div>
<Button onClick={create}></Button>
</div>
<div className="mb-4 flex gap-2">
{SPECIES.map((s) => (
<Button
key={s.key}
variant={species === s.key ? 'default' : 'outline'}
size="sm"
onClick={() => setSpecies(s.key)}
>
{s.label}
</Button>
))}
<span className="ml-2 self-center text-sm text-muted-foreground">
{list.length} {letters.length}
</span>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16"></TableHead>
<TableHead></TableHead>
<TableHead className="w-20"></TableHead>
<TableHead className="w-20"></TableHead>
<TableHead className="w-32 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{letters.map((L) =>
groups[L].map((b, i) => (
<TableRow key={b.id}>
<TableCell className="font-mono text-muted-foreground">
{i === 0 ? L : ''}
</TableCell>
<TableCell className="font-medium">{b.name}</TableCell>
<TableCell className="text-muted-foreground">{b.sort}</TableCell>
<TableCell>
{b.enabled ? (
<Badge></Badge>
) : (
<Badge variant="secondary"></Badge>
)}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => edit(b)}>
</Button>
<Button variant="ghost" size="sm" onClick={() => del(b)}>
</Button>
</TableCell>
</TableRow>
)),
)}
{!list.length && (
<TableRow>
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{form.id ? '编辑品种' : '新增品种'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-2">
<Label></Label>
<div className="flex gap-2">
{SPECIES.map((s) => (
<Button
key={s.key}
type="button"
variant={form.species === s.key ? 'default' : 'outline'}
size="sm"
onClick={() => setForm({ ...form, species: s.key })}
>
{s.label}
</Button>
))}
</div>
</div>
<div className="grid gap-2">
<Label></Label>
<Input value={form.name} onChange={set('name')} placeholder="例如:布偶猫" />
</div>
<div className="grid gap-2">
<Label>A-Z</Label>
<Input
value={form.initial}
maxLength={1}
onChange={(e) => setForm({ ...form, initial: e.target.value.toUpperCase() })}
placeholder="B"
className="w-20 font-mono"
/>
<p className="text-xs text-muted-foreground">
Z C
</p>
</div>
<div className="grid gap-2">
<Label></Label>
<Input
type="number"
value={form.sort}
onChange={(e) => setForm({ ...form, sort: Number(e.target.value) || 0 })}
className="w-28"
/>
</div>
<div className="flex items-center gap-2">
<input
id="bd-enabled"
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
/>
<Label htmlFor="bd-enabled" className="cursor-pointer">
</Label>
</div>
{err && <p className="text-sm text-destructive">{err}</p>}
<Button onClick={save}></Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
+444 -206
View File
@@ -1,285 +1,523 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Cat, Dog, Wand2, Plus, Trash2, Save, Info } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Cat, Dog, Plus, Trash2, Wand2, Info } from 'lucide-react'
import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
const SPECIES = [
{ key: 'cat', label: '猫猫', Icon: Cat },
{ key: 'dog', label: '狗狗', Icon: Dog },
]
const STAGES = ['刚到家 0-30 天', '幼年期', '成年期', '老年期']
const SHEET_LABEL: Record<string, string> = {
'': '无', weight: '体重', poop: '便便', food: '饮食', symptom: '异常',
vaccine: '疫苗', medicine: '用药', report: '报告', taskDetail: '任务详情',
}
// 阶段列表从后端 meta 来(service.CareStages)。写死在这里的话,
// 后端从 3 段细分成 5 段之后这一页就少两个选项,而且不会报错
const REMINDER_LABEL: Record<string, string> = {
vaccine: '疫苗', deworm: '驱虫', weight: '体重', monthlyReport: '月报',
}
const TASK_SHEETS = ['', 'weight', 'poop', 'food', 'symptom', 'vaccine', 'medicine']
const PLAN_SHEETS = ['', 'weight', 'vaccine', 'medicine', 'report', 'taskDetail']
const REMINDER_TYPES = ['vaccine', 'deworm', 'weight', 'monthlyReport']
const REPEATS = [
{ key: 'daily', label: '每天' },
{ key: 'weekly', label: '每周' },
{ key: 'monthly', label: '每月' },
{ key: 'once', label: '一次性' },
]
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日']
// 阶段的展示顺序。后端 CareStages 就是这个顺序,但列表里要用它排序,
// 单独列一份免得依赖接口返回的时序
const STAGE_ORDER = ['刚到家 0-30 天', '幼年期', '青年期', '成年期', '中年期', '老年期']
const selectCls =
'h-9 rounded-md border border-input bg-card px-2 text-sm shrink-0'
const selectCls = 'h-9 rounded-md border border-input bg-card px-2 text-sm shrink-0'
type Item = {
_k: number
kind: 'task' | 'plan' | 'reminder'
kind: 'plan' | 'reminder'
title: string
description: string
priority: string
sheet_type: string
repeat: string
day: number
day_label: string
weekdays: string
month_day: number
reminder_type: string
offset_days: number
frequency: string
}
type Tpl = {
id: string
species: string
stage: string
name: string
summary: string
sort: number
enabled: boolean
items?: any[]
}
let uid = 1
function normalize(raw: any[]): Item[] {
return (raw || []).map((r) => ({
function toItem(r: any): Item {
return {
_k: uid++,
kind: r.kind,
kind: r.kind === 'reminder' ? 'reminder' : 'plan',
title: r.title || '',
description: r.description || '',
priority: r.priority || '',
sheet_type: r.sheet_type || '',
repeat: r.repeat || 'once',
day: r.day ?? 0,
day_label: r.day_label || '',
weekdays: r.weekdays || '',
month_day: r.month_day ?? 1,
reminder_type: r.reminder_type || 'vaccine',
offset_days: r.offset_days ?? 0,
frequency: r.frequency || '',
}))
}
}
function blank(kind: Item['kind']): Item {
return {
_k: uid++, kind, title: '', description: '', priority: '', sheet_type: '',
day: 0, day_label: '', reminder_type: 'vaccine', offset_days: 0, frequency: '',
}
return toItem({ kind, repeat: kind === 'plan' ? 'daily' : '' })
}
export default function CareTemplates() {
const [species, setSpecies] = useState('cat')
const [stage, setStage] = useState('成年期')
const [list, setList] = useState<Tpl[]>([])
const [tab, setTab] = useState('cat')
const [types, setTypes] = useState<any[]>([])
const [stages, setStages] = useState<string[]>([])
const [open, setOpen] = useState(false)
const [tpl, setTpl] = useState<Tpl>({
id: '', species: 'cat', stage: '成年期', name: '', summary: '', sort: 0, enabled: true,
})
const [items, setItems] = useState<Item[]>([])
const [loading, setLoading] = useState(false)
const [err, setErr] = useState('')
const [saving, setSaving] = useState(false)
const [gen, setGen] = useState(false)
const [dirty, setDirty] = useState(false)
const [msg, setMsg] = useState('')
const reqId = useRef(0)
async function load(sp: string, st: string) {
const my = ++reqId.current
setLoading(true)
setMsg('')
function reload() {
api.careTemplates().then((r) => setList(r || [])).catch(() => setList([]))
}
useEffect(() => {
reload()
// 「关联记录事项」的可选值来自 record_types,不再是写死的几个。
// 原来那两个硬编码数组里还有 report / taskDetail 这种早就不存在的值
api
.careTemplateMeta()
.then((m: any) => {
setTypes(m?.record_types || [])
setStages(m?.stages || [])
})
.catch(() => {})
}, [])
function create() {
// 默认跟当前 tab 的物种,省一步点击
setTpl({ id: '', species: tab, stage: '成年期', name: '', summary: '', sort: 0, enabled: true })
setItems([])
setErr('')
setOpen(true)
}
async function edit(t: Tpl) {
setErr('')
const full = await api.careTemplate(t.id)
setTpl({ ...t })
setItems(((full as any)?.items || []).map(toItem))
setOpen(true)
}
async function save() {
setErr('')
setSaving(true)
try {
const data = await api.careTemplate(sp, st)
if (my === reqId.current) {
setItems(normalize(data.items))
setDirty(false)
}
await api.saveCareTemplate({ ...tpl, items })
setOpen(false)
reload()
} catch (e: any) {
// 后端会指名道姓说哪条配错了(「XX 选了每周,但没勾星期几」)
setErr(e?.message || '保存失败')
} finally {
if (my === reqId.current) setLoading(false)
setSaving(false)
}
}
useEffect(() => {
load(species, stage)
}, [species, stage])
function update(k: number, key: keyof Item, value: any) {
setItems((prev) => prev.map((it) => (it._k === k ? { ...it, [key]: value } : it)))
setDirty(true)
async function del(t: Tpl) {
if (!confirm(`删除方案「${t.name || t.stage}」?已经套用出去的计划不受影响。`)) return
await api.deleteCareTemplate(t.id).catch((e: any) => alert(e?.message || '删除失败'))
reload()
}
function remove(k: number) {
setItems((prev) => prev.filter((it) => it._k !== k))
setDirty(true)
}
function add(kind: Item['kind']) {
setItems((prev) => [...prev, blank(kind)])
setDirty(true)
}
async function generate() {
async function draft() {
setGen(true)
setMsg('')
try {
const data = await api.genCareTemplate(species, stage)
setItems(normalize(data.items))
setDirty(true)
setMsg('已生成草稿,确认无误后点「保存该模板」')
const r: any = await api.genCareTemplate(tpl.species, tpl.stage)
setItems((r?.items || []).map(toItem))
} catch (e: any) {
setMsg(e?.message || 'AI 生成失败')
setErr(e?.message || 'AI 生成失败')
} finally {
setGen(false)
}
}
async function save() {
setSaving(true)
setMsg('')
try {
await api.saveCareTemplate(species, stage, items)
setDirty(false)
setMsg('已保存')
} catch (e: any) {
setMsg(e?.message || '保存失败')
} finally {
setSaving(false)
}
const upd = (k: number, field: string, v: any) =>
setItems((prev) => prev.map((it) => (it._k === k ? { ...it, [field]: v } : it)))
const rm = (k: number) => setItems((prev) => prev.filter((it) => it._k !== k))
function toggleWd(it: Item, idx: number) {
const n = String(idx + 1)
const cur = it.weekdays.split(',').filter(Boolean)
const next = cur.includes(n) ? cur.filter((x) => x !== n) : [...cur, n].sort()
upd(it._k, 'weekdays', next.join(','))
}
const tasks = useMemo(() => items.filter((i) => i.kind === 'task'), [items])
const plans = useMemo(() => items.filter((i) => i.kind === 'plan'), [items])
const reminders = useMemo(() => items.filter((i) => i.kind === 'reminder'), [items])
// 按 tab 过滤,并按阶段顺序排(数据库按 sort 排,但 sort 是全局的,
// 分物种看时会跳号)
const order = STAGE_ORDER
const shown = list
.filter((t) => t.species === tab)
.sort((a, b) => order.indexOf(a.stage) - order.indexOf(b.stage))
const plans = items.filter((i) => i.kind === 'plan')
const reminders = items.filter((i) => i.kind === 'reminder')
// 列表里展示「这套方案套一个月大概会排多少条」——只报明细条数会骗人:
// 4 条「每天」套一整月就是 120 多条
function spread(t: Tpl) {
const its = t.items || []
const c = { daily: 0, weekly: 0, monthly: 0, once: 0, rem: 0 }
its.forEach((i: any) => {
if (i.kind === 'reminder') c.rem++
else if (i.repeat === 'daily') c.daily++
else if (i.repeat === 'weekly') c.weekly++
else if (i.repeat === 'monthly') c.monthly++
else c.once++
})
return c
}
return (
<div>
<div className="flex items-start justify-between mb-4">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1">
/
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={generate} disabled={gen}>
<Wand2 className="h-4 w-4" /> {gen ? '生成中…' : 'AI 生成草稿'}
</Button>
<Button onClick={save} disabled={saving || !dirty}>
<Save className="h-4 w-4" /> {saving ? '保存中…' : '保存该模板'}
</Button>
</div>
<Button onClick={create}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex gap-2 mb-2">
{SPECIES.map((s) => (
<button
key={s.key}
onClick={() => setSpecies(s.key)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm transition-colors',
species === s.key ? 'bg-primary/15 text-primary border-primary/40' : 'text-muted-foreground hover:bg-accent',
)}
>
<s.Icon className="h-4 w-4" /> {s.label}
</button>
))}
</div>
<div className="flex flex-wrap gap-2 mb-4">
{STAGES.map((st) => (
<button
key={st}
onClick={() => setStage(st)}
className={cn(
'rounded-full border px-3 py-1.5 text-sm transition-colors',
stage === st ? 'bg-primary/15 text-primary border-primary/40' : 'text-muted-foreground hover:bg-accent',
)}
>
{st}
</button>
))}
{/* 猫狗分开看。混在一起 10 套要上下找,而且阶段名一样(幼年期/青年期…),
扫一眼分不清哪套是猫的 */}
<div className="mb-4 flex items-center gap-2">
{SPECIES.map((s) => {
const n = list.filter((t) => t.species === s.key).length
return (
<Button
key={s.key}
size="sm"
variant={tab === s.key ? 'default' : 'outline'}
onClick={() => setTab(s.key)}
>
<s.Icon className="h-4 w-4" /> {s.label} {n}
</Button>
)
})}
</div>
{msg && (
<div className="mb-4 flex items-center gap-2 rounded-md bg-primary/10 px-3 py-2 text-sm text-primary">
<Info className="h-4 w-4" /> {msg}
</div>
)}
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-24"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-20"></TableHead>
<TableHead className="w-32 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{shown.map((t) => {
const c = spread(t)
return (
<TableRow key={t.id}>
<TableCell className="text-muted-foreground">{t.stage}</TableCell>
<TableCell>
<div className="font-medium">{t.name || '(未命名)'}</div>
<div className="text-xs text-muted-foreground">{t.summary}</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{c.daily} · {c.weekly} · {c.monthly} · {c.once} · {c.rem}
</TableCell>
<TableCell>
{t.enabled ? <Badge></Badge> : <Badge variant="secondary"></Badge>}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => edit(t)}>
</Button>
<Button variant="ghost" size="sm" onClick={() => del(t)}>
</Button>
</TableCell>
</TableRow>
)
})}
{!shown.length && (
<TableRow>
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
{loading ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="space-y-4">
<Section title="今日任务" count={tasks.length} onAdd={() => add('task')}>
{tasks.map((it) => (
<div key={it._k} className="flex flex-wrap items-center gap-2">
<Input className="flex-1 min-w-40" placeholder="任务标题" value={it.title} onChange={(e) => update(it._k, 'title', e.target.value)} />
<Input className="flex-1 min-w-40" placeholder="说明" value={it.description} onChange={(e) => update(it._k, 'description', e.target.value)} />
<select className={selectCls} value={it.priority} onChange={(e) => update(it._k, 'priority', e.target.value)}>
<option value=""></option>
<option value="重要"></option>
</select>
<select className={selectCls} value={it.sheet_type} onChange={(e) => update(it._k, 'sheet_type', e.target.value)}>
{TASK_SHEETS.map((s) => <option key={s} value={s}>·{SHEET_LABEL[s]}</option>)}
</select>
<DelBtn onClick={() => remove(it._k)} />
</div>
))}
</Section>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[88vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{tpl.id ? '编辑方案' : '新建方案'}</DialogTitle>
</DialogHeader>
<Section title="30 天计划" count={plans.length} onAdd={() => add('plan')}>
{plans.map((it) => (
<div key={it._k} className="flex flex-wrap items-center gap-2">
<Input type="number" className="w-16" placeholder="第N天" value={it.day} onChange={(e) => update(it._k, 'day', Number(e.target.value))} />
<Input className="w-24" placeholder="标签" value={it.day_label} onChange={(e) => update(it._k, 'day_label', e.target.value)} />
<Input className="flex-1 min-w-36" placeholder="节点标题" value={it.title} onChange={(e) => update(it._k, 'title', e.target.value)} />
<Input className="flex-1 min-w-36" placeholder="说明" value={it.description} onChange={(e) => update(it._k, 'description', e.target.value)} />
<select className={selectCls} value={it.sheet_type} onChange={(e) => update(it._k, 'sheet_type', e.target.value)}>
{PLAN_SHEETS.map((s) => <option key={s} value={s}>·{SHEET_LABEL[s]}</option>)}
</select>
<DelBtn onClick={() => remove(it._k)} />
</div>
))}
</Section>
<Section title="提醒" count={reminders.length} onAdd={() => add('reminder')}>
{reminders.map((it) => (
<div key={it._k} className="flex flex-wrap items-center gap-2">
<select className={selectCls} value={it.reminder_type} onChange={(e) => update(it._k, 'reminder_type', e.target.value)}>
{REMINDER_TYPES.map((s) => <option key={s} value={s}>{REMINDER_LABEL[s]}</option>)}
</select>
<Input className="flex-1 min-w-40" placeholder="提醒标题" value={it.title} onChange={(e) => update(it._k, 'title', e.target.value)} />
<div className="flex items-center gap-1 shrink-0">
<span className="text-sm text-muted-foreground">+</span>
<Input type="number" className="w-16" value={it.offset_days} onChange={(e) => update(it._k, 'offset_days', Number(e.target.value))} />
<span className="text-sm text-muted-foreground"></span>
<div className="grid gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label></Label>
<div className="flex gap-2">
{SPECIES.map((s) => (
<Button
key={s.key}
type="button"
size="sm"
variant={tpl.species === s.key ? 'default' : 'outline'}
onClick={() => setTpl({ ...tpl, species: s.key })}
>
<s.Icon className="h-4 w-4" /> {s.label}
</Button>
))}
</div>
<Input className="flex-1 min-w-36" placeholder="或填频率(到期天数填0)" value={it.frequency} onChange={(e) => update(it._k, 'frequency', e.target.value)} />
<DelBtn onClick={() => remove(it._k)} />
</div>
))}
</Section>
<div className="grid gap-2">
<Label></Label>
<select
className={selectCls}
value={tpl.stage}
onChange={(e) => setTpl({ ...tpl, stage: e.target.value })}
>
{stages.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
</div>
<div className="grid gap-2">
<Label></Label>
<Input
value={tpl.name}
onChange={(e) => setTpl({ ...tpl, name: e.target.value })}
placeholder="幼猫标准照护"
/>
</div>
<div className="grid gap-2">
<Label></Label>
<Input
value={tpl.summary}
onChange={(e) => setTpl({ ...tpl, summary: e.target.value })}
placeholder="每天清砂盆和喂食,每周称体重,含疫苗驱虫提醒"
/>
</div>
<div className="flex items-center gap-2">
<input
id="ct-enabled"
type="checkbox"
checked={tpl.enabled}
onChange={(e) => setTpl({ ...tpl, enabled: e.target.checked })}
/>
<Label htmlFor="ct-enabled" className="cursor-pointer">
</Label>
</div>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Info className="h-3.5 w-3.5" /> 退
</p>
</div>
)}
{/* ── 计划事项 ── */}
<div className="rounded-md border p-3">
<div className="mb-3 flex items-center justify-between">
<div className="font-medium">{plans.length}</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={draft} disabled={gen}>
<Wand2 className="h-4 w-4" /> {gen ? '生成中…' : 'AI 起草'}
</Button>
<Button size="sm" variant="outline" onClick={() => setItems([...items, blank('plan')])}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
<div className="mb-3 flex items-start gap-2 rounded bg-muted p-2 text-xs text-muted-foreground">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
=
</span>
</div>
<div className="grid gap-2">
{plans.map((it) => (
<div key={it._k} className="flex flex-wrap items-center gap-2">
<Input
className="min-w-40 flex-1"
placeholder="做什么"
value={it.title}
onChange={(e) => upd(it._k, 'title', e.target.value)}
/>
<Input
className="min-w-40 flex-1"
placeholder="说明"
value={it.description}
onChange={(e) => upd(it._k, 'description', e.target.value)}
/>
<select
className={selectCls}
value={it.repeat}
onChange={(e) => upd(it._k, 'repeat', e.target.value)}
>
{REPEATS.map((r) => (
<option key={r.key} value={r.key}>
{r.label}
</option>
))}
</select>
{it.repeat === 'weekly' && (
<div className="flex gap-1">
{WEEKDAYS.map((w, i) => (
<button
key={w}
type="button"
onClick={() => toggleWd(it, i)}
className={
'h-7 w-7 rounded text-xs ' +
(it.weekdays.split(',').includes(String(i + 1))
? 'bg-primary text-primary-foreground'
: 'border border-input bg-card')
}
>
{w}
</button>
))}
</div>
)}
{it.repeat === 'monthly' && (
<Input
type="number"
className="w-20"
placeholder="几号"
value={it.month_day}
onChange={(e) => upd(it._k, 'month_day', Number(e.target.value) || 1)}
/>
)}
{it.repeat === 'once' && (
<Input
type="number"
className="w-24"
placeholder="第N天"
value={it.day}
onChange={(e) => upd(it._k, 'day', Number(e.target.value) || 0)}
/>
)}
<select
className={selectCls}
value={it.priority}
onChange={(e) => upd(it._k, 'priority', e.target.value)}
>
<option value=""></option>
<option value="重要"></option>
</select>
<select
className={selectCls}
value={it.sheet_type}
onChange={(e) => upd(it._k, 'sheet_type', e.target.value)}
>
<option value=""></option>
{types.map((t: any) => (
<option key={t.code} value={t.code}>
{t.label}
</option>
))}
</select>
<Button variant="ghost" size="sm" onClick={() => rm(it._k)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{!plans.length && (
<p className="py-4 text-center text-sm text-muted-foreground"></p>
)}
</div>
</div>
{/* ── 提醒 ── */}
<div className="rounded-md border p-3">
<div className="mb-3 flex items-center justify-between">
<div className="font-medium">{reminders.length}</div>
<Button size="sm" variant="outline" onClick={() => setItems([...items, blank('reminder')])}>
<Plus className="h-4 w-4" />
</Button>
</div>
<p className="mb-3 text-xs text-muted-foreground">
</p>
<div className="grid gap-2">
{reminders.map((it) => (
<div key={it._k} className="flex flex-wrap items-center gap-2">
<select
className={selectCls}
value={it.reminder_type}
onChange={(e) => upd(it._k, 'reminder_type', e.target.value)}
>
{REMINDER_TYPES.map((r) => (
<option key={r} value={r}>
{REMINDER_LABEL[r]}
</option>
))}
</select>
<Input
className="min-w-40 flex-1"
placeholder="提醒标题"
value={it.title}
onChange={(e) => upd(it._k, 'title', e.target.value)}
/>
<Input
type="number"
className="w-28"
placeholder="N天后到期"
value={it.offset_days}
onChange={(e) => upd(it._k, 'offset_days', Number(e.target.value) || 0)}
/>
<Input
className="w-40"
placeholder="频率说明"
value={it.frequency}
onChange={(e) => upd(it._k, 'frequency', e.target.value)}
/>
<Button variant="ghost" size="sm" onClick={() => rm(it._k)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{!reminders.length && (
<p className="py-4 text-center text-sm text-muted-foreground"></p>
)}
</div>
</div>
{err && <p className="text-sm text-destructive">{err}</p>}
<Button onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存方案'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
function Section({ title, count, onAdd, children }: { title: string; count: number; onAdd: () => void; children: React.ReactNode }) {
return (
<Card>
<CardContent className="pt-4 pb-4">
<div className="flex items-center justify-between mb-3">
<div className="font-medium">
{title} <span className="text-muted-foreground">{count}</span>
</div>
<Button variant="outline" size="sm" onClick={onAdd}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
{count === 0 ? <p className="text-sm text-muted-foreground"></p> : children}
</div>
</CardContent>
</Card>
)
}
function DelBtn({ onClick }: { onClick: () => void }) {
return (
<Button variant="ghost" size="icon" className="text-muted-foreground shrink-0" onClick={onClick} aria-label="删除">
<Trash2 className="h-4 w-4" />
</Button>
)
}
+71 -1
View File
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
type Cfg = { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }
type Quota = { enabled: boolean; daily_chat: number; daily_symptom: number; daily_plan: number }
export default function CommunityBot() {
const [cfg, setCfg] = useState<Cfg>({ enabled: true, daily_count: 3, start_hour: 9, end_hour: 21 })
@@ -15,14 +16,38 @@ export default function CommunityBot() {
const [genN, setGenN] = useState(5)
const [gen, setGen] = useState(false)
const [msg, setMsg] = useState('')
// AI 额度和社区机器人都是「烧多少 token」的开关,放一页里管
const [q, setQ] = useState<Quota>({ enabled: true, daily_chat: 20, daily_symptom: 10, daily_plan: 5 })
const [qSaving, setQSaving] = useState(false)
const [qMsg, setQMsg] = useState('')
useEffect(() => {
api.communityBot().then((c) => {
setCfg(c)
setLoading(false)
})
api.aiQuota().then(setQ).catch(() => {})
}, [])
const qNum = (k: keyof Quota) => (e: any) => {
let v = Number(e.target.value)
if (Number.isNaN(v) || v < 0) v = 0
setQ({ ...q, [k]: Math.min(999, v) })
}
async function saveQuota() {
setQSaving(true)
setQMsg('')
try {
setQ(await api.saveAIQuota(q))
setQMsg('已保存,立即生效')
} catch (e: any) {
setQMsg(e.message || '保存失败')
} finally {
setQSaving(false)
}
}
const num = (k: keyof Cfg, min: number, max: number) => (e: any) => {
let v = Number(e.target.value)
if (Number.isNaN(v)) v = min
@@ -144,6 +169,51 @@ export default function CommunityBot() {
</Card>
</div>
)}
</div>
<Card className="mt-4">
<CardContent className="pt-6 space-y-4">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
<span className="font-medium">AI </span>
</div>
<p className="text-sm text-muted-foreground flex gap-2">
<Info className="h-4 w-4 shrink-0 mt-0.5" />
<span>
/ 0
AI
</span>
</p>
<div className="flex items-center gap-2">
<input
id="q-enabled"
type="checkbox"
checked={q.enabled}
onChange={(e) => setQ({ ...q, enabled: e.target.checked })}
/>
<Label htmlFor="q-enabled"></Label>
</div>
<div className="grid grid-cols-3 gap-4 max-w-lg">
<div className="space-y-1.5">
<Label> AI</Label>
<Input type="number" value={q.daily_chat} onChange={qNum('daily_chat')} />
</div>
<div className="space-y-1.5">
<Label></Label>
<Input type="number" value={q.daily_symptom} onChange={qNum('daily_symptom')} />
</div>
<div className="space-y-1.5">
<Label>AI </Label>
<Input type="number" value={q.daily_plan} onChange={qNum('daily_plan')} />
</div>
</div>
<div className="flex items-center gap-3">
<Button onClick={saveQuota} disabled={qSaving}>
<Save className="h-4 w-4" /> {qSaving ? '保存中…' : '保存额度'}
</Button>
{qMsg && <span className="text-sm text-muted-foreground">{qMsg}</span>}
</div>
</CardContent>
</Card>
</div>
)
}
+22 -6
View File
@@ -7,19 +7,24 @@ import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export default function Login() {
const [username, setUsername] = useState('sundynix')
const [password, setPassword] = useState('sundynix')
// 不预填账号密码:预填等于把后台凭证写在页面上
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
async function submit(e: React.FormEvent) {
e.preventDefault()
if (!username.trim() || !password) {
setError('请输入账号和密码')
return
}
setLoading(true)
setError('')
try {
const res = await api.login(username, password)
setToken(res.token)
const res = await api.login(username.trim(), password)
setToken(res.token, res.refresh_token)
navigate('/')
} catch (err: any) {
setError(err.message || '登录失败')
@@ -39,11 +44,22 @@ export default function Login() {
<form onSubmit={submit} className="space-y-4">
<div className="space-y-1.5">
<Label></Label>
<Input value={username} onChange={(e) => setUsername(e.target.value)} />
<Input
value={username}
autoComplete="username"
placeholder="请输入账号"
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<Input
type="password"
value={password}
autoComplete="current-password"
placeholder="请输入密码"
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
+19
View File
@@ -12,6 +12,21 @@ const statusMap: Record<string, { label: string; variant: any }> = {
deleted: { label: '已删除', variant: 'destructive' },
}
// 今天的只看时分,往前的看月日——审核时关心的是「多久以前发的」
function fmtTime(iso?: string) {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return '—'
const p = (n: number) => String(n).padStart(2, '0')
const hm = `${p(d.getHours())}:${p(d.getMinutes())}`
const now = new Date()
if (d.toDateString() === now.toDateString()) return `今天 ${hm}`
const y = new Date(now.getTime() - 86400000)
if (d.toDateString() === y.toDateString()) return `昨天 ${hm}`
const sameYear = d.getFullYear() === now.getFullYear()
return `${sameYear ? '' : d.getFullYear() + '-'}${p(d.getMonth() + 1)}-${p(d.getDate())} ${hm}`
}
export default function Posts() {
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.posts)
@@ -32,6 +47,7 @@ export default function Posts() {
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>/</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
@@ -48,6 +64,9 @@ export default function Posts() {
<TableCell className="whitespace-nowrap">
{p.like_count} / {p.comment_count}
</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">
{fmtTime(p.created_at)}
</TableCell>
<TableCell>
<Badge variant={statusMap[p.status]?.variant}>{statusMap[p.status]?.label ?? p.status}</Badge>
</TableCell>
+275
View File
@@ -0,0 +1,275 @@
import { useEffect, useState } from 'react'
import { api } from '@/lib/api'
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'
type Field = { kind: string; label: string; unit?: string; options?: string[]; to_category: boolean }
type RT = {
id: string
code: string
label: string
icon: string
group: string
sort: number
enabled: boolean
form: string
locked: boolean
fields_raw: string
fields: Field[]
}
const empty: any = {
id: '', code: '', label: '', icon: 'note', group: 'daily',
sort: 0, enabled: true, form: 'simple', locked: false, fields_raw: '',
}
const GROUPS = [
{ key: 'daily', label: '日常' },
{ key: 'health', label: '健康' },
{ key: 'care', label: '洗护' },
{ key: 'clean', label: '清洁' },
]
const gLabel = (k: string) => GROUPS.find((g) => g.key === k)?.label || k
// 字段配置那段紧凑写法的说明。放在弹窗里而不是文档里——
// 这东西一年用两次,去翻文档的成本比它本身还高
const HINT = `number:体重:kg 数值,落 num_value
options:状态:正常|软便|拉稀 单选,落 category
options:症状:呕吐|拉稀:- 末尾 - 表示不落 category(只进标题)
text:吃了什么 文本,只进标题
(留空) 只要「时间 + 描述 + 照片」`
export default function RecordTypes() {
const [list, setList] = useState<RT[]>([])
const [open, setOpen] = useState(false)
const [form, setForm] = useState<any>(empty)
const [err, setErr] = useState('')
function reload() {
api
.recordTypes()
.then((r) => setList(r || []))
.catch(() => setList([]))
}
useEffect(reload, [])
function create() {
setForm({ ...empty })
setErr('')
setOpen(true)
}
function edit(t: RT) {
setForm({ ...t })
setErr('')
setOpen(true)
}
async function save() {
setErr('')
try {
await api.saveRecordType(form)
setOpen(false)
reload()
} catch (e: any) {
// 字段配置写错了后端会说第几行错在哪,原样透出
setErr(e?.message || '保存失败')
}
}
async function del(t: RT) {
if (!confirm(`删除「${t.label}」?`)) return
try {
await api.deleteRecordType(t.id)
reload()
} catch (e: any) {
alert(e?.message || '删除失败')
}
}
const set = (k: string) => (e: any) => setForm({ ...form, [k]: e.target.value })
// 字段配置渲染成人话,省得盯着 DSL 猜
function fieldsText(t: RT) {
if (!t.fields?.length) return <span className="text-muted-foreground"> + + </span>
return (
<span className="space-x-2">
{t.fields.map((f, i) => (
<span key={i} className="whitespace-nowrap text-xs">
{f.label}
{f.kind === 'number' && `·数值${f.unit || ''}`}
{f.kind === 'text' && '·文本'}
{f.kind === 'options' && `·单选${f.to_category ? '★' : ''}`}
</span>
))}
</span>
)
}
const byGroup: Record<string, RT[]> = {}
list.forEach((t) => ((byGroup[t.group] = byGroup[t.group] || []).push(t)))
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">
category
</p>
</div>
<Button onClick={create}></Button>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20"></TableHead>
<TableHead className="w-28"></TableHead>
<TableHead className="w-28">code</TableHead>
<TableHead></TableHead>
<TableHead className="w-16"></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-32 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{GROUPS.map((g) =>
(byGroup[g.key] || []).map((t, i) => (
<TableRow key={t.id}>
<TableCell className="text-muted-foreground">{i === 0 ? g.label : ''}</TableCell>
<TableCell className="font-medium">{t.label}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{t.code}</TableCell>
<TableCell>{fieldsText(t)}</TableCell>
<TableCell className="text-muted-foreground">{t.sort}</TableCell>
<TableCell>
{t.enabled ? <Badge></Badge> : <Badge variant="secondary"></Badge>}
{t.locked && (
<Badge variant="outline" className="ml-1">
</Badge>
)}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => edit(t)}>
</Button>
{!t.locked && (
<Button variant="ghost" size="sm" onClick={() => del(t)}>
</Button>
)}
</TableCell>
</TableRow>
)),
)}
{!list.length && (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{form.id ? `编辑「${form.label}` : '新增事项'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label></Label>
<Input value={form.label} onChange={set('label')} placeholder="洗澡" />
</div>
<div className="grid gap-2">
<Label>code type</Label>
<Input
value={form.code}
onChange={set('code')}
placeholder="bath"
className="font-mono"
disabled={form.locked}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{GROUPS.map((g) => (
<Button
key={g.key}
type="button"
variant={form.group === g.key ? 'default' : 'outline'}
size="sm"
onClick={() => setForm({ ...form, group: g.key })}
>
{g.label}
</Button>
))}
</div>
</div>
<div className="grid gap-2">
<Label>pt-icon</Label>
<Input value={form.icon} onChange={set('icon')} placeholder="bath" className="font-mono" />
</div>
</div>
<div className="grid gap-2">
<Label> + + </Label>
<textarea
className="min-h-[120px] w-full rounded-md border border-input bg-card px-3 py-2 font-mono text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={form.fields_raw || ''}
onChange={set('fields_raw')}
placeholder="number:体重:kg"
/>
<pre className="whitespace-pre-wrap rounded bg-muted p-3 text-xs text-muted-foreground">
{HINT}
</pre>
<p className="text-xs text-muted-foreground">
category <code>:-</code>
</p>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Label></Label>
<Input
type="number"
value={form.sort}
onChange={(e) => setForm({ ...form, sort: Number(e.target.value) || 0 })}
className="w-24"
/>
</div>
<div className="flex items-center gap-2">
<input
id="rt-enabled"
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
/>
<Label htmlFor="rt-enabled" className="cursor-pointer">
</Label>
</div>
</div>
{form.locked && (
<p className="text-xs text-muted-foreground">
code code
</p>
)}
{err && <p className="text-sm text-destructive">{err}</p>}
<Button onClick={save}></Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
+4
View File
@@ -49,6 +49,7 @@ export default function Users() {
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
@@ -67,6 +68,9 @@ export default function Users() {
)}
</TableCell>
<TableCell>{u.phone || '—'}</TableCell>
<TableCell className="whitespace-nowrap">
{u.pet_count > 0 ? `${u.pet_count}` : <span className="text-muted-foreground"></span>}
</TableCell>
<TableCell>{u.onboarded ? '已建档' : '未建档'}</TableCell>
<TableCell>
{u.disabled ? <Badge variant="destructive"></Badge> : <Badge variant="success"></Badge>}
+1 -1
View File
@@ -19,7 +19,7 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8080',
'/api': 'http://localhost:9090',
},
},
})
+4
View File
@@ -0,0 +1,4 @@
# 联调时写 token 的临时文件
adm.txt
at.txt
+26 -6
View File
@@ -4,11 +4,20 @@
"pages/home/home",
"pages/plan/plan",
"pages/record/record",
"pages/addrecord/addrecord",
"pages/history/history",
"pages/expense/expense",
"pages/petform/petform",
"pages/idcard/idcard",
"pages/report/report",
"pages/community/community",
"pages/learn/learn",
"pages/article/article",
"pages/profile/profile"
"pages/mine/mine",
"pages/decorate/decorate",
"pages/settings/settings",
"pages/user/user",
"pages/relations/relations"
],
"window": {
"navigationStyle": "custom",
@@ -21,11 +30,22 @@
"selectedColor": "#DD8420",
"backgroundColor": "#FFFFFF",
"list": [
{ "pagePath": "pages/home/home", "text": "首页" },
{ "pagePath": "pages/plan/plan", "text": "计划" },
{ "pagePath": "pages/record/record", "text": "记录" },
{ "pagePath": "pages/report/report", "text": "报告" },
{ "pagePath": "pages/community/community", "text": "社区" }
{
"pagePath": "pages/home/home",
"text": "首页"
},
{
"pagePath": "pages/report/report",
"text": "报告"
},
{
"pagePath": "pages/community/community",
"text": "社区"
},
{
"pagePath": "pages/mine/mine",
"text": "我的"
}
]
},
"sitemapLocation": "sitemap.json",
+355 -109
View File
@@ -1,39 +1,107 @@
/* ===== 全局设计令牌 + 通用样式(由 preview.html 迁移,px→rpx ×2 ===== */
/* ===== 全局设计令牌 + 通用样式 =====
规则:任何页面/组件都不要再写死色值、圆角、字号,一律用下面的 token。
收敛前的现状是 22 种圆角、26 档字号、9 种近似浅橙、7 处一次性阴影,
font-weight 900 到处都是——什么都在喊,于是什么都不突出。 */
page{
/* ── 底色与文字 ── */
--bg:#F8F6F2;
--bg2:#FBF8F3;
--card:#FFFFFF;
--surface-2:#FBFAF8; /* 卡片内的次级底色(原本硬编码出现 10+ 次) */
--text:#2D2925;
--text-2:#5F554D; /* 正文次级灰(原本有 7 种近似值散落各处) */
--muted:#8D8277;
--muted2:#B5A99D;
--line:#EEE6DC;
/* ── 主色 ── */
--primary:#F5A947;
--primary2:#FFBF68;
--primary-dark:#DD8420;
--green:#73BE9D;
--green-soft:#EAF7F1;
--red:#EE7D73;
--red-soft:#FFF0EE;
--blue:#78A6E8;
--blue-soft:#EEF5FF;
--purple:#A993E8;
--purple-soft:#F4F0FF;
--primary-soft:#FFF4E4; /* 唯一的浅橙底(收编了 9 种近似值) */
--primary-line:#FFD6A1; /* 浅橙块的描边 */
--primary-ink:#A96500; /* 浅橙底上的文字 */
/* ── 语义色:soft 底 / base 主体 / ink 文字,三件套配齐 ── */
--green:#73BE9D; --green-soft:#EAF7F1; --green-ink:#2E7D5B;
--red:#EE7D73; --red-soft:#FFF0EE; --red-ink:#C95348;
--blue:#78A6E8; --blue-soft:#EEF5FF; --blue-ink:#3C6EBA;
--purple:#A993E8; --purple-soft:#F4F0FF; --purple-ink:#765AC5;
--yellow-soft:#FFF6E4;
--shadow:0 24rpx 60rpx rgba(70,48,25,.08);
--shadow2:0 36rpx 88rpx rgba(70,48,25,.13);
--radius:44rpx;
/* ── 深色主 CTA。全页最多一个,用来指明「这一步该点哪」 ── */
--cta:#2D2925;
/* ── 圆角 5 档 ── */
--r-xs:12rpx;
--r-sm:20rpx;
--r-md:28rpx;
--r-lg:40rpx; /* 卡片 */
--r-full:999rpx;
--radius:var(--r-lg); /* 兼容旧名,页面里还有引用 */
/* ── 字号 6 档 ── */
--fs-cap:22rpx;
--fs-sm:24rpx;
--fs-md:28rpx;
--fs-lg:32rpx;
--fs-xl:40rpx;
--fs-2xl:48rpx;
/* ── 字重只留两档。强调靠颜色和字号,不靠把所有东西都加粗 ── */
--fw:400;
--fw-b:600;
/* ── 间距 6 档 ── */
--sp-1:8rpx; --sp-2:12rpx; --sp-3:16rpx;
--sp-4:24rpx; --sp-5:32rpx; --sp-6:40rpx;
/* ── 控件高度 3 档 ── */
--h-sm:68rpx;
--h-md:80rpx;
--h-lg:96rpx;
/* ── 阴影 3 档(原来 7 处一次性阴影,且都偏重) ── */
--sd-1:0 12rpx 32rpx rgba(70,48,25,.06);
--sd-2:0 20rpx 48rpx rgba(70,48,25,.10);
--sd-3:0 -20rpx 60rpx rgba(70,48,25,.14);
--shadow:var(--sd-1); /* 兼容旧名 */
--shadow2:var(--sd-2);
/* 页面留白。FAB 固定在 bottom:190rpx、高 116rpx,上沿距底 306rpx
内容必须让开这段距离,否则最后一张卡会被它压住。 */
--pad-x:40rpx;
--pad-b-fab:calc(330rpx + env(safe-area-inset-bottom));
--pad-b-plain:calc(60rpx + env(safe-area-inset-bottom));
/* tab 页底下有原生 tabBar(高 108rpx)。报告页/我的没有 FAB,用 no-fab 的
60rpx 让不开 tabBar,最后一张卡会被 tabBar 压住 —— 这两页要用这个 */
--pad-b-tab:calc(150rpx + env(safe-area-inset-bottom));
color:var(--text);
font-weight:var(--fw);
font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","PingFang SC","Microsoft YaHei",sans-serif;
background:
radial-gradient(circle at 10% 8%, rgba(245,169,71,.26), transparent 30%),
radial-gradient(circle at 88% 10%, rgba(115,190,157,.20), transparent 26%),
radial-gradient(circle at 72% 88%, rgba(120,166,232,.14), transparent 28%),
linear-gradient(135deg,#FBF5EA,#EEF6F1 55%,#F8F1E7);
min-height:100vh;
/* 页面自身不滚,滚动交给 .page-scroll,这样 nav-bar 和 tabBar 才能固定住。
这 4 行原本在 9 个页面的 wxss 里逐字重复。 */
height:100vh;
overflow:hidden;
display:flex;
flex-direction:column;
}
view,text{box-sizing:border-box}
/* 页面骨架:nav-bar → .page-scroll(承载滚动) → .page-body(内容留白) */
.page-scroll{flex:1;min-height:0}
.page-body{padding:var(--sp-1) var(--pad-x) var(--pad-b-fab)}
/* 没挂 FAB 的页面(目前只有文章详情)不需要给它让位 */
.page-body.no-fab{padding-bottom:var(--pad-b-plain)}
.page-body.tab-nofab{padding-bottom:var(--pad-b-tab)}
/* 全局隐藏滚动条(页面级 + 组件级),保留正常滚动 */
::-webkit-scrollbar{
width:0 !important;
@@ -47,207 +115,385 @@ page{scrollbar-width:none;-ms-overflow-style:none}
/* 文本 */
.muted{color:var(--muted)}
.tiny{font-size:24rpx;color:var(--muted)}
.tiny{font-size:var(--fs-sm);color:var(--muted)}
.bold{font-weight:var(--fw-b)}
/* 区块标题 */
.section-head{
display:flex;align-items:center;justify-content:space-between;margin:8rpx 0 24rpx;
display:flex;align-items:center;justify-content:space-between;margin:var(--sp-1) 0 var(--sp-4);
}
.section-head .sh-title{font-size:36rpx;letter-spacing:-.4rpx;font-weight:700}
.link{
color:var(--primary-dark);font-weight:900;font-size:26rpx;
}
.head-links{display:flex;align-items:center;gap:26rpx}
.section-head .sh-title{font-size:var(--fs-lg);letter-spacing:-.4rpx;font-weight:var(--fw-b)}
.link{color:var(--primary-dark);font-weight:var(--fw-b);font-size:var(--fs-md)}
/* 一组 .link 当「切换」用时:未选中压成灰,不然两个都是橙的、看不出当前在哪。
单独一个 .link-tabs 而不是挂在 .head-links 上——首页的「管理 / 全部完成」
也用 head-links,那是两个并列动作,不该有一个是灰的 */
.link-tabs{display:flex;gap:var(--sp-4)}
.link-tabs .link{color:var(--muted)}
.link-tabs .link.on{color:var(--primary-dark)}
.head-links{display:flex;align-items:center;gap:var(--sp-4)}
/* 页面标题 */
.page-title{margin:4rpx 0 28rpx}
.page-title h2{font-size:46rpx;letter-spacing:-.6rpx;font-weight:800}
.page-title p{color:var(--muted);font-size:26rpx;margin-top:10rpx}
/* 页面标题
小程序没有 h2/p/b/span 标签,从 preview.html 迁移来的标签选择器全是死规则,
各页当初都自建了替身 class(.pt-h2 在 5 个文件里逐字重复),这里统一收编。 */
.page-title{margin:4rpx 0 var(--sp-4)}
.pt-h2{font-size:var(--fs-2xl);letter-spacing:-.6rpx;font-weight:var(--fw-b)}
.pt-p{color:var(--muted);font-size:var(--fs-md);margin-top:10rpx}
/* 按钮 */
.btn{
border:0;height:96rpx;border-radius:34rpx;padding:0 32rpx;
display:inline-flex;align-items:center;justify-content:center;gap:16rpx;
font-weight:900;font-size:30rpx;line-height:96rpx;transition:.16s ease;
border:0;height:var(--h-lg);border-radius:var(--r-md);padding:0 var(--sp-5);
display:inline-flex;align-items:center;justify-content:center;gap:var(--sp-2);
font-weight:var(--fw-b);font-size:var(--fs-lg);line-height:1.3;transition:.16s ease;
min-height:var(--h-lg);height:auto;padding-top:var(--sp-2);padding-bottom:var(--sp-2);
}
.btn::after{border:none}
.btn:active{transform:scale(.98)}
.btn-primary{
color:#fff;background:linear-gradient(135deg,var(--primary),var(--primary2));
box-shadow:0 24rpx 48rpx rgba(245,169,71,.28);
}
/* 主按钮保持暖橙:它在 36 处被使用,绝大多数是弹层里的「保存」,
把它改成深色会让整个 App 的主色消失。深色 CTA 走 .btn-dark。 */
.btn-primary{color:#fff;background:var(--primary);box-shadow:0 12rpx 28rpx rgba(245,169,71,.24)}
.btn-dark{color:#fff;background:var(--cta)}
.btn-ghost{color:var(--text);background:#fff;border:1rpx solid var(--line)}
.btn-soft{color:#9E6200;background:var(--yellow-soft)}
.btn-green{color:#fff;background:linear-gradient(135deg,#69B991,#86CEB0)}
.btn-soft{color:var(--primary-ink);background:var(--primary-soft)}
.btn-green{color:#fff;background:var(--green)}
.btn-block{width:100%;display:flex}
.btn-small{height:68rpx;border-radius:24rpx;padding:0 22rpx;font-size:24rpx;line-height:68rpx}
.btn-small{min-height:var(--h-sm);border-radius:var(--r-sm);padding:0 var(--sp-4);font-size:var(--fs-sm)}
/* 卡片 */
.card{
background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);
padding:32rpx;margin-bottom:28rpx;position:relative;overflow:hidden;
background:var(--card);border-radius:var(--r-lg);box-shadow:var(--sd-1);
padding:var(--sp-5);margin-bottom:var(--sp-4);position:relative;overflow:hidden;
}
.card.no-shadow{box-shadow:none;border:1rpx solid var(--line)}
/* 标签 */
.tag{
display:inline-flex;align-items:center;height:48rpx;border-radius:999rpx;padding:0 18rpx;
font-size:24rpx;font-weight:900;background:var(--green-soft);color:#377A61;white-space:nowrap;
display:inline-flex;align-items:center;height:48rpx;border-radius:var(--r-full);padding:0 var(--sp-3);
font-size:var(--fs-sm);font-weight:var(--fw-b);background:var(--green-soft);color:var(--green-ink);white-space:nowrap;
}
.tag.warn{background:#FFF0D7;color:#A96500}
.tag.red{background:var(--red-soft);color:#C95348}
.tag.blue{background:var(--blue-soft);color:#3C6EBA}
.tag.purple{background:var(--purple-soft);color:#765AC5}
.tag.warn{background:var(--primary-soft);color:var(--primary-ink)}
.tag.red{background:var(--red-soft);color:var(--red-ink)}
.tag.blue{background:var(--blue-soft);color:var(--blue-ink)}
.tag.purple{background:var(--purple-soft);color:var(--purple-ink)}
/* 状态 chip:比 tag 矮,可带图标,用于「体重稳定」「驱虫将到期」这类瞬时状态 */
.chip{
display:inline-flex;align-items:center;gap:var(--sp-1);height:44rpx;padding:0 var(--sp-3);
border-radius:var(--r-full);font-size:var(--fs-cap);font-weight:var(--fw-b);
background:var(--primary-soft);color:var(--primary-ink);white-space:nowrap;
}
.chip.green{background:var(--green-soft);color:var(--green-ink)}
.chip.red{background:var(--red-soft);color:var(--red-ink)}
.chip.blue{background:var(--blue-soft);color:var(--blue-ink)}
/* 4 色分类块。用在快速记录入口这类并列的功能格上,靠颜色区分类别而不是靠 emoji */
.tone-1{background:var(--primary-soft);color:var(--primary-ink)}
.tone-2{background:var(--green-soft);color:var(--green-ink)}
.tone-3{background:var(--blue-soft);color:var(--blue-ink)}
.tone-4{background:var(--purple-soft);color:var(--purple-ink)}
/* 记录入口宫格。首页(4 列 8 个)和记录页(3 列 9 个)原本是同一个东西的两个副本,
高度/圆角/阴影/字号全不一样,这里合成一套。 */
.quick-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--sp-2)}
.quick-grid.cols-3{grid-template-columns:repeat(3,1fr)}
.quick{
border-radius:var(--r-md);min-height:128rpx;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--sp-1);
font-weight:var(--fw-b);font-size:var(--fs-sm);
}
/* 空态。原本有 6 套长得差不多的写法散在各页 */
.empty{padding:56rpx var(--sp-1);text-align:center;color:var(--muted);font-size:var(--fs-sm)}
.empty.lg{padding:96rpx 0}
/* 表单 */
.field{margin-bottom:28rpx}
.field{margin-bottom:var(--sp-4)}
.field:last-child{margin-bottom:0}
.field label{display:block;font-size:26rpx;color:var(--muted);font-weight:800;margin-bottom:16rpx}
.field label{display:block;font-size:var(--fs-md);color:var(--muted);font-weight:var(--fw-b);margin-bottom:var(--sp-3)}
.input,input,textarea,.picker-box{
width:100%;border:1rpx solid var(--line);background:#FBFAF8;height:88rpx;border-radius:30rpx;
padding:0 26rpx;color:var(--text);font-size:30rpx;outline:none;box-sizing:border-box;
width:100%;border:1rpx solid var(--line);background:var(--surface-2);height:88rpx;border-radius:var(--r-md);
padding:0 var(--sp-4);color:var(--text);font-size:var(--fs-lg);outline:none;box-sizing:border-box;
display:flex;align-items:center;
}
.textarea{
width:100%;border:1rpx solid var(--line);background:#FBFAF8;height:184rpx;border-radius:30rpx;
padding:24rpx 26rpx;color:var(--text);font-size:30rpx;box-sizing:border-box;
width:100%;border:1rpx solid var(--line);background:var(--surface-2);height:184rpx;border-radius:var(--r-md);
padding:var(--sp-4);color:var(--text);font-size:var(--fs-lg);box-sizing:border-box;
}
.placeholder{color:var(--muted2)}
/* 分段选择 */
.seg{display:flex;gap:16rpx}
.seg{display:flex;gap:var(--sp-3)}
.seg .seg-btn{
flex:1;height:80rpx;line-height:80rpx;text-align:center;border-radius:26rpx;
border:1rpx solid var(--line);background:#fff;color:var(--muted);font-weight:800;font-size:28rpx;
flex:1;height:var(--h-md);line-height:var(--h-md);text-align:center;border-radius:var(--r-md);
border:1rpx solid var(--line);background:#fff;color:var(--muted);font-weight:var(--fw-b);font-size:var(--fs-md);
}
.seg .seg-btn.selected{color:var(--primary-dark);background:#FFF2DC;border-color:#FFD39A}
.seg .seg-btn.selected{color:var(--primary-dark);background:var(--primary-soft);border-color:var(--primary-line)}
/* 多选 check */
.check-list{display:flex;flex-direction:column;gap:20rpx;margin:32rpx 0}
.check-list{display:flex;flex-direction:column;gap:var(--sp-3);margin:var(--sp-5) 0}
.check{
display:flex;align-items:center;gap:20rpx;background:#fff;box-shadow:var(--shadow);
border-radius:32rpx;padding:26rpx;border:1rpx solid transparent;font-size:28rpx;font-weight:700;
display:flex;align-items:center;gap:var(--sp-3);background:#fff;box-shadow:var(--sd-1);
border-radius:var(--r-md);padding:var(--sp-4);border:1rpx solid transparent;font-size:var(--fs-md);font-weight:var(--fw-b);
}
.box{
width:44rpx;height:44rpx;border-radius:16rpx;border:4rpx solid #DDCEC1;
width:44rpx;height:44rpx;border-radius:var(--r-xs);border:4rpx solid #DDCEC1;
display:flex;align-items:center;justify-content:center;color:#fff;flex:none;
font-size:26rpx;font-weight:900;
font-size:var(--fs-md);font-weight:var(--fw-b);
}
.check.selected{background:#FFF8EC;border-color:#FFD29A}
.check.selected{background:var(--primary-soft);border-color:var(--primary-line)}
.check.selected .box{background:var(--primary);border-color:var(--primary)}
/* 圆形/条形选项 */
.mini-options{display:flex;flex-wrap:wrap;gap:16rpx;margin:20rpx 0 28rpx}
.mini-options{display:flex;flex-wrap:wrap;gap:var(--sp-3);margin:var(--sp-3) 0 var(--sp-4)}
.option{
border:1rpx solid var(--line);background:#fff;border-radius:999rpx;height:68rpx;line-height:68rpx;
padding:0 24rpx;color:#5F554D;font-weight:800;font-size:24rpx;
border:1rpx solid var(--line);background:#fff;border-radius:var(--r-full);height:var(--h-sm);line-height:var(--h-sm);
padding:0 var(--sp-4);color:var(--text-2);font-weight:var(--fw-b);font-size:var(--fs-sm);
}
.option.selected{background:#FFF2DC;color:var(--primary-dark);border-color:#FFD39A}
.option.selected{background:var(--primary-soft);color:var(--primary-dark);border-color:var(--primary-line)}
/* 进度条 */
.progress-row{
display:flex;align-items:center;justify-content:space-between;margin-bottom:16rpx;
font-size:26rpx;color:#6A5D52;font-weight:900;
display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--sp-3);
font-size:var(--fs-md);color:var(--text-2);font-weight:var(--fw-b);
}
.bar{height:20rpx;border-radius:999rpx;background:#F0E7DD;overflow:hidden}
.bar{height:var(--sp-3);border-radius:var(--r-full);background:#F0E7DD;overflow:hidden}
.bar .bar-fill{
height:100%;border-radius:999rpx;
height:100%;border-radius:var(--r-full);
background:linear-gradient(90deg,var(--primary),#FFD57F);display:block;transition:.25s ease;
}
/* 头像 */
.avatar{
width:120rpx;height:120rpx;border-radius:44rpx;
width:120rpx;height:120rpx;border-radius:var(--r-lg);
background:linear-gradient(135deg,#FFE5B7,#FFC06B);
display:flex;align-items:center;justify-content:center;font-size:64rpx;
box-shadow:0 24rpx 52rpx rgba(245,169,71,.18);flex:none;
box-shadow:var(--sd-1);flex:none;
}
/* 多宠切换 */
.pet-switch{display:flex;gap:20rpx;overflow-x:auto;padding:0 0 24rpx;white-space:nowrap}
.pet-switch{display:flex;gap:var(--sp-3);overflow-x:auto;padding:0 0 var(--sp-4);white-space:nowrap}
.pet-switch::-webkit-scrollbar{display:none}
.pet-chip{
flex:none;display:inline-flex;border:1rpx solid var(--line);background:#fff;border-radius:40rpx;
padding:18rpx 24rpx;align-items:center;gap:16rpx;color:var(--muted);font-weight:900;font-size:28rpx;
box-shadow:0 12rpx 36rpx rgba(70,48,25,.05);
}
.pet-chip.active{color:var(--primary-dark);background:#FFF4E4;border-color:#FFD6A1}
.pet-chip .mini-avatar{
width:56rpx;height:56rpx;border-radius:20rpx;display:flex;align-items:center;
justify-content:center;background:#FFE4B7;font-size:30rpx;
flex:none;display:inline-flex;border:1rpx solid var(--line);background:#fff;border-radius:var(--r-full);
padding:var(--sp-2) var(--sp-4);align-items:center;gap:var(--sp-2);color:var(--muted);font-weight:var(--fw-b);font-size:var(--fs-md);
}
.pet-chip.active{color:var(--primary-dark);background:var(--primary-soft);border-color:var(--primary-line)}
/* 任务列表 */
.task-list{display:flex;flex-direction:column;gap:20rpx}
.task{display:flex;gap:20rpx;align-items:flex-start;background:#FBFAF8;border-radius:32rpx;padding:24rpx;transition:.15s ease}
.task-list{display:flex;flex-direction:column;gap:var(--sp-2)}
.task{display:flex;gap:var(--sp-3);align-items:flex-start;background:var(--surface-2);border-radius:var(--r-md);padding:var(--sp-4);transition:.15s ease}
.circle{
width:46rpx;height:46rpx;border-radius:50%;border:4rpx solid #DED1C4;display:flex;
align-items:center;justify-content:center;color:#fff;font-size:24rpx;flex:none;margin-top:2rpx;font-weight:900;
width:44rpx;height:44rpx;border-radius:50%;border:4rpx solid #DED1C4;display:flex;
align-items:center;justify-content:center;color:#fff;font-size:var(--fs-sm);flex:none;margin-top:2rpx;
}
.task.done{opacity:.55}
.task.done .circle{background:var(--green);border-color:var(--green)}
.task-text{flex:1}
.task-text b{font-size:28rpx}
.task-text p{font-size:24rpx;color:var(--muted);margin-top:8rpx;line-height:1.45}
.tt-b{font-size:var(--fs-md);font-weight:var(--fw-b)}
.tt-p{font-size:var(--fs-sm);color:var(--muted);margin-top:var(--sp-1);line-height:1.45}
.priority{
font-size:22rpx;color:#A96500;background:#FFF0D7;border-radius:999rpx;padding:8rpx 14rpx;font-weight:900;flex:none;
font-size:var(--fs-cap);color:var(--primary-ink);background:var(--primary-soft);
border-radius:var(--r-full);padding:6rpx var(--sp-2);font-weight:var(--fw-b);flex:none;
}
/* 记录行 */
.record-row{
display:flex;justify-content:space-between;align-items:center;padding:24rpx 0;
border-bottom:1rpx solid var(--line);font-size:26rpx;
display:flex;justify-content:space-between;align-items:center;padding:var(--sp-4) 0;
border-bottom:1rpx solid var(--line);font-size:var(--fs-md);
}
.record-row:last-child{border-bottom:0}
.record-row b{font-size:28rpx}
.record-row span{color:var(--muted);font-size:24rpx}
/* 统计格 */
.stats-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16rpx;margin:28rpx 0}
.stat{background:#fff;border:1rpx solid var(--line);border-radius:32rpx;padding:24rpx 14rpx;text-align:center}
.stat b{display:block;font-size:36rpx;margin-bottom:8rpx}
.stat span{font-size:22rpx;color:var(--muted)}
.stats-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-3);margin:var(--sp-4) 0}
.stat{background:#fff;border:1rpx solid var(--line);border-radius:var(--r-md);padding:var(--sp-4) var(--sp-2);text-align:center}
.stat-b{display:block;font-size:var(--fs-xl);margin-bottom:6rpx;font-weight:var(--fw-b)}
.stat-s{font-size:var(--fs-cap);color:var(--muted)}
/* 文章卡 */
.article-card{
display:flex;gap:24rpx;background:#fff;border-radius:40rpx;padding:26rpx;box-shadow:var(--shadow);margin-bottom:24rpx;
display:flex;gap:var(--sp-4);background:#fff;border-radius:var(--r-lg);padding:var(--sp-4);box-shadow:var(--sd-1);margin-bottom:var(--sp-4);
}
.article-icon{
width:116rpx;height:116rpx;border-radius:36rpx;flex:none;display:flex;align-items:center;
justify-content:center;background:#FFF1D8;font-size:56rpx;
width:104rpx;height:104rpx;border-radius:var(--r-md);flex:none;display:flex;align-items:center;
justify-content:center;background:var(--primary-soft);color:var(--primary-ink);font-size:48rpx;
}
.article-card b{font-size:30rpx}
.article-card p{color:var(--muted);font-size:24rpx;line-height:1.45;margin-top:10rpx}
.ac-b{font-size:var(--fs-lg);font-weight:var(--fw-b)}
.ac-p{color:var(--muted);font-size:var(--fs-sm);line-height:1.45;margin-top:10rpx}
/* 海报 */
.poster{
background:
radial-gradient(circle at 100% 0%, rgba(245,169,71,.24), transparent 35%),
linear-gradient(160deg,#FFF7E8,#FFFFFF);
border:1rpx solid #F2DFC3;border-radius:52rpx;padding:36rpx;text-align:center;overflow:hidden;position:relative;
border:1rpx solid #F2DFC3;border-radius:var(--r-lg);padding:var(--sp-5);text-align:center;overflow:hidden;position:relative;
}
.poster h3{font-size:40rpx;margin-bottom:20rpx;font-weight:800}
.poster-avatar{
width:152rpx;height:152rpx;margin:16rpx auto 20rpx;border-radius:54rpx;
width:152rpx;height:152rpx;margin:var(--sp-3) auto var(--sp-3);border-radius:var(--r-lg);
background:linear-gradient(135deg,#FFE5B7,#FFC06B);display:flex;align-items:center;justify-content:center;
font-size:80rpx;box-shadow:0 24rpx 52rpx rgba(245,169,71,.20);
font-size:80rpx;box-shadow:var(--sd-1);
}
.poster-list{
background:rgba(255,255,255,.78);border-radius:32rpx;padding:24rpx;margin:28rpx 0;text-align:left;line-height:1.8;font-size:26rpx;
background:rgba(255,255,255,.78);border-radius:var(--r-md);padding:var(--sp-4);margin:var(--sp-4) 0;text-align:left;line-height:1.8;font-size:var(--fs-md);
}
/* 风险盒 */
.risk-box{
background:#FFF0D8;color:#A96500;padding:24rpx;border-radius:32rpx;font-weight:900;
display:flex;gap:18rpx;margin-bottom:24rpx;align-items:center;
background:var(--primary-soft);color:var(--primary-ink);padding:var(--sp-4);border-radius:var(--r-md);font-weight:var(--fw-b);
display:flex;gap:var(--sp-3);margin-bottom:var(--sp-4);align-items:center;
}
/* 弹层内动作行 */
.sheet-actions{display:flex;gap:20rpx;margin-top:24rpx}
.sheet-actions{display:flex;gap:var(--sp-3);margin-top:var(--sp-4)}
.sheet-actions .btn{flex:1}
/* 聊天气泡 */
.plan-chat{display:flex;flex-direction:column;gap:20rpx;margin-bottom:24rpx}
.msg{max-width:88%;border-radius:36rpx;padding:22rpx 26rpx;font-size:26rpx;line-height:1.55}
.msg.ai{background:#F3F8F5;align-self:flex-start}
.msg.user{background:#FFF1D8;align-self:flex-end}
.plan-chat{display:flex;flex-direction:column;gap:var(--sp-3);margin-bottom:var(--sp-4)}
.msg{max-width:88%;border-radius:var(--r-md);padding:var(--sp-3) var(--sp-4);font-size:var(--fs-md);line-height:1.55}
.msg.ai{background:var(--green-soft);align-self:flex-start}
.msg.user{background:var(--primary-soft);align-self:flex-end}
/* 没有宠物时的引导块。四个主页面都会 currentPetId 为空直接 return
留下一堆空卡片却不解释为什么——用户以为是坏了。 */
.no-pet{
background:#fff;border-radius:var(--r-lg);box-shadow:var(--sd-1);
padding:var(--sp-6) var(--sp-5);text-align:center;margin-bottom:var(--sp-4);
}
.no-pet-ic{
width:104rpx;height:104rpx;border-radius:var(--r-md);margin:0 auto var(--sp-3);
display:flex;align-items:center;justify-content:center;
background:var(--primary-soft);color:var(--primary-ink);
}
.no-pet-b{font-size:var(--fs-lg);font-weight:var(--fw-b)}
.no-pet-p{color:var(--muted);font-size:var(--fs-sm);margin:var(--sp-2) 0 var(--sp-4);line-height:1.6}
/* 顶部固定条:宠物切换 / 社区分类。
它在文档流里而不是浮层——内容永远不会滚到它下面,所以不需要任何背景。
之前给了不透明底色 + 分隔线,等于在页面渐变上贴了块补丁,
屏幕上就出现三条颜色不搭的横带。 */
.top-bar{flex:none;padding:var(--sp-2) var(--pad-x) 0}
.top-bar .pet-switch{padding-bottom:var(--sp-2)}
.top-bar .st-pill{padding-bottom:var(--sp-2)}
/* 帖子卡。社区和用户主页都要用,页面级 wxss 不跨页生效,所以放全局 */
.post-card{background:#fff;border-radius:var(--r-lg);box-shadow:var(--sd-1);padding:var(--sp-4);margin-bottom:var(--sp-4)}
.post-head{display:flex;align-items:center;gap:var(--sp-3);margin-bottom:var(--sp-3)}
.post-avatar{
width:80rpx;height:80rpx;border-radius:var(--r-sm);background:var(--primary-soft);
display:flex;align-items:center;justify-content:center;font-size:40rpx;flex:none;
}
.post-user{flex:1;min-width:0}
.pu-b{font-size:var(--fs-md);display:block;font-weight:var(--fw-b)}
.ai-tag{
display:inline-block;margin-left:var(--sp-1);padding:0 var(--sp-1);border-radius:var(--r-xs);
background:var(--blue-soft);color:var(--blue-ink);font-size:var(--fs-cap);font-weight:var(--fw-b);vertical-align:1rpx;
}
.pu-s{font-size:var(--fs-sm);color:var(--muted)}
.post-content{font-size:var(--fs-md);line-height:1.62;color:var(--text-2);margin-bottom:var(--sp-3)}
.photo-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-1);margin-bottom:var(--sp-3)}
.photo-tile{
aspect-ratio:1;border-radius:var(--r-sm);background:var(--primary-soft);
display:flex;align-items:center;justify-content:center;font-size:56rpx;
}
/* 三等分。原来是 space-around 排三个宽度不一的项,点赞数一变宽度就跳 */
.post-actions{display:flex;border-top:1rpx solid var(--line);padding-top:var(--sp-2)}
.pa-btn{
flex:1;height:72rpx;
display:flex;align-items:center;justify-content:center;gap:var(--sp-1);
color:var(--muted);font-weight:var(--fw);font-size:var(--fs-sm);
}
.pa-n{line-height:1}
.pa-btn.liked{color:var(--red-ink)}
.pa-btn:active{background:var(--surface-2);border-radius:var(--r-sm)}
/* 分享用微信原生 open-type=share,得抹掉 button 默认外观 */
.pa-share{background:transparent;border:none;padding:0;margin:0;line-height:inherit}
.pa-share::after{border:none}
/* ===== 设置项列表 =====
settings 和 mine 都在用。页面级 wxss 跨不了页,放在任一页里另一页就是裸的 */
.profile-list{background:#fff;border-radius:var(--r-lg);box-shadow:var(--sd-1);overflow:hidden;margin-bottom:var(--sp-4)}
.profile-row{
display:flex;align-items:center;gap:var(--sp-3);min-height:104rpx;padding:0 var(--sp-4);
border-bottom:1rpx solid var(--line);font-size:var(--fs-md);font-weight:var(--fw-b);
}
.profile-row:last-child{border-bottom:0}
.pr-ic{
width:56rpx;height:56rpx;border-radius:var(--r-xs);flex:none;
display:flex;align-items:center;justify-content:center;
background:var(--primary-soft);color:var(--primary-ink);
}
.pr-label{flex:1;min-width:0}
.pr-val{color:var(--muted);font-weight:var(--fw);font-size:var(--fs-sm)}
.pr-arrow{color:var(--muted2);display:flex;align-items:center;flex:none}
/* ===== 关注按钮 =====
社区列表和关注/粉丝列表都在用。同上:两页共用的就不能留在任一页的 wxss 里 */
.follow-btn{
flex:none;font-size:var(--fs-cap);font-weight:var(--fw-b);padding:6rpx var(--sp-3);border-radius:var(--r-full);
color:var(--primary-dark);background:var(--primary-soft);border:1rpx solid var(--primary-line);
}
.follow-btn.on{color:var(--muted);background:#F4F1EC;border-color:var(--line)}
/* ===== 关注按钮 =====
社区列表和关注/粉丝列表都在用。同上:两页共用的就不能留在任一页的 wxss 里 */
.follow-btn{
flex:none;font-size:var(--fs-cap);font-weight:var(--fw-b);padding:6rpx var(--sp-3);border-radius:var(--r-full);
color:var(--primary-dark);background:var(--primary-soft);border:1rpx solid var(--primary-line);
}
.follow-btn.on{color:var(--muted);background:#F4F1EC;border-color:var(--line)}
/* ===== 记录类型分组宫格 =====
记录页和历史页都在用。页面级 wxss 跨不了页,所以放全局。
组的着色走 g-1..g-4,不要复用 tone-N —— tone-N 自带背景色,
套在分组外层会把整块涂成色块。 */
.tg{margin-bottom:var(--sp-5)}
.tg:last-of-type{margin-bottom:0}
.tg-head{
display:flex;align-items:center;gap:var(--sp-2);
font-size:var(--fs-md);font-weight:var(--fw-b);margin-bottom:var(--sp-3);
}
.tg-bar{width:6rpx;height:26rpx;border-radius:3rpx;background:currentColor}
.g-1 .tg-head{color:var(--primary-ink)}
.g-2 .tg-head{color:var(--green-ink)}
.g-3 .tg-head{color:var(--blue-ink)}
.g-4 .tg-head{color:var(--purple-ink)}
/* ===== 关注按钮 =====
社区列表和关注/粉丝列表都在用。同上:两页共用的就不能留在任一页的 wxss 里 */
.follow-btn{
flex:none;font-size:var(--fs-cap);font-weight:var(--fw-b);padding:6rpx var(--sp-3);border-radius:var(--r-full);
color:var(--primary-dark);background:var(--primary-soft);border:1rpx solid var(--primary-line);
}
.follow-btn.on{color:var(--muted);background:#F4F1EC;border-color:var(--line)}
/* ===== 记录类型分组宫格 =====
记录页和「快速记录」弹层都在用,页面级 wxss 跨不了页
四组各一个色系,色值全走 token 的 soft/ink 二件套,没有新色。
.quick 的配色由 .tone-N 决定(app.wxss 里已有),这里只管组标题和留白 */
.tg{margin-bottom:var(--sp-4)}
.tg:last-of-type{margin-bottom:0}
.tg-head{
display:flex;align-items:center;gap:var(--sp-2);
font-size:var(--fs-md);font-weight:var(--fw-b);margin-bottom:var(--sp-3);
}
.tg-bar{width:6rpx;height:26rpx;border-radius:3rpx;background:currentColor}
.tg-n{margin-left:auto;font-size:var(--fs-cap);font-weight:var(--fw);color:var(--muted2)}
.tg.tone-1 .tg-head{color:var(--primary-ink)}
.tg.tone-2 .tg-head{color:var(--green-ink)}
.tg.tone-3 .tg-head{color:var(--blue-ink)}
.tg.tone-4 .tg-head{color:var(--purple-ink)}
/* ===== 日历网格 =====
首页(可展开日历)和计划页(30 天计划日历)都在用 */
.cal-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--sp-3);font-weight:var(--fw-b);font-size:var(--fs-lg)}
.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:var(--sp-2);text-align:center}
.cal-w{font-size:var(--fs-cap);color:var(--muted);padding:var(--sp-1) 0}
.cal-cell{
height:76rpx;border-radius:var(--r-sm);display:flex;align-items:center;justify-content:center;
font-weight:var(--fw-b);font-size:var(--fs-sm);background:var(--surface-2);position:relative;
}
.cal-cell.blank{background:transparent}
.cal-cell.today{background:var(--primary-soft);color:var(--primary-dark)}
.cal-cell.tasked::after{content:"";position:absolute;bottom:8rpx;width:8rpx;height:8rpx;border-radius:50%;background:var(--green)}
.cal-cell.sel{background:var(--primary);color:#fff}
.cal-cell.sel.tasked::after{background:#fff}
+318 -379
View File
@@ -8,29 +8,30 @@ function fmtDate(iso) {
return d.getMonth() + 1 + '月' + d.getDate() + '日';
}
function ageFromBirthday(bday) {
if (!bday) return '';
const b = new Date(bday);
const now = new Date();
let months = (now.getFullYear() - b.getFullYear()) * 12 + (now.getMonth() - b.getMonth());
if (months < 0) months = 0;
return months < 24 ? months + '个月' : Math.floor(months / 12) + '岁';
// n 天后的 YYYY-MM-DD,给提醒类弹层做默认日期
function daysLater(n) {
const d = new Date(Date.now() + n * 86400000);
const p = (x) => (x < 10 ? '0' + x : '' + x);
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
}
const GENDERS = ['男孩', '女孩', '不确定'];
const STAGES = ['刚到家 0-30 天', '幼年期', '成年期', '老年期'];
// "2026-07-30" → "2026-07-30T12:00:00+08:00"
//
// 相对时间。社区里关心的是「多久以前发的」,精确到秒没意义
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 TASK_SHEETS = [
{ key: '', label: '不关联' },
{ key: 'weight', label: '体重' },
{ key: 'poop', label: '便便' },
{ key: 'food', label: '饮食' },
{ key: 'symptom', label: '异常' },
{ key: 'vaccine', label: '疫苗' },
{ key: 'medicine', label: '用药' },
];
const TASK_SHEET_LABELS = TASK_SHEETS.map((s) => s.label);
// 提醒类型(与后端 model.Reminder* 常量对应)
const REMINDER_TYPES = [
@@ -41,9 +42,7 @@ const REMINDER_TYPES = [
];
const REMINDER_LABELS = REMINDER_TYPES.map((t) => t.label);
const POOP = ['正常', '软便', '拉稀'];
const FOOD = ['正常', '偏少', '不吃'];
const COST = ['食品', '医疗', '用品'];
const IDENTITY = ['petName', 'anonymous', 'official'];
const PTAG = ['晒宠', '求助', '经验', '避坑'];
@@ -60,20 +59,9 @@ Component({
pet: {},
segSel: {},
optSel: {},
dateVals: { vaccine: '2026-07-21', deworm: '2026-07-15' },
wInput: '',
wNote: '',
poopNote: '',
foodText: '',
medName: '',
medDose: '',
costNote: '',
costAmount: '',
// 通用简易记录(洗护/清洁那 15 种共用)
postContent: '',
postImages: [],
manageTasks: [],
taskForm: { id: '', title: '', description: '', priority: '', sheetIdx: 0 },
taskSheetLabels: TASK_SHEET_LABELS,
remForm: { id: '', typeIdx: 0, title: '', date: '', freq: '' },
remTypeLabels: REMINDER_LABELS,
myPets: [],
@@ -81,21 +69,20 @@ Component({
fbContent: '',
fbContact: '',
fbDone: false,
addName: '',
addBirthday: '',
addWeight: '',
addColor: '',
addBreed: '',
commentText: '',
aiInput: '',
aiMessages: [],
riskData: null,
comments: [],
reminders: [],
poster: null,
reportDetail: null,
proInfo: null,
saving: false,
posterSaving: false,
commentImages: [],
replyTo: {},
cmFocus: false,
composing: false, // 评论输入面板是否展开(收起时只是一条细条)
kbHeight: 0,
total: 0,
},
observers: {
show: function (show) {
@@ -119,56 +106,22 @@ Component({
if (fresh) {
patch.segSel = {};
patch.optSel = {};
patch.costAmount = '';
patch.postContent = '';
patch.postImages = [];
patch.poopNote = '';
patch.foodText = '';
patch.medName = '';
patch.medDose = '';
patch.costNote = '';
patch.manageTasks = [];
patch.taskForm = { id: '', title: '', description: '', priority: '', sheetIdx: 0 };
patch.remForm = { id: '', typeIdx: 0, title: '', date: '', freq: '' };
patch.exportText = '';
patch.fbContent = '';
patch.fbContact = '';
patch.fbDone = false;
patch.addName = '';
patch.addBirthday = '';
patch.addWeight = '';
patch.addColor = '';
patch.addBreed = '';
patch.commentText = '';
patch.riskData = null;
patch.commentImages = [];
patch.replyTo = {};
patch.cmFocus = false;
patch.composing = false;
patch.kbHeight = 0;
patch.saving = false;
}
if (type === 'weight') {
patch.wInput = (pet.weight || '').replace('kg', '');
patch.wNote = '';
}
if (type === 'editPet') {
// 预填当前宠物档案
patch.addName = pet.name || '';
patch.addColor = pet.color || '';
patch.addBreed = pet.breed || '';
patch.addWeight = (pet.weight || '').replace('kg', '');
patch.addBirthday = pet.birthday ? String(pet.birthday).slice(0, 10) : '';
patch.segSel = {
addType: pet.type === '狗狗' ? 1 : 0,
addGender: Math.max(0, GENDERS.indexOf(pet.gender)),
};
patch.optSel = { addStage: Math.max(0, STAGES.indexOf(pet.stage)) };
}
if (type === 'ai') {
patch.aiMessages = [
{
role: 'ai',
text: `我是 ${pet.name || '毛孩子'} 的养宠助手。我会优先基于档案、计划和历史记录回答,而不是泛泛聊天。`,
},
];
patch.aiInput = '';
}
this.setData(patch);
this.loadSheetData(type);
},
@@ -190,23 +143,11 @@ Component({
api.getPoster(id).then((p) => this.setData({ poster: p })).catch(() => {});
} else if (type === 'reportDetail' && id) {
api.weeklyReport(id).then((r) => this.setData({ reportDetail: r })).catch(() => {});
} else if (type === 'ai') {
this.loadAIHistory();
} else if (type === 'pro') {
api.getPro().then((p) => this.setData({ proInfo: p })).catch(() => {});
}
},
onActivatePro() {
api
.activatePro()
.then((p) => {
this.setData({ proInfo: p });
wx.showToast({ title: '已开通 Pro', icon: 'success' });
setTimeout(() => this.close(), 800);
})
.catch((e) => wx.showToast({ title: e.message || '开通失败', icon: 'none' }));
},
goSheet(e) {
this.setType(e.currentTarget.dataset.type, true);
},
@@ -225,6 +166,174 @@ Component({
page.getTabBar().setData({ hidden });
}
},
// ---- 成长海报:画进 canvas 才能存进相册 ----
onSavePoster() {
if (this.data.posterSaving) return;
this.setData({ posterSaving: true });
this.drawPoster()
.then((tempPath) => this.saveToAlbum(tempPath))
.then(() => wx.showToast({ title: '已保存到相册', icon: 'success' }))
.catch((e) => {
if (e && e.canceled) return;
wx.showToast({ title: (e && e.message) || '保存失败', icon: 'none' });
})
.then(() => this.setData({ posterSaving: false }));
},
// 把海报画到离屏 canvas 上,返回临时图片路径
drawPoster() {
const W = 600;
const H = 840;
return new Promise((resolve, reject) => {
wx.createSelectorQuery()
.in(this)
.select('#posterCanvas')
.fields({ node: true, size: true })
.exec((res) => {
const node = res && res[0] && res[0].node;
if (!node) return reject(new Error('画布还没准备好,稍后再试'));
// 按设备像素比放大,否则在高分屏上导出的图是糊的
let dpr = 2;
try {
dpr = (wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()).pixelRatio || 2;
} catch (err) {
dpr = 2;
}
node.width = W * dpr;
node.height = H * dpr;
const ctx = node.getContext('2d');
ctx.scale(dpr, dpr);
const pet = this.data.pet || {};
const p = this.data.poster || {};
// 头像是远程图,异步加载。加载完(或没有头像)再往下画,
// 否则头像会画不上。加载失败当没有头像处理
const loadAvatar = () =>
new Promise((res) => {
if (!pet.avatar_url) return res(null);
const img = node.createImage();
img.onload = () => res(img);
img.onerror = () => res(null);
img.src = pet.avatar_url;
});
loadAvatar().then((avatar) => {
const bg = ctx.createLinearGradient(0, 0, W, H);
bg.addColorStop(0, '#FFF7E8');
bg.addColorStop(1, '#FFFFFF');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#F2DFC3';
ctx.fillRect(0, 0, W, 8);
ctx.textAlign = 'center';
ctx.fillStyle = '#2D2925';
ctx.font = '600 40px sans-serif';
ctx.fillText((pet.name || '毛孩子') + ' 的成长报告', W / 2, 96);
// 上传过照片就画圆形头像,没有才退回 emoji(默认头像太丑)
const avD = 132, avX = (W - avD) / 2, avY = 132;
if (avatar) {
ctx.save();
ctx.beginPath();
ctx.arc(W / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
ctx.drawImage(avatar, avX, avY, avD, avD);
ctx.restore();
} else {
ctx.font = '96px sans-serif';
ctx.fillText(pet.emoji || '🐾', W / 2, 216);
}
ctx.textAlign = 'center';
ctx.fillStyle = '#8D8277';
ctx.font = '24px sans-serif';
ctx.fillText([pet.age, pet.weight, pet.stage].filter(Boolean).join(' | '), W / 2, 268);
ctx.fillStyle = 'rgba(255,255,255,.8)';
this.roundRect(ctx, 60, 310, W - 120, 232, 24);
ctx.fill();
const lines = [
'完成任务 ' + (p.tasks_completed || 0) + ' 项',
'体重记录 ' + (p.weight_records || 0) + ' 次',
'疫苗记录 ' + (p.vaccine_records || 0) + ' 次',
'高风险异常 ' + (p.high_risk_count || 0) + ' 次',
];
ctx.textAlign = 'left';
ctx.font = '28px sans-serif';
lines.forEach((t, i) => {
const y = 360 + i * 52;
ctx.fillStyle = '#73BE9D';
ctx.fillText('✓', 92, y);
ctx.fillStyle = '#443D37';
ctx.fillText(t, 132, y);
});
ctx.textAlign = 'center';
ctx.fillStyle = '#2D2925';
ctx.font = '600 30px sans-serif';
ctx.fillText('健康状态:' + (p.headline || '稳定成长'), W / 2, 620);
ctx.fillStyle = '#B5A99D';
ctx.font = '22px sans-serif';
ctx.fillText('生成自 · 肉垫计划', W / 2, 780);
// 头像加载完(或没有)才走到这里,此刻画布已完整
wx.canvasToTempFilePath(
{
canvas: node,
fileType: 'png',
success: (r) => resolve(r.tempFilePath),
fail: () => reject(new Error('生成图片失败')),
},
this,
);
}); // loadAvatar().then
});
});
},
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();
},
// 存相册要相册写权限。用户拒过一次之后 wx.authorize 不会再弹,
// 必须引导他去设置页打开,否则这里会永远静默失败。
saveToAlbum(filePath) {
return new Promise((resolve, reject) => {
wx.saveImageToPhotosAlbum({
filePath,
success: resolve,
fail: (err) => {
const msg = (err && err.errMsg) || '';
if (msg.indexOf('cancel') >= 0) return reject({ canceled: true });
if (msg.indexOf('auth deny') >= 0 || msg.indexOf('authorize') >= 0) {
wx.showModal({
title: '需要相册权限',
content: '保存卡片需要允许访问相册,去设置里打开一下?',
confirmText: '去设置',
success: (r) => {
if (r.confirm) wx.openSetting({});
},
});
return reject({ canceled: true });
}
reject(new Error('保存失败'));
},
});
});
},
onSeg(e) {
const { group, index } = e.currentTarget.dataset;
const segSel = Object.assign({}, this.data.segSel, { [group]: Number(index) });
@@ -235,28 +344,34 @@ Component({
const optSel = Object.assign({}, this.data.optSel, { [group]: Number(index) });
this.setData({ optSel });
},
onDate(e) {
this.setData({ [`dateVals.${e.currentTarget.dataset.key}`]: e.detail.value });
},
onWInput(e) { this.setData({ wInput: e.detail.value }); },
onWNote(e) { this.setData({ wNote: e.detail.value }); },
onPoopNote(e) { this.setData({ poopNote: e.detail.value }); },
onFoodText(e) { this.setData({ foodText: e.detail.value }); },
onMedName(e) { this.setData({ medName: e.detail.value }); },
onMedDose(e) { this.setData({ medDose: e.detail.value }); },
onCostNote(e) { this.setData({ costNote: e.detail.value }); },
onCostInput(e) { this.setData({ costAmount: e.detail.value }); },
onPostInput(e) { this.setData({ postContent: e.detail.value }); },
loadComments() {
if (!this.data.postId) return;
api.listComments(this.data.postId)
.then((page) => this.setData({ comments: page.list || [] }))
.then((page) =>
this.setData({
total: page.total || 0,
comments: (page.list || []).map((c) => ({
...c,
timeText: fmtAgo(c.created_at),
// 没有头像字段,用昵称首字做个色块,比一律显示同一个 emoji 强
initial: (c.author_name || '?').slice(0, 1),
imgs: Array.isArray(c.images) ? c.images : [],
replies: (c.replies || []).map((r) => ({
...r,
timeText: fmtAgo(r.created_at),
initial: (r.author_name || '?').slice(0, 1),
})),
})),
}),
)
.catch(() => {});
},
// 长按删除自己的评论
onDeleteComment(e) {
const c = this.data.comments[e.currentTarget.dataset.index];
if (!c || !c.is_self) return;
const { id, self } = e.currentTarget.dataset;
if (!id || !self) return;
const c = { id };
wx.showModal({
title: '删除评论',
content: '确定删除这条评论?',
@@ -287,9 +402,13 @@ Component({
wx.showToast({ title: '已切换', icon: 'success' });
setTimeout(() => this.close(), 500);
},
// 多宠管理里点某只去编辑。editPet 那支已经搬到 pages/petform
// 这里必须关掉弹层再跳页 —— 留着 setType('editPet') 会开一个空白弹层
onEditPetFromList(e) {
store.switchPet(e.currentTarget.dataset.id);
this.setType('editPet', true);
const id = e.currentTarget.dataset.id;
store.switchPet(id);
this.close();
wx.navigateTo({ url: '/pages/petform/petform?id=' + id });
},
// ---- 导出健康档案 ----
@@ -419,47 +538,6 @@ Component({
if (!id) return;
api.getTasks(id).then((tasks) => this.setData({ manageTasks: tasks || [] })).catch(() => {});
},
onTaskTitle(e) { this.setData({ 'taskForm.title': e.detail.value }); },
onTaskDesc(e) { this.setData({ 'taskForm.description': e.detail.value }); },
onTaskPriority() {
this.setData({ 'taskForm.priority': this.data.taskForm.priority === '重要' ? '' : '重要' });
},
onTaskSheet(e) { this.setData({ 'taskForm.sheetIdx': Number(e.detail.value) }); },
onEditTaskItem(e) {
const t = this.data.manageTasks[e.currentTarget.dataset.index];
let sheetIdx = TASK_SHEETS.findIndex((s) => s.key === (t.sheet_type || ''));
if (sheetIdx < 0) sheetIdx = 0;
this.setData({
taskForm: { id: t.id, title: t.title, description: t.description || '', priority: t.priority || '', sheetIdx },
});
},
onSaveTask() {
const f = this.data.taskForm;
const title = (f.title || '').trim();
if (!title) return wx.showToast({ title: '填个任务名', icon: 'none' });
const body = { title, description: f.description, priority: f.priority, sheet_type: TASK_SHEETS[f.sheetIdx].key };
const id = store.currentPetId();
const p = f.id ? api.updateTask(f.id, body) : api.createTask(id, body);
p.then(() => {
this.setData({ taskForm: { id: '', title: '', description: '', priority: '', sheetIdx: 0 } });
this.loadManageTasks();
this.triggerEvent('saved');
}).catch((e) => wx.showToast({ title: e.message || '保存失败', icon: 'none' }));
},
onDeleteTaskItem(e) {
const t = this.data.manageTasks[e.currentTarget.dataset.index];
wx.showModal({
title: '删除任务',
content: '确定删除「' + t.title + '」?',
success: (r) => {
if (!r.confirm) return;
api.deleteTask(t.id).then(() => {
this.loadManageTasks();
this.triggerEvent('saved');
}).catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
},
});
},
onPickPostImages() {
const left = 9 - this.data.postImages.length;
if (left <= 0) return wx.showToast({ title: '最多 9 张', icon: 'none' });
@@ -477,11 +555,6 @@ Component({
arr.splice(i, 1);
this.setData({ postImages: arr });
},
onAddName(e) { this.setData({ addName: e.detail.value }); },
onAddBirthday(e) { this.setData({ addBirthday: e.detail.value }); },
onAddWeight(e) { this.setData({ addWeight: e.detail.value }); },
onAddColor(e) { this.setData({ addColor: e.detail.value }); },
onAddBreed(e) { this.setData({ addBreed: e.detail.value }); },
onCommentInput(e) { this.setData({ commentText: e.detail.value }); },
segIdx(group) {
@@ -492,97 +565,26 @@ Component({
const v = this.data.optSel[group];
return v === undefined ? 0 : v;
},
// 异常观察 → 生成风险评估(真调后端 AI,失败回退)
onGenRisk() {
const id = store.currentPetId();
const symptoms = ['呕吐', '拉稀', '精神差', '皮肤红'];
const durations = ['刚刚', '半天', '1天以上'];
const spirits = ['正常', '一般', '明显变差'];
const body = {
symptoms: [symptoms[this.optIdx('symp')]],
duration: durations[this.segIdx('dur')],
spirit: spirits[this.segIdx('spirit')],
};
if (!id) {
this.setData({ innerType: 'risk', riskData: null });
return;
}
wx.showLoading({ title: '分析中...' });
api
.assessSymptom(id, body)
.then((res) => {
wx.hideLoading();
this.setData({ innerType: 'risk', riskData: res });
})
.catch(() => {
wx.hideLoading();
this.setData({ innerType: 'risk', riskData: null });
});
},
// 依据当前弹层类型组装一条健康记录
// 记录表单整体搬去 pages/addrecord 之后,这里只剩「生成就医前摘要」
// 会往 health_records 写一条 note —— 它是个动作留痕,不是用户填的表单
buildRecord() {
const t = this.data.innerType;
switch (t) {
case 'poop': {
const s = POOP[this.segIdx('poopState')];
return {
type: 'poop', icon: '💩', title: '排便记录:' + s, category: s,
description: (this.data.poopNote || '').trim(),
};
}
case 'food': {
const a = FOOD[this.segIdx('food')];
const what = (this.data.foodText || '').trim();
return {
type: 'food', icon: '🍽️',
title: what ? '饮食记录:' + what : '饮食记录:食欲' + a,
category: a,
description: what ? '食欲' + a : '',
};
}
case 'medicine': {
const name = (this.data.medName || '').trim();
const dose = (this.data.medDose || '').trim();
return {
type: 'medicine', icon: '💊',
title: name ? '用药:' + name : '用药记录',
description: dose,
};
}
case 'cost': {
const c = COST[this.segIdx('cost')];
const amt = parseFloat(this.data.costAmount) || 0;
return {
type: 'cost', icon: '💰', title: '消费记录:¥' + amt, num_value: amt, category: c,
description: (this.data.costNote || '').trim(),
};
}
case 'vaccine':
return { type: 'vaccine', icon: '💉', title: '疫苗提醒:' + this.data.dateVals.vaccine };
case 'deworm':
return { type: 'deworm', icon: '🛡️', title: '驱虫提醒:' + this.data.dateVals.deworm };
case 'risk': {
// 用 AI 返回的真实风险等级,而不是写死「中」
const r = this.data.riskData || {};
const lv = r.risk_level || '中';
const desc = [r.causes, r.suggestion].filter(Boolean).join('');
return { type: 'symptom', icon: '🤒', title: '异常观察:风险' + lv, category: lv, description: desc };
}
case 'vetSummary':
return { type: 'note', icon: '📄', title: '生成就医前摘要' };
default:
return null;
if (this.data.innerType === 'vetSummary') {
return { type: 'note', icon: '📄', title: '生成就医前摘要' };
}
return null;
},
async createAndClose(rec) {
const id = store.currentPetId();
if (!rec || !id) {
if (!rec) {
this.close();
return;
}
// 没建档时点「保存」原来是静默关掉弹层,用户以为存上了,其实什么都没发生
if (!id) {
wx.showToast({ title: '先建一份宠物档案再记录', icon: 'none' });
return;
}
if (this.data.saving) return;
this.setData({ saving: true });
try {
@@ -599,116 +601,6 @@ Component({
onSave() {
this.createAndClose(this.buildRecord());
},
onSaveWeight() {
let w = (this.data.wInput || '').toString().replace(/\s+/g, '');
if (!w) w = (this.data.pet.weight || '').replace('kg', '');
const num = parseFloat(w) || 0;
const weight = w.includes('kg') ? w : w + 'kg';
this.createAndClose({ type: 'weight', icon: '⚖️', title: '体重 ' + weight, num_value: num, description: (this.data.wNote || '').trim() });
},
onSavePhoto() {
const id = store.currentPetId();
if (!id) return this.close();
upload
.chooseAndUploadImage()
.then((f) =>
api.createRecord(id, { type: 'photo', icon: '📷', title: '成长照片', image_file_id: f.id, image_url: f.url }),
)
.then((saved) => {
wx.showToast({ title: '照片已保存', icon: 'success' });
this.triggerEvent('saved', saved);
this.close();
})
.catch((e) => {
if (e && e.canceled) return;
wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' });
});
},
// 组装宠物档案表单
petFormBody() {
const isDog = this.segIdx('addType') === 1;
return {
name: (this.data.addName || '').trim(),
type: isDog ? '狗狗' : '猫猫',
emoji: isDog ? '🐶' : '🐱',
gender: GENDERS[this.segIdx('addGender')],
birthday: this.data.addBirthday,
weight: this.data.addWeight,
color: (this.data.addColor || '').trim(),
breed: (this.data.addBreed || '').trim(),
stage: STAGES[this.optIdx('addStage')],
age: ageFromBirthday(this.data.addBirthday),
};
},
// 添加/编辑 共用提交入口
onPetSubmit() {
if (this.data.innerType === 'editPet') return this.onEditPetSubmit();
return this.onAddPetSubmit();
},
async onAddPetSubmit() {
const body = this.petFormBody();
if (!body.name) return wx.showToast({ title: '请输入名字', icon: 'none' });
if (this.data.saving) return;
this.setData({ saving: true });
try {
await api.createPet(body);
await store.loadPets();
this.triggerEvent('petadded');
this.close();
} catch (e) {
wx.showToast({ title: e.message || '添加失败', icon: 'none' });
this.setData({ saving: false });
}
},
async onEditPetSubmit() {
const body = this.petFormBody();
if (!body.name) return wx.showToast({ title: '请输入名字', icon: 'none' });
const id = store.currentPetId();
if (!id) return this.close();
if (this.data.saving) return;
this.setData({ saving: true });
try {
await api.updatePet(id, body);
await store.loadPets();
this.triggerEvent('petadded');
this.close();
} catch (e) {
wx.showToast({ title: e.message || '保存失败', icon: 'none' });
this.setData({ saving: false });
}
},
onDeletePet() {
const id = store.currentPetId();
const name = this.data.pet.name || '这只宠物';
if (!id) return;
wx.showModal({
title: '删除宠物',
content: `删除「${name}」后,它的记录、计划、提醒都会一并移除,且不可恢复。确定删除吗?`,
confirmText: '删除',
confirmColor: '#EE7D73',
success: (res) => {
if (!res.confirm) return;
api
.deletePet(id)
.then(async () => {
await store.loadPets();
this.triggerEvent('petadded');
this.close();
if (store.getPets().length === 0) {
wx.reLaunch({ url: '/pages/onboarding/onboarding' });
}
})
.catch((e) => wx.showToast({ title: e.message || '删除失败', icon: 'none' }));
},
});
},
// 发帖
async onCreatePost() {
const content = (this.data.postContent || '').trim();
if (!content) return wx.showToast({ title: '写点什么吧', icon: 'none' });
@@ -735,45 +627,92 @@ Component({
// 评论
async onSendComment() {
const text = (this.data.commentText || '').trim();
if (!text || !this.data.postId) return this.close();
// 原来空评论会直接把弹层关掉,看起来像发成功了。改成明确提示
if (!text) return wx.showToast({ title: '写点什么再发', icon: 'none' });
if (!this.data.postId) return this.close();
try {
await api.createComment(this.data.postId, text);
await api.createComment(
this.data.postId,
text,
this.data.commentImages.map((i) => i.url),
this.data.replyTo.id || '',
);
this.setData({ commentText: '', commentImages: [], replyTo: {}, composing: false, cmFocus: false });
this.loadComments();
this.triggerEvent('commented');
} catch (e) {
wx.showToast({ title: e.message || '评论失败', icon: 'none' });
}
this.close();
},
// AI 聊天
onAiInput(e) { this.setData({ aiInput: e.detail.value }); },
// 拉取历史对话,接在开场白之后;没有历史就保持开场白
loadAIHistory() {
api
.aiMessages('sheet', 30)
.then((list) => {
if (!list || !list.length) return;
const history = list.map((m) => ({ role: m.role === 'user' ? 'user' : 'ai', text: m.text }));
this.setData({ aiMessages: this.data.aiMessages.concat(history) });
})
.catch(() => {});
},
sendAI(e) {
const preset = e.currentTarget.dataset.text;
const text = (preset || this.data.aiInput || '').trim();
if (!text) return;
const msgs = this.data.aiMessages.concat([{ role: 'user', text }]);
this.setData({ aiMessages: msgs, aiInput: '' });
api
.aiChat({ pet_id: store.currentPetId() || null, session: 'sheet', text })
.then((res) => {
this.setData({ aiMessages: this.data.aiMessages.concat([{ role: 'ai', text: res.reply }]) });
})
.catch(() => {
this.setData({
aiMessages: this.data.aiMessages.concat([{ role: 'ai', text: '网络异常,请稍后再试。' }]),
});
// 评论配图,最多 3 张
onPickCommentImages() {
const left = 3 - this.data.commentImages.length;
if (left <= 0) return wx.showToast({ title: '最多 3 张', icon: 'none' });
upload
.chooseAndUploadImages(left)
.then((files) => this.setData({ commentImages: this.data.commentImages.concat(files) }))
.catch((e) => {
if (e && e.canceled) return;
wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' });
});
},
// 回复某条评论:输入区顶部显示「回复 xxx」,发送时带上 parent_id
// 小红书那套:点任意一条评论/回复,直接把输入框激活并填上「回复 @xxx」,
// 不用再去底部找输入框、也不用单独点一个「回复」按钮
onReplyTo(e) {
const { id, name } = e.currentTarget.dataset;
if (!id) return;
// 点任意评论直接展开面板并预填回复对象
this.setData({ replyTo: { id, name }, composing: true, cmFocus: true });
},
// 点细条:展开大输入面板并聚焦(拉起键盘)
onStartCompose() {
this.setData({ composing: true, cmFocus: true });
},
// 收起面板,草稿(文字/图片)留着,下次点开接着写
onCloseCompose() {
this.setData({ composing: false, cmFocus: false, kbHeight: 0 });
},
onCommentFocus(e) {
// adjust-position=false,键盘高度自己接管:弹层是 fixed 定位,
// 交给系统顶会把整块推出屏幕
this.setData({ kbHeight: (e.detail && e.detail.height) || 0 });
},
onCommentBlur() {
// 失焦先放下键盘。有草稿就保持展开(否则点「发送」会因面板收起而丢掉这一下点击);
// 空内容才收回细条
const empty = !(this.data.commentText || '').trim() && !this.data.commentImages.length;
this.setData({ kbHeight: 0, cmFocus: false, composing: empty ? false : this.data.composing });
},
onCancelReply() {
this.setData({ replyTo: {} });
},
// 一级评论默认只带 3 条回复,点开拉全量
onExpandReplies(e) {
const { id, index } = e.currentTarget.dataset;
api
.listReplies(id)
.then((list) =>
this.setData({
[`comments[${index}].replies`]: (list || []).map((r) => ({
...r,
timeText: fmtAgo(r.created_at),
initial: (r.author_name || '?').slice(0, 1),
})),
}),
)
.catch((err) => wx.showToast({ title: (err && err.message) || '加载失败', icon: 'none' }));
},
onPreviewCommentImage(e) {
const { urls, cur } = e.currentTarget.dataset;
if (urls && urls.length) wx.previewImage({ urls, current: cur });
},
onRemoveCommentImage(e) {
const list = this.data.commentImages.slice();
list.splice(e.currentTarget.dataset.index, 1);
this.setData({ commentImages: list });
},
},
});
@@ -1,4 +1,6 @@
{
"component": true,
"usingComponents": {}
"usingComponents": {
"pt-icon": "/components/pt-icon/index"
}
}
+108 -223
View File
@@ -7,150 +7,116 @@ module.exports.sel = function (map, key, index, def) {
</wxs>
<view class="overlay {{show ? 'show' : ''}}" bindtap="onMaskTap">
<view class="sheet" catchtap="noop">
<view class="sheet {{innerType === 'comments' ? 'sheet-cmp' : ''}}" catchtap="noop">
<view class="sheetbar"></view>
<scroll-view class="sheet-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<!-- 记录体重 -->
<block wx:if="{{innerType === 'weight'}}">
<view class="sheet-h3">记录体重</view>
<view class="sheet-p">趋势比单次数值更重要。幼年期建议每周记录 1-2 次。</view>
<view class="field"><label>{{pet.name}} 当前体重</label>
<input class="input" value="{{wInput}}" bindinput="onWInput" type="digit"/></view>
<view class="field"><label>备注</label>
<textarea class="textarea" placeholder="例如:最近食欲不错,活动量正常" placeholder-class="placeholder" value="{{wNote}}" bindinput="onWNote"></textarea></view>
<button class="btn btn-primary btn-block" bindtap="onSaveWeight">保存记录</button>
</block>
<!-- 评论:头 + 滚动列表 + 常驻输入栏。输入栏不能放进滚动区,
否则列表一滚它就跟着走,点评论回复时还得再找回来 -->
<block wx:if="{{innerType === 'comments'}}">
<view class="cmp-head">共 {{total}} 条评论</view>
<!-- 记录便便 -->
<block wx:elif="{{innerType === 'poop'}}">
<view class="sheet-h3">记录便便</view>
<view class="sheet-p">选择状态并补充备注,几秒完成。</view>
<view class="field"><label>状态</label><view class="seg">
<view class="seg-btn {{u.sel(segSel,'poopState',0,0)?'selected':''}}" data-group="poopState" data-index="0" bindtap="onSeg">正常</view>
<view class="seg-btn {{u.sel(segSel,'poopState',1,0)?'selected':''}}" data-group="poopState" data-index="1" bindtap="onSeg">软便</view>
<view class="seg-btn {{u.sel(segSel,'poopState',2,0)?'selected':''}}" data-group="poopState" data-index="2" bindtap="onSeg">拉稀</view>
</view></view>
<view class="field"><label>颜色/备注</label>
<textarea class="textarea" placeholder="颜色、次数、是否带血、是否有异味等" placeholder-class="placeholder" value="{{poopNote}}" bindinput="onPoopNote"></textarea></view>
<button class="btn btn-primary btn-block" data-text="排便记录:正常" data-icon="💩" bindtap="onSave">保存记录</button>
</block>
<scroll-view class="cmp-list" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view wx:for="{{comments}}" wx:key="id" class="cm"
bindtap="onReplyTo" data-id="{{item.id}}" data-name="{{item.author_name}}">
<view class="cm-av">{{item.initial}}</view>
<view class="cm-body">
<view class="cm-name">{{item.author_name}}<text wx:if="{{item.is_self}}" class="mine-tag">我</text></view>
<view class="cm-text">{{item.content}}</view>
<view wx:if="{{item.imgs.length}}" class="cm-imgs">
<image wx:for="{{item.imgs}}" wx:for-item="u" wx:key="*this" class="cm-img"
src="{{u}}" mode="aspectFill" catchtap="onPreviewCommentImage"
data-urls="{{item.imgs}}" data-cur="{{u}}"></image>
</view>
<view class="cm-meta">
<text>{{item.timeText}}</text>
<text class="cm-reply-btn">回复</text>
<text wx:if="{{item.is_self}}" class="cm-del" catchtap="onDeleteComment"
data-id="{{item.id}}" data-self="{{true}}">删除</text>
</view>
<!-- 记录饮食 -->
<block wx:elif="{{innerType === 'food'}}">
<view class="sheet-h3">记录饮食</view>
<view class="field"><label>今天吃了什么?</label>
<input class="input" placeholder="例如:幼猫粮 45g" placeholder-class="placeholder" value="{{foodText}}" bindinput="onFoodText"/></view>
<view class="field"><label>食欲</label><view class="seg">
<view class="seg-btn {{u.sel(segSel,'food',0,0)?'selected':''}}" data-group="food" data-index="0" bindtap="onSeg">正常</view>
<view class="seg-btn {{u.sel(segSel,'food',1,0)?'selected':''}}" data-group="food" data-index="1" bindtap="onSeg">偏少</view>
<view class="seg-btn {{u.sel(segSel,'food',2,0)?'selected':''}}" data-group="food" data-index="2" bindtap="onSeg">不吃</view>
</view></view>
<button class="btn btn-primary btn-block" data-text="饮食记录:食欲正常" data-icon="🍽️" bindtap="onSave">保存记录</button>
</block>
<!-- 回复只缩进一层,再深手机上没法读 -->
<view wx:if="{{item.replies.length}}" class="cm-replies">
<view wx:for="{{item.replies}}" wx:for-item="r" wx:key="id" class="cm-reply"
catchtap="onReplyTo" data-id="{{r.id}}" data-name="{{r.author_name}}">
<view class="cm-av cm-av-sm">{{r.initial}}</view>
<view class="cm-body">
<view class="cm-name">
{{r.author_name}}<text wx:if="{{r.is_self}}" class="mine-tag">我</text>
<text wx:if="{{r.reply_to_name}}" class="cm-at"> 回复 {{r.reply_to_name}}</text>
</view>
<view class="cm-text">{{r.content}}</view>
<view class="cm-meta">
<text>{{r.timeText}}</text>
<text class="cm-reply-btn">回复</text>
<text wx:if="{{r.is_self}}" class="cm-del" catchtap="onDeleteComment"
data-id="{{r.id}}" data-self="{{true}}">删除</text>
</view>
</view>
</view>
<view wx:if="{{item.reply_n > item.replies.length}}" class="cm-more"
catchtap="onExpandReplies" data-id="{{item.id}}" data-index="{{index}}">
— 展开全部 {{item.reply_n}} 条回复
</view>
</view>
</view>
</view>
<!-- 异常观察 -->
<block wx:elif="{{innerType === 'symptom'}}">
<view class="sheet-h3">异常观察</view>
<view class="sheet-p">如实描述异常,AI 会给出观察建议与就医提示(非诊断)。</view>
<view class="field"><label>发生了什么?</label><view class="mini-options">
<view class="option {{u.sel(optSel,'symp',0,0)?'selected':''}}" data-group="symp" data-index="0" bindtap="onOpt">呕吐</view>
<view class="option {{u.sel(optSel,'symp',1,0)?'selected':''}}" data-group="symp" data-index="1" bindtap="onOpt">拉稀</view>
<view class="option {{u.sel(optSel,'symp',2,0)?'selected':''}}" data-group="symp" data-index="2" bindtap="onOpt">精神差</view>
<view class="option {{u.sel(optSel,'symp',3,0)?'selected':''}}" data-group="symp" data-index="3" bindtap="onOpt">皮肤红</view>
</view></view>
<view class="field"><label>持续多久?</label><view class="seg">
<view class="seg-btn {{u.sel(segSel,'dur',0,0)?'selected':''}}" data-group="dur" data-index="0" bindtap="onSeg">刚刚</view>
<view class="seg-btn {{u.sel(segSel,'dur',1,0)?'selected':''}}" data-group="dur" data-index="1" bindtap="onSeg">半天</view>
<view class="seg-btn {{u.sel(segSel,'dur',2,0)?'selected':''}}" data-group="dur" data-index="2" bindtap="onSeg">1天以上</view>
</view></view>
<view class="field"><label>精神状态</label><view class="seg">
<view class="seg-btn {{u.sel(segSel,'spirit',0,0)?'selected':''}}" data-group="spirit" data-index="0" bindtap="onSeg">正常</view>
<view class="seg-btn {{u.sel(segSel,'spirit',1,0)?'selected':''}}" data-group="spirit" data-index="1" bindtap="onSeg">一般</view>
<view class="seg-btn {{u.sel(segSel,'spirit',2,0)?'selected':''}}" data-group="spirit" data-index="2" bindtap="onSeg">明显变差</view>
</view></view>
<button class="btn btn-primary btn-block" bindtap="onGenRisk">生成观察建议</button>
</block>
<view wx:if="{{!comments.length}}" class="cmp-empty">
<view class="cmp-empty-ic"><pt-icon name="comment" size="{{72}}"></pt-icon></view>
<view class="cmp-empty-t">还没有评论</view>
<view class="cmp-empty-p">来抢个沙发,说点什么吧~</view>
</view>
</scroll-view>
<!-- AI 观察建议 -->
<block wx:elif="{{innerType === 'risk'}}">
<view class="sheet-h3">AI 观察建议</view>
<view class="risk-box">⚠️ 风险等级:{{riskData ? riskData.risk_level : '中'}}</view>
<view class="sheet-p"><text class="bold">可能原因:</text>{{riskData ? riskData.causes : '可能与换粮、应激或消化不适有关。不要把这里写成诊断结论。'}}</view>
<view class="sheet-p"><text class="bold">建议:</text>{{riskData ? riskData.suggestion : '继续观察精神、食欲和排便;暂停新食物;记录呕吐或腹泻次数。'}}</view>
<view class="sheet-p"><text class="bold">建议就医:</text>{{riskData ? riskData.seek_care : '如果持续超过 24 小时,或伴随便血、精神明显变差、频繁呕吐,建议尽快就医。'}}</view>
<view class="sheet-actions">
<button class="btn btn-ghost" data-text="异常观察:风险中" data-icon="🤒" bindtap="onSave">保存记录</button>
<button class="btn btn-primary" data-type="vetSummary" bindtap="goSheet">就医摘要</button>
<!-- 收起态:一条细条,点了才展开成大写评论面板(小红书那套) -->
<view wx:if="{{!composing}}" class="cm-collapsed" catchtap="onStartCompose">
<view class="cm-collapsed-pill {{commentText ? 'has' : ''}}">{{commentText || '说点什么…'}}</view>
<view class="cm-collapsed-ic"><pt-icon name="photo" size="{{40}}"></pt-icon></view>
</view>
<!-- 展开态:大输入面板,浮在键盘上方 -->
<view wx:else class="cmp-dock" style="padding-bottom:{{kbHeight ? kbHeight + 'px' : 'calc(20rpx + env(safe-area-inset-bottom))'}}">
<view class="cm-dock-head">
<text class="cm-dock-title">{{replyTo.name ? '回复 ' + replyTo.name : '写评论'}}</text>
<view class="cm-dock-close" catchtap="onCloseCompose"><pt-icon name="close" size="{{30}}"></pt-icon></view>
</view>
<textarea class="cm-ta" placeholder="{{replyTo.name ? '回复 @' + replyTo.name + '…' : '有爱评论,说点好听的~'}}"
placeholder-class="placeholder" value="{{commentText}}" bindinput="onCommentInput"
focus="{{cmFocus}}" bindfocus="onCommentFocus" bindblur="onCommentBlur"
maxlength="500" auto-height="{{true}}" show-confirm-bar="{{false}}"
adjust-position="{{false}}" cursor-spacing="12"></textarea>
<view wx:if="{{commentImages.length}}" class="img-picker" style="margin-top:12rpx">
<view wx:for="{{commentImages}}" wx:key="id" class="img-thumb">
<image src="{{item.url}}" mode="aspectFill"></image>
<view class="img-del" catchtap="onRemoveCommentImage" data-index="{{index}}"><pt-icon name="close" size="{{24}}"></pt-icon></view>
</view>
</view>
<view class="cm-toolbar">
<view class="cm-pic" catchtap="onPickCommentImages"><pt-icon name="photo" size="{{44}}"></pt-icon></view>
<text class="cm-count">{{commentText.length}}/500</text>
<view class="cm-send {{commentText ? 'on' : ''}}" catchtap="onSendComment">发送</view>
</view>
</view>
</block>
<!-- 就医前摘要 -->
<block wx:elif="{{innerType === 'vetSummary'}}">
<scroll-view wx:if="{{innerType !== 'comments'}}" class="sheet-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<!-- 那 9 种记录表单(体重/排便/饮食/异常/给药/记账/疫苗/驱虫/照片)已经整体
搬到 pages/addrecord —— 字段由后端 record_types.fields 驱动,一个页面吃掉
全部 24 种,不再每种一个 wx:elif。弹层现在只管「不是记一笔」的那些事:
提醒、海报、档案、发帖、评论…… -->
<!-- 就医前摘要。这是链上第一支,必须是 wx:if -->
<block wx:if="{{innerType === 'vetSummary'}}">
<view class="sheet-h3">就医前摘要</view>
<view class="sheet-p pre">宠物:{{pet.name}}
阶段:{{pet.stage}}
当前体重:{{pet.weight}}
近期重点:疫苗期、换粮观察、排便记录
建议携带:排便/呕吐照片、饮食变化、疫苗驱虫记录。</view>
<button class="btn btn-primary btn-block" data-text="生成就医前摘要" data-icon="📄" bindtap="onSave">保存摘要</button>
<button class="btn btn-primary btn-block" bindtap="onSave">保存摘要</button>
</block>
<!-- 记录用药 -->
<block wx:elif="{{innerType === 'medicine'}}">
<view class="sheet-h3">记录用药</view>
<view class="field"><label>药品名称</label><input class="input" placeholder="例如:体内驱虫药" placeholder-class="placeholder" value="{{medName}}" bindinput="onMedName"/></view>
<view class="field"><label>剂量/备注</label><input class="input" placeholder="例如:按 2.8kg 剂量" placeholder-class="placeholder" value="{{medDose}}" bindinput="onMedDose"/></view>
<button class="btn btn-primary btn-block" data-text="用药记录" data-icon="💊" bindtap="onSave">保存记录</button>
</block>
<!-- 记一笔消费 -->
<block wx:elif="{{innerType === 'cost'}}">
<view class="sheet-h3">记一笔消费</view>
<view class="field"><label>金额</label><input class="input" placeholder="例如:168" placeholder-class="placeholder" type="digit" value="{{costAmount}}" bindinput="onCostInput"/></view>
<view class="field"><label>类别</label><view class="seg">
<view class="seg-btn {{u.sel(segSel,'cost',0,0)?'selected':''}}" data-group="cost" data-index="0" bindtap="onSeg">食品</view>
<view class="seg-btn {{u.sel(segSel,'cost',1,0)?'selected':''}}" data-group="cost" data-index="1" bindtap="onSeg">医疗</view>
<view class="seg-btn {{u.sel(segSel,'cost',2,0)?'selected':''}}" data-group="cost" data-index="2" bindtap="onSeg">用品</view>
</view></view>
<view class="field"><label>备注</label><input class="input" placeholder="例如:幼猫粮" placeholder-class="placeholder" value="{{costNote}}" bindinput="onCostNote"/></view>
<button class="btn btn-primary btn-block" data-text="消费记录:¥168" data-icon="💰" bindtap="onSave">保存记录</button>
</block>
<!-- 疫苗提醒 -->
<block wx:elif="{{innerType === 'vaccine'}}">
<view class="sheet-h3">疫苗提醒</view>
<view class="sheet-p">为 {{pet.name}} 设置疫苗提醒。接种前后避免洗澡和长途出行。</view>
<view class="field"><label>提醒日期</label>
<picker mode="date" value="{{dateVals.vaccine}}" data-key="vaccine" bindchange="onDate">
<view class="picker-box">{{dateVals.vaccine}}</view>
</picker></view>
<button class="btn btn-primary btn-block" data-text="疫苗提醒:7月21日" data-icon="💉" bindtap="onSave">保存提醒</button>
</block>
<!-- 驱虫提醒 -->
<block wx:elif="{{innerType === 'deworm'}}">
<view class="sheet-h3">驱虫提醒</view>
<view class="sheet-p">设置下次驱虫日期,到点提醒你。</view>
<view class="field"><label>下次驱虫日期</label>
<picker mode="date" value="{{dateVals.deworm}}" data-key="deworm" bindchange="onDate">
<view class="picker-box">{{dateVals.deworm}}</view>
</picker></view>
<button class="btn btn-primary btn-block" data-text="驱虫提醒:7月15日" data-icon="🛡️" bindtap="onSave">保存提醒</button>
</block>
<!-- 成长照片 -->
<block wx:elif="{{innerType === 'photo'}}">
<view class="sheet-h3">成长照片</view>
<view class="sheet-p">上传成长照片,用于宠物档案与成长报告。</view>
<view class="poster" style="margin-bottom:28rpx">
<view class="poster-avatar">{{pet.emoji}}</view>
<view class="bold" style="font-size:30rpx">{{pet.name}} 的成长照片</view>
<view class="muted" style="font-size:24rpx;margin-top:12rpx">模拟上传预览</view>
</view>
<button class="btn btn-primary btn-block" bindtap="onSavePhoto">选择图片上传</button>
</block>
<!-- 提醒中心 -->
<block wx:elif="{{innerType === 'reminders'}}">
<view class="sheet-h3">提醒中心</view>
@@ -184,24 +150,6 @@ module.exports.sel = function (map, key, index, def) {
</block>
<!-- 任务详情 -->
<!-- 问问 AI -->
<block wx:elif="{{innerType === 'ai'}}">
<view class="sheet-h3">问问 AI</view>
<scroll-view class="plan-chat" scroll-y="true" enhanced="{{true}}" show-scrollbar="{{false}}" style="max-height:660rpx" scroll-into-view="ai-bottom">
<view wx:for="{{aiMessages}}" wx:key="index" class="msg {{item.role}}">{{item.text}}</view>
<view id="ai-bottom"></view>
</scroll-view>
<view class="mini-options">
<view class="option" data-text="猫咪今天吐了一次怎么办?" bindtap="sendAI">猫咪吐了一次</view>
<view class="option" data-text="幼猫多久驱虫一次?" bindtap="sendAI">多久驱虫</view>
<view class="option" data-text="换粮后软便怎么办?" bindtap="sendAI">换粮软便</view>
</view>
<view class="ai-input-row">
<input class="input" placeholder="描述一下问题..." placeholder-class="placeholder"
value="{{aiInput}}" bindinput="onAiInput" confirm-type="send" bindconfirm="sendAI"/>
<button class="btn btn-primary ai-send" bindtap="sendAI">发</button>
</view>
</block>
<!-- 成长海报 -->
<block wx:elif="{{innerType === 'poster'}}">
@@ -216,7 +164,14 @@ module.exports.sel = function (map, key, index, def) {
<view class="bold">健康状态:{{poster ? poster.headline : '稳定成长'}}</view>
<view class="muted" style="font-size:24rpx;margin-top:20rpx">生成自:肉垫计划</view>
</view>
<button class="btn btn-primary btn-block" style="margin-top:28rpx" bindtap="close">保存 / 分享卡片</button>
<view class="sheet-actions" style="margin-top:28rpx">
<button class="btn btn-ghost" open-type="share">分享给朋友</button>
<button class="btn btn-primary" bindtap="onSavePoster" disabled="{{posterSaving}}">
{{posterSaving ? '生成中…' : '保存到相册'}}
</button>
</view>
<!-- 离屏画布:海报要存成图片才能进相册。不能用 display:none,否则取不到节点 -->
<canvas type="2d" id="posterCanvas" class="poster-canvas"></canvas>
</block>
<!-- 报告详情 -->
@@ -232,9 +187,9 @@ module.exports.sel = function (map, key, index, def) {
<view class="sheet-h3">肉垫计划 Pro</view>
<view class="sheet-p">解锁 365 天计划、PDF 健康档案、月度报告、多宠物管理与年度账单。</view>
<view class="check-list">
<view wx:for="{{proInfo.features}}" wx:key="*this" class="check selected"><view class="box"></view>{{item}}</view>
<view wx:for="{{proInfo.features}}" wx:key="*this" class="check selected"><view class="box"><pt-icon name="check" size="{{26}}"></pt-icon></view>{{item}}</view>
</view>
<button wx:if="{{proInfo.status === 'active'}}" class="btn btn-soft btn-block" bindtap="close">已开通 Pro</button>
<button wx:if="{{proInfo.status === 'active'}}" class="btn btn-soft btn-block" bindtap="close"><pt-icon name="check" size="{{30}}"></pt-icon>已开通 Pro</button>
<button wx:else class="btn btn-soft btn-block" disabled="true">即将开放</button>
<view wx:if="{{proInfo.status !== 'active'}}" class="sheet-p" style="text-align:center;margin-top:16rpx;font-size:24rpx">
会员功能还在准备中,暂未开放购买。
@@ -242,36 +197,8 @@ module.exports.sel = function (map, key, index, def) {
</block>
<!-- 添加宠物 -->
<block wx:elif="{{innerType === 'addPet' || innerType === 'editPet'}}">
<view class="sheet-h3">{{innerType === 'editPet' ? '编辑档案' : '添加毛孩子'}}</view>
<view class="field"><label>名字</label><input class="input" placeholder="例如:布丁" placeholder-class="placeholder" value="{{addName}}" bindinput="onAddName"/></view>
<view class="field"><label>类型</label><view class="seg">
<view class="seg-btn {{u.sel(segSel,'addType',0,0)?'selected':''}}" data-group="addType" data-index="0" bindtap="onSeg">猫猫</view>
<view class="seg-btn {{u.sel(segSel,'addType',1,0)?'selected':''}}" data-group="addType" data-index="1" bindtap="onSeg">狗狗</view>
</view></view>
<view class="field"><label>性别</label><view class="seg">
<view class="seg-btn {{u.sel(segSel,'addGender',0,0)?'selected':''}}" data-group="addGender" data-index="0" bindtap="onSeg">男孩</view>
<view class="seg-btn {{u.sel(segSel,'addGender',1,0)?'selected':''}}" data-group="addGender" data-index="1" bindtap="onSeg">女孩</view>
<view class="seg-btn {{u.sel(segSel,'addGender',2,0)?'selected':''}}" data-group="addGender" data-index="2" bindtap="onSeg">不确定</view>
</view></view>
<view class="field"><label>生日 / 到家时间</label>
<picker mode="date" value="{{addBirthday}}" bindchange="onAddBirthday">
<view class="picker-box">{{addBirthday || '选择日期'}}</view>
</picker></view>
<view class="field"><label>毛色</label><input class="input" placeholder="例如:橘白 / 奶牛 / 黑" placeholder-class="placeholder" value="{{addColor}}" bindinput="onAddColor"/></view>
<view class="field"><label>品种</label><input class="input" placeholder="例如:中华田园猫 / 柯基" placeholder-class="placeholder" value="{{addBreed}}" bindinput="onAddBreed"/></view>
<view class="field"><label>当前体重</label><input class="input" placeholder="例如:2.8" placeholder-class="placeholder" type="digit" value="{{addWeight}}" bindinput="onAddWeight"/></view>
<view class="field"><label>当前阶段</label><view class="mini-options">
<view class="option {{u.sel(optSel,'addStage',0,0)?'selected':''}}" data-group="addStage" data-index="0" bindtap="onOpt">刚到家</view>
<view class="option {{u.sel(optSel,'addStage',1,0)?'selected':''}}" data-group="addStage" data-index="1" bindtap="onOpt">幼年期</view>
<view class="option {{u.sel(optSel,'addStage',2,0)?'selected':''}}" data-group="addStage" data-index="2" bindtap="onOpt">成年期</view>
<view class="option {{u.sel(optSel,'addStage',3,0)?'selected':''}}" data-group="addStage" data-index="3" bindtap="onOpt">老年期</view>
</view></view>
<button class="btn btn-primary btn-block" bindtap="onPetSubmit">{{innerType === 'editPet' ? '保存修改' : '添加宠物'}}</button>
<button wx:if="{{innerType === 'editPet'}}" class="btn btn-block del-btn" style="margin-top:16rpx" bindtap="onDeletePet">删除该宠物</button>
</block>
<!-- 发布图文 -->
<!-- 添加/编辑宠物已经整体搬到 pages/petform:三步向导(名字+头像 → 猫狗+品种
→ 其他资料),品种从后端 breeds 表来、按首字母索引。弹层塞不下这些 -->
<block wx:elif="{{innerType === 'createPost'}}">
<view class="sheet-h3">发布图文</view>
<view class="sheet-p">选择发布身份,分享你的养宠日常。</view>
@@ -292,58 +219,16 @@ module.exports.sel = function (map, key, index, def) {
<view class="img-picker">
<view wx:for="{{postImages}}" wx:key="id" class="img-thumb">
<image src="{{item.url}}" mode="aspectFill"></image>
<view class="img-del" catchtap="onRemovePostImage" data-index="{{index}}">×</view>
<view class="img-del" catchtap="onRemovePostImage" data-index="{{index}}"><pt-icon name="close" size="{{24}}"></pt-icon></view>
</view>
<view wx:if="{{postImages.length < 9}}" class="img-add" bindtap="onPickPostImages"></view>
<view wx:if="{{postImages.length < 9}}" class="img-add" bindtap="onPickPostImages"><pt-icon name="plus" size="{{48}}"></pt-icon></view>
</view>
</view>
<button class="btn btn-primary btn-block" bindtap="onCreatePost">发布到宠友圈</button>
</block>
<!-- 管理任务 -->
<block wx:elif="{{innerType === 'manageTasks'}}">
<view class="sheet-h3">管理任务</view>
<view class="sheet-p">增删改今日任务,改动会自动顺延到以后每天。</view>
<view wx:for="{{manageTasks}}" wx:key="id" class="mtask-row">
<view class="mtask-body">
<text class="mtask-b">{{item.title}}<text wx:if="{{item.priority}}" class="mtask-pri">{{item.priority}}</text></text>
<view wx:if="{{item.description}}" class="mtask-p">{{item.description}}</view>
</view>
<view class="mtask-op" catchtap="onEditTaskItem" data-index="{{index}}">改</view>
<view class="mtask-op del" catchtap="onDeleteTaskItem" data-index="{{index}}">删</view>
</view>
<view wx:if="{{!manageTasks.length}}" class="sheet-p">今天还没有任务,下面加一条吧。</view>
<view class="field" style="margin-top:12rpx"><label>{{taskForm.id ? '修改任务' : '新增任务'}}</label>
<input class="input" placeholder="任务名,例如:喂驱虫药" placeholder-class="placeholder" value="{{taskForm.title}}" bindinput="onTaskTitle"/></view>
<view class="field"><input class="input" placeholder="说明(可选)" placeholder-class="placeholder" value="{{taskForm.description}}" bindinput="onTaskDesc"/></view>
<view class="field"><label>优先级 / 关联记录</label>
<view class="mtask-form-row">
<view class="seg-btn {{taskForm.priority === '重要' ? 'selected' : ''}}" catchtap="onTaskPriority">重要</view>
<picker mode="selector" range="{{taskSheetLabels}}" value="{{taskForm.sheetIdx}}" bindchange="onTaskSheet">
<view class="picker-box">关联:{{taskSheetLabels[taskForm.sheetIdx]}}</view>
</picker>
</view>
</view>
<button class="btn btn-primary btn-block" bindtap="onSaveTask">{{taskForm.id ? '保存修改' : '添加任务'}}</button>
</block>
<!-- 评论 -->
<block wx:elif="{{innerType === 'comments'}}">
<view class="sheet-h3">评论</view>
<view wx:for="{{comments}}" wx:key="id" class="record-row" bindlongpress="onDeleteComment" data-index="{{index}}">
<text class="bold">{{item.author_name}}<text wx:if="{{item.is_self}}" class="mine-tag">我</text></text>
<text class="muted">{{item.content}}</text>
</view>
<view wx:if="{{!comments.length}}" class="sheet-p">还没有评论,来抢沙发~</view>
<view wx:if="{{comments.length}}" class="sheet-p" style="font-size:22rpx;color:var(--muted)">长按自己的评论可删除</view>
<view class="ai-input-row" style="margin-top:24rpx">
<input class="input" placeholder="友善交流,分享经验..." placeholder-class="placeholder" value="{{commentText}}" bindinput="onCommentInput"/>
<button class="btn btn-primary ai-send" bindtap="onSendComment">发</button>
</view>
</block>
<!-- 多宠物管理 -->
<!-- 任务管理那一支删了:它管的是 DailyTask,而「今日任务」已经并进计划节点,
增删改都在计划页做。留着会出现「这里加的任务」和「计划页加的节点」进不同的表 -->
<block wx:elif="{{innerType === 'managePets'}}">
<view class="sheet-h3">多宠物管理</view>
<view class="sheet-p">点一只切换为当前宠物,或编辑它的档案。</view>
+136 -30
View File
@@ -7,58 +7,164 @@
.overlay.show{opacity:1;pointer-events:auto}
.sheet{
position:absolute;left:0;right:0;bottom:0;
background:#fff;border-radius:60rpx 60rpx 0 0;
padding:32rpx 0 0;
background:#fff;border-radius:48rpx 48rpx 0 0;
padding:var(--sp-5) 0 0;
transform:translateY(106%);transition:.24s ease;
box-shadow:0 -40rpx 100rpx rgba(0,0,0,.16);
box-shadow:var(--sd-3);
}
.overlay.show .sheet{transform:translateY(0)}
.sheet-scroll{
box-sizing:border-box;
max-height:78vh;
padding:0 36rpx calc(120rpx + env(safe-area-inset-bottom));
/* 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:88rpx;height:10rpx;border-radius:999rpx;background:#E1D8CF;margin:0 auto 24rpx}
.sheetbar{width:80rpx;height:8rpx;border-radius:var(--r-full);background:#E1D8CF;margin:0 auto var(--sp-4)}
.sheet-h3{font-size:40rpx;font-weight:800;margin-bottom:20rpx}
.sheet-p{font-size:28rpx;color:#62584F;line-height:1.65;margin-bottom:24rpx}
.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:40rpx;font-weight:800;margin-bottom:20rpx}
.bold{font-weight:900}
.poster-h3{font-size:var(--fs-xl);font-weight:var(--fw-b);margin-bottom:var(--sp-3)}
.ai-input-row{display:flex;gap:16rpx;align-items:center}
.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 28rpx}
.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)}
.del-btn{background:var(--red-soft);color:var(--red-ink)}
/* 管理任务 */
.mtask-row{display:flex;align-items:center;gap:16rpx;padding:20rpx 0;border-bottom:1rpx solid var(--line)}
/* 管理任务 / 管理宠物 */
.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:28rpx;font-weight:700}
.mtask-pri{margin-left:12rpx;font-size:20rpx;font-weight:700;color:var(--primary-dark);background:#FFF2DC;padding:2rpx 12rpx;border-radius:999rpx}
.mtask-p{margin-top:6rpx;font-size:24rpx;color:var(--muted)}
.mtask-op{flex:none;font-size:26rpx;font-weight:700;color:var(--primary-dark);padding:8rpx 18rpx;background:#FBFAF8;border-radius:16rpx}
.mtask-op.del{color:var(--red)}
.mtask-form-row{display:flex;align-items:center;gap:16rpx}
.mtask-form-row .seg-btn{flex:none;padding:0 28rpx;height:72rpx;line-height:72rpx}
.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:16rpx}
.img-thumb{position:relative;width:160rpx;height:160rpx;border-radius:20rpx;overflow:hidden}
.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:40rpx;height:40rpx;line-height:38rpx;text-align:center;background:rgba(0,0,0,.55);color:#fff;font-size:30rpx;border-radius:0 20rpx 0 20rpx}
.img-add{width:160rpx;height:160rpx;border-radius:20rpx;border:2rpx dashed #D8CFC4;display:flex;align-items:center;justify-content:center;font-size:56rpx;color:#B9AE9F;background:#FBFAF8}
.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:#FBFAF8;border-radius:28rpx;
padding:24rpx;font-size:25rpx;line-height:1.7;color:#4A4A46;white-space:pre-line;
border:1rpx solid var(--line);margin-bottom:24rpx;
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:10rpx;font-size:20rpx;font-weight:700;color:var(--primary-dark);background:#FFF2DC;padding:2rpx 10rpx;border-radius:999rpx}
.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)}
+7
View File
@@ -1,5 +1,12 @@
Component({
options: { addGlobalClass: true },
properties: {
// 之前漏声明,icon 一直没传进来,pt-icon 收到空值退到 note 兜底字形,
// 首页那个「文档」图标就是这么来的
icon: { type: String, value: 'plus' },
// 首页快速记录用肉垫造型(呼应「肉垫计划」),社区发帖仍用普通图标
paw: { type: Boolean, value: false },
},
methods: {
onTap() {
this.triggerEvent('tap');
+3 -1
View File
@@ -1,4 +1,6 @@
{
"component": true,
"usingComponents": {}
"usingComponents": {
"pt-icon": "/components/pt-icon/index"
}
}
+11 -1
View File
@@ -1 +1,11 @@
<view class="fab" bindtap="onTap">AI</view>
<view class="fab {{paw ? 'fab-paw' : ''}}" bindtap="onTap">
<!-- 肉垫:一个大掌垫 + 四个脚趾垫,纯 CSS 画,呼应 app 名 -->
<view wx:if="{{paw}}" class="paw">
<view class="bean bean-1"></view>
<view class="bean bean-2"></view>
<view class="bean bean-3"></view>
<view class="bean bean-4"></view>
<view class="pad"></view>
</view>
<pt-icon wx:else name="{{icon}}" size="{{48}}"></pt-icon>
</view>
+23 -5
View File
@@ -1,10 +1,28 @@
/* 统一暖橘圆钮:首页肉垫和社区发帖用同一套主题色 */
.fab{
position:fixed;right:36rpx;bottom:calc(190rpx + env(safe-area-inset-bottom));
width:116rpx;height:116rpx;border-radius:44rpx;
background:linear-gradient(135deg,#78A6E8,#9FC0F5);
color:#fff;font-weight:900;font-size:40rpx;
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 28rpx 60rpx rgba(120,166,232,.32);
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(.96)}
.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)}
+3 -1
View File
@@ -1,4 +1,6 @@
{
"component": true,
"usingComponents": {}
"usingComponents": {
"pt-icon": "/components/pt-icon/index"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
<view class="nav-wrap" style="padding-top:{{statusBarHeight}}px;height:{{statusBarHeight + navBarHeight}}px;">
<view class="nav-inner" style="height:{{navBarHeight}}px;">
<view wx:if="{{showBack}}" class="nav-back" bindtap="onBack"></view>
<view wx:if="{{showBack}}" class="nav-back" bindtap="onBack"><pt-icon name="back" size="{{40}}"></pt-icon></view>
<view class="nav-title">{{title}}</view>
</view>
</view>
+22 -8
View File
@@ -1,19 +1,33 @@
:host{display:block}
/* 原来是一整条半透明色块,底边和页面渐变硬碰硬,屏幕上横着一道接缝。
改成上实下虚的渐变,并且用同一条 mask 把毛玻璃也一起淡出——
只淡化背景色而不淡化 blur 的话,接缝会从「色差」变成「糊边」,一样难看。 */
.nav-wrap{
position:fixed;top:0;left:0;right:0;z-index:100;
background:rgba(248,246,242,.82);
backdrop-filter:blur(28rpx);
-webkit-backdrop-filter:blur(28rpx);
background:linear-gradient(180deg,
rgba(250,247,242,.94) 0%,
rgba(250,247,242,.88) 58%,
rgba(250,247,242,0) 100%);
backdrop-filter:blur(20rpx);
-webkit-backdrop-filter:blur(20rpx);
-webkit-mask-image:linear-gradient(180deg,#000 0%,#000 62%,transparent 100%);
mask-image:linear-gradient(180deg,#000 0%,#000 62%,transparent 100%);
}
.nav-inner{
position:relative;display:flex;align-items:center;justify-content:center;
}
.nav-title{
font-size:32rpx;font-weight:900;color:var(--text);
max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text);
letter-spacing:.5rpx;
max-width:56%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
/* 返回键做成有边界的圆形,和右上角胶囊在视觉上对称 */
.nav-back{
position:absolute;left:20rpx;top:50%;transform:translateY(-50%);
width:64rpx;height:64rpx;display:flex;align-items:center;justify-content:center;
font-size:52rpx;line-height:1;color:var(--text);
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(--text);background:rgba(255,255,255,.72);
box-shadow:0 4rpx 12rpx rgba(70,48,25,.06);
}
.nav-back:active{background:rgba(255,255,255,.95)}
@@ -2,6 +2,11 @@ const store = require('../../utils/store.js');
Component({
options: { addGlobalClass: true },
properties: {
// 「添加」这一格不是每个页面都该有:计划页/记录页只是在既有宠物之间切,
// 建档入口在首页和「我的」就够了,到处摆一个会让这条 chip 变得很长
showAdd: { type: Boolean, value: true },
},
data: {
pets: [],
currentId: 0,
@@ -1,4 +1,6 @@
{
"component": true,
"usingComponents": {}
"usingComponents": {
"pt-icon": "/components/pt-icon/index"
}
}
@@ -1,10 +1,7 @@
<scroll-view class="pet-switch" scroll-x="true" enhanced="{{true}}" show-scrollbar="{{false}}">
<!-- 纯文字筛选:带头像时不同宠物 chip 高度对不齐,这里只作切换用,文字最稳 -->
<view wx:for="{{pets}}" wx:key="id"
class="pet-chip {{item.id === currentId ? 'active' : ''}}"
data-id="{{item.id}}" bindtap="onPick">
<view class="mini-avatar">{{item.emoji}}</view>{{item.name}}
</view>
<view class="pet-chip" bindtap="onAdd">
<view class="mini-avatar"></view>添加
</view>
data-id="{{item.id}}" bindtap="onPick">{{item.name}}</view>
<view wx:if="{{showAdd}}" class="pet-chip pet-chip-add" bindtap="onAdd"> 添加</view>
</scroll-view>
@@ -1,5 +1,11 @@
.pet-switch{
display:block;white-space:nowrap;width:100%;
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{margin-right:20rpx}
.pet-switch .pet-chip:last-child{margin-right:0}
.pet-chip-add{color:var(--muted)}
+36
View File
@@ -0,0 +1,36 @@
// 个人主页头部。「我的」和「宠友主页」共用同一份渲染——自己看和别人看
// 长得不一样的话,两边会慢慢长歪,装扮功能也就失去意义了。
// 差别只有一处:自己看是「装扮主页」,别人看是「关注 TA」。
Component({
options: { addGlobalClass: true },
properties: {
card: { type: Object, value: null },
// 装扮页的实时预览:只看效果,不要底部那排按钮
preview: { 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 });
},
},
});
@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"pt-icon": "/components/pt-icon/index"
}
}
@@ -0,0 +1,54 @@
<view class="pf th-{{theme}}">
<!-- 头图。没传背景图就用主题色渐变,不留空白块 -->
<view class="pf-bg">
<image wx:if="{{card.bg_url}}" class="pf-bg-img" src="{{card.bg_url}}" mode="aspectFill"></image>
</view>
<view class="pf-body">
<view class="pf-av">
<image wx:if="{{card.avatar_url}}" class="pf-av-img" src="{{card.avatar_url}}" mode="aspectFill"></image>
<block wx:else>{{initial}}</block>
</view>
<view class="pf-name">{{card.nickname || '毛孩子家长'}}<text wx:if="{{card.is_bot}}" class="ai-tag">AI</text></view>
<view class="pf-bio">{{card.bio || '这个人很懒,还没写签名'}}</view>
<view class="pf-stats">
<view class="pf-stat"><text class="pf-n">{{card.post_count || 0}}</text><text class="pf-l">帖子</text></view>
<view class="pf-stat" data-kind="following" bindtap="onRelations">
<text class="pf-n">{{card.following_count || 0}}</text><text class="pf-l">关注</text>
</view>
<view class="pf-stat" data-kind="followers" bindtap="onRelations">
<text class="pf-n">{{card.follower_count || 0}}</text><text class="pf-l">粉丝</text>
</view>
</view>
<!-- 养宠数据。这是这个 App 独有的东西,比帖子数更能说明「这人是个认真的铲屎官」 -->
<view wx:if="{{card.stats}}" class="pf-keep">
<view class="pf-keep-i"><text class="pf-kn">{{card.stats.days || 0}}</text><text class="pf-kl">养宠天数</text></view>
<view class="pf-keep-i"><text class="pf-kn">{{card.stats.records || 0}}</text><text class="pf-kl">累计记录</text></view>
<view class="pf-keep-i"><text class="pf-kn">{{card.stats.streak || 0}}</text><text class="pf-kl">连续打卡</text></view>
</view>
<!-- 宠物名片墙。可以在装扮里关掉 -->
<scroll-view wx:if="{{card.show_pets && card.pets.length}}" class="pf-wall"
scroll-x="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view wx:for="{{card.pets}}" wx:key="id" class="pf-pet" data-id="{{item.id}}" bindtap="onPet">
<view class="pf-pet-av">
<image wx:if="{{item.avatar_url}}" class="pf-pet-img" src="{{item.avatar_url}}" mode="aspectFill"></image>
<block wx:else>{{item.emoji}}</block>
</view>
<view class="pf-pet-n">{{item.name}}</view>
<view class="pf-pet-s">{{item.stage}}</view>
</view>
</scroll-view>
<block wx:if="{{!preview}}">
<view wx:if="{{card.is_self}}" class="btn btn-ghost btn-block" bindtap="onDecorate">
<pt-icon name="edit" size="{{30}}"></pt-icon>装扮我的主页
</view>
<view wx:else class="btn {{card.followed ? 'btn-ghost' : 'btn-dark'}} btn-block" bindtap="onFollow">
<pt-icon wx:if="{{!card.followed}}" name="plus" size="{{28}}"></pt-icon>{{card.followed ? '已关注' : '关注 TA'}}
</view>
</block>
</view>
</view>
@@ -0,0 +1,61 @@
/* 主题只在这里定义两个变量,下面所有着色都走变量。
后端存的是 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{height:180rpx;background:linear-gradient(135deg,var(--pf-a),var(--pf-b))}
.pf-bg-img{width:100%;height:100%;display:block}
.pf-body{padding:0 var(--sp-5) var(--sp-5);text-align:center}
/* 头像压在头图上,负 margin 提上去一半 */
.pf-av{
/* 必须定位:头像要压在头图上面,靠 DOM 顺序不够——只要头图是定位元素就会反盖过来 */
position:relative;z-index:1;
width:144rpx;height:144rpx;margin:-72rpx auto var(--sp-3);
border-radius:50%;background:var(--pf-b);color:var(--pf-ink);
display:flex;align-items:center;justify-content:center;
font-size:var(--fs-xl);font-weight:var(--fw-b);
border:6rpx solid #fff;overflow:hidden;box-shadow:var(--sd-1);
}
.pf-av-img{width:100%;height:100%;display:block}
.pf-name{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text)}
.pf-bio{
font-size:var(--fs-sm);color:var(--muted);margin-top:6rpx;
line-height:1.5;word-break:break-all;
}
.pf-stats{display:flex;margin:var(--sp-4) 0}
.pf-stat{flex:1;display:flex;flex-direction:column;align-items:center;gap:4rpx}
.pf-n{font-size:var(--fs-lg);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-3) 0;margin-bottom:var(--sp-4);
}
.pf-keep-i{flex:1;display:flex;flex-direction:column;align-items:center;gap:2rpx}
.pf-kn{font-size:var(--fs-md);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-bottom: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}
+59
View File
@@ -0,0 +1,59 @@
// 线性图标。图标资源在 styles/iconfont.wxssmask-image + currentColor)。
//
// name 可以直接传图标名,也可以传服务端下发的值:
// - 记录类型(HealthRecord.Type9 值枚举,可靠)
// - 文章分类(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 });
},
},
});
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+1
View File
@@ -0,0 +1 @@
<view class="{{cls}}" style="{{style}}"></view>
+17
View File
@@ -0,0 +1,17 @@
/* 图标资源在组件内 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%;
}
+38
View File
@@ -0,0 +1,38 @@
// 横向切换控件。此前有三套长得像但尺寸各不同的实现:
// 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 });
},
},
});
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+9
View File
@@ -0,0 +1,9 @@
<scroll-view wx:if="{{variant === 'pill'}}" class="st st-pill" scroll-x="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view wx:for="{{list}}" wx:key="key" class="st-item {{item.active ? 'on' : ''}}"
data-index="{{index}}" data-key="{{item.key}}" bindtap="onTap">{{item.label}}</view>
</scroll-view>
<view wx:else class="st st-solid">
<view wx:for="{{list}}" wx:key="key" class="st-item {{item.active ? 'on' : ''}}"
data-index="{{index}}" data-key="{{item.key}}" bindtap="onTap">{{item.label}}</view>
</view>
+21
View File
@@ -0,0 +1,21 @@
.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)}
+41
View File
@@ -0,0 +1,41 @@
// 完成度环。用 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) =>
'<circle cx="50" cy="50" r="' + R + '" fill="none" stroke="' + color +
'" stroke-width="' + stroke + '" stroke-linecap="round"' + dash + '/>';
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">' +
circle(track, '') +
'<g transform="rotate(-90 50 50)">' +
circle(fill, ' stroke-dasharray="' + c.toFixed(1) + '" stroke-dashoffset="' + off.toFixed(1) + '"') +
'</g></svg>'
);
}
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',
});
},
},
});
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

Some files were not shown because too many files have changed in this diff Show More