Merge pull request 'feat(auth): access token 缩到 2 小时 + refresh token 机制' (#3) from feat/dev into main
deploy / deploy (push) Successful in 1m59s
deploy / deploy (push) Successful in 1m59s
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
+5
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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:?}
|
||||
|
||||
@@ -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` 重新登录。
|
||||
|
||||
@@ -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
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/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>
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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_value,CreateRecord 靠它回写宠物档案
|
||||
// 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/水碗、猫爬架、航空箱、指甲剪、梳子;绝育是第一年最大的一笔。
|
||||
|
||||
【最容易被低估的一项:医疗】
|
||||
真正让预算失控的从来不是猫粮,是突发医疗。常见的口炎、尿闭、异物、肾病,一次就可能超过全年口粮开销。
|
||||
|
||||
建议:单独留一笔医疗备用金,或了解一下宠物医疗保险。这比省几十块钱的粮重要得多。
|
||||
|
||||
【怎么省得聪明】
|
||||
· 该省:花哨玩具、频繁换的网红零食。
|
||||
· 不该省:口粮质量、驱虫疫苗、该做的体检——省这些通常是把钱挪到医疗账单上。
|
||||
|
||||
在小程序里每次消费顺手记一笔,月底就能看到自己的真实结构,比拍脑袋估算准得多。`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"` // 关联记录类型 code:weight/poop/bath...
|
||||
// Repeat 列名用 repeat_rule:repeat 是 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
|
||||
|
||||
@@ -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"` // 回复总数(可能多于返回的条数)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 而不是 group:group 是 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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 宠物分页
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 日"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 "下一针疫苗(按月龄安排)"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
'/api': 'http://localhost:9090',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
# 联调时写 token 的临时文件
|
||||
adm.txt
|
||||
at.txt
|
||||
+26
-6
@@ -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
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
"usingComponents": {
|
||||
"pt-icon": "/components/pt-icon/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
"usingComponents": {
|
||||
"pt-icon": "/components/pt-icon/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 线性图标。图标资源在 styles/iconfont.wxss(mask-image + currentColor)。
|
||||
//
|
||||
// name 可以直接传图标名,也可以传服务端下发的值:
|
||||
// - 记录类型(HealthRecord.Type,9 值枚举,可靠)
|
||||
// - 文章分类(Article.Category)
|
||||
// - 历史 emoji(老数据里 icon 字段存的就是 emoji,只能兜底映射)
|
||||
// 三层都命不中时退到 fallback(默认 note),绝不留空白。
|
||||
|
||||
// 记录类型 / 文章分类 → 图标名
|
||||
const BY_TYPE = {
|
||||
weight: 'weight',
|
||||
poop: 'poop',
|
||||
food: 'food',
|
||||
symptom: 'symptom',
|
||||
medicine: 'medicine',
|
||||
vaccine: 'vaccine',
|
||||
deworm: 'deworm',
|
||||
cost: 'cost',
|
||||
photo: 'photo',
|
||||
note: 'doc',
|
||||
};
|
||||
|
||||
// 老数据里存的 emoji → 图标名。新数据不再写 emoji,但历史记录得能正常显示
|
||||
const BY_EMOJI = {
|
||||
'⚖️': 'weight', '⚖': 'weight',
|
||||
'💩': 'poop',
|
||||
'🍽️': 'food', '🍽': 'food', '🍚': 'food',
|
||||
'🤒': 'symptom', '🌡️': 'symptom',
|
||||
'💊': 'medicine',
|
||||
'💰': 'cost', '💸': 'cost',
|
||||
'📷': 'photo', '📸': 'photo',
|
||||
'💉': 'vaccine',
|
||||
'🛡️': 'deworm', '🛡': 'deworm',
|
||||
'📄': 'doc', '📋': 'doc',
|
||||
'📝': 'note', '✍️': 'note', '✍': 'note',
|
||||
'🐱': 'cat', '🐶': 'dog', '🐾': 'community',
|
||||
'🔔': 'bell', '⚙️': 'settings', '⚙': 'settings',
|
||||
'📅': 'plan', '📊': 'report', '🏠': 'home',
|
||||
'⚠️': 'warn', '📖': 'book', '📚': 'book',
|
||||
};
|
||||
|
||||
Component({
|
||||
options: { addGlobalClass: true, virtualHost: true },
|
||||
properties: {
|
||||
name: { type: String, value: '' },
|
||||
size: { type: Number, value: 32 }, // rpx
|
||||
color: { type: String, value: '' }, // 留空则跟随父级文字色
|
||||
fallback: { type: String, value: 'note' },
|
||||
},
|
||||
data: { cls: '', style: '' },
|
||||
observers: {
|
||||
'name,size,color,fallback': function (name, size, color, fallback) {
|
||||
const n = BY_TYPE[name] || BY_EMOJI[name] || name || fallback;
|
||||
const style =
|
||||
'width:' + size + 'rpx;height:' + size + 'rpx' + (color ? ';background-color:' + color : '');
|
||||
this.setData({ cls: 'pt-i pt-i-' + n, style });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<view class="{{cls}}" style="{{style}}"></view>
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)}
|
||||
@@ -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',
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user