feat(auth): access token 缩到 2 小时 + refresh token 机制 #3
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>admin</title>
|
<title>admin</title>
|
||||||
<script type="module" crossorigin src="/admin/assets/index-GGI_ie1V.js"></script>
|
<script type="module" crossorigin src="/admin/assets/index-DMg3rix-.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-BjLxu16U.css">
|
<link rel="stylesheet" crossorigin href="/admin/assets/index-D4fxTaca.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -390,3 +390,23 @@ func (h *Handler) AdminGenerateCommunityPosts(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
response.OK(c, gin.H{"made": made})
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
reply, err := h.svc.AIChat(middleware.UserID(c), req.PetID, req.Session, req.Text)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrAIQuotaExceeded) {
|
||||||
|
response.Fail(c, 42900, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
response.FailErr(c, err)
|
response.FailErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -188,3 +192,8 @@ func (h *Handler) ListAIMessages(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
response.OK(c, msgs)
|
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)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (r petReq) toInput() service.PetInput {
|
|||||||
AvatarFileID: r.AvatarFileID,
|
AvatarFileID: r.AvatarFileID,
|
||||||
}
|
}
|
||||||
if r.Birthday != "" {
|
if r.Birthday != "" {
|
||||||
if t, err := time.Parse("2006-01-02", r.Birthday); err == nil {
|
if t, err := time.ParseInLocation("2006-01-02", r.Birthday, time.Local); err == nil {
|
||||||
// 生日不接受未来日期:前端限制过一道,服务端不能只信前端
|
// 生日不接受未来日期:前端限制过一道,服务端不能只信前端
|
||||||
if !t.After(time.Now()) {
|
if !t.After(time.Now()) {
|
||||||
in.Birthday = &t
|
in.Birthday = &t
|
||||||
@@ -42,7 +42,7 @@ func (r petReq) toInput() service.PetInput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if r.ArrivedAt != "" {
|
if r.ArrivedAt != "" {
|
||||||
if t, err := time.Parse("2006-01-02", r.ArrivedAt); err == nil && !t.After(time.Now()) {
|
if t, err := time.ParseInLocation("2006-01-02", r.ArrivedAt, time.Local); err == nil && !t.After(time.Now()) {
|
||||||
in.ArrivedAt = &t
|
in.ArrivedAt = &t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
func (h *Handler) ListTasks(c *gin.Context) {
|
func (h *Handler) ListTasks(c *gin.Context) {
|
||||||
var date *time.Time
|
var date *time.Time
|
||||||
if q := c.Query("date"); q != "" {
|
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
|
date = &t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ type taskReq struct {
|
|||||||
func (r taskReq) toInput() service.TaskInput {
|
func (r taskReq) toInput() service.TaskInput {
|
||||||
in := service.TaskInput{Title: r.Title, Description: r.Description, Priority: r.Priority, SheetType: r.SheetType}
|
in := service.TaskInput{Title: r.Title, Description: r.Description, Priority: r.Priority, SheetType: r.SheetType}
|
||||||
if r.Date != "" {
|
if r.Date != "" {
|
||||||
if t, err := time.Parse("2006-01-02", r.Date); err == nil {
|
if t, err := time.ParseInLocation("2006-01-02", r.Date, time.Local); err == nil {
|
||||||
in.Date = &t
|
in.Date = &t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func (h *Handler) CreateReminder(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
in := service.ReminderInput{Type: req.Type, Title: req.Title, Frequency: req.Frequency}
|
in := service.ReminderInput{Type: req.Type, Title: req.Title, Frequency: req.Frequency}
|
||||||
if req.NextDueDate != "" {
|
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
|
in.NextDueDate = &t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ func (h *Handler) UpdateReminder(c *gin.Context) {
|
|||||||
fields["frequency"] = req.Frequency
|
fields["frequency"] = req.Frequency
|
||||||
}
|
}
|
||||||
if req.NextDueDate != "" {
|
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
|
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"`
|
||||||
|
}
|
||||||
@@ -48,5 +48,7 @@ func AllModels() []any {
|
|||||||
&Feedback{},
|
&Feedback{},
|
||||||
&Follow{},
|
&Follow{},
|
||||||
&RefreshToken{},
|
&RefreshToken{},
|
||||||
|
&AIQuotaConfig{},
|
||||||
|
&AIUsage{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
|
import "gorm.io/datatypes"
|
||||||
|
|
||||||
// Comment 帖子评论
|
// Comment 帖子评论
|
||||||
type Comment struct {
|
type Comment struct {
|
||||||
Base
|
Base
|
||||||
@@ -7,6 +9,7 @@ type Comment struct {
|
|||||||
UserID string `gorm:"size:24;index" json:"user_id"`
|
UserID string `gorm:"size:24;index" json:"user_id"`
|
||||||
AuthorName string `gorm:"size:64" json:"author_name"`
|
AuthorName string `gorm:"size:64" json:"author_name"`
|
||||||
Content string `gorm:"size:512" json:"content"`
|
Content string `gorm:"size:512" json:"content"`
|
||||||
|
Images datatypes.JSON `json:"images"`
|
||||||
Status string `gorm:"size:16;default:published" json:"status"`
|
Status string `gorm:"size:16;default:published" json:"status"`
|
||||||
|
|
||||||
IsSelf bool `gorm:"-" json:"is_self"` // 计算字段:是不是我自己发的
|
IsSelf bool `gorm:"-" json:"is_self"` // 计算字段:是不是我自己发的
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
|
|||||||
|
|
||||||
g.POST("/ai/chat", h.AIChat)
|
g.POST("/ai/chat", h.AIChat)
|
||||||
g.GET("/ai/messages", h.ListAIMessages)
|
g.GET("/ai/messages", h.ListAIMessages)
|
||||||
|
g.GET("/ai/quota", h.AIQuota)
|
||||||
g.POST("/pets/:id/ai/assess-symptom", h.AssessSymptom)
|
g.POST("/pets/:id/ai/assess-symptom", h.AssessSymptom)
|
||||||
g.POST("/upload", h.Upload)
|
g.POST("/upload", h.Upload)
|
||||||
}
|
}
|
||||||
@@ -146,6 +147,9 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag
|
|||||||
g.PUT("/care-templates", h.AdminSaveCareTemplate)
|
g.PUT("/care-templates", h.AdminSaveCareTemplate)
|
||||||
g.POST("/care-templates/generate", h.AdminGenerateCareTemplate)
|
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.GET("/community-bot", h.AdminGetCommunityBot)
|
||||||
g.PUT("/community-bot", h.AdminSaveCommunityBot)
|
g.PUT("/community-bot", h.AdminSaveCommunityBot)
|
||||||
g.POST("/community-bot/generate", h.AdminGenerateCommunityPosts)
|
g.POST("/community-bot/generate", h.AdminGenerateCommunityPosts)
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import (
|
|||||||
|
|
||||||
// AIChat 记录一问一答:启用模型则真调(注入宠物档案),否则规则化文案
|
// AIChat 记录一问一答:启用模型则真调(注入宠物档案),否则规则化文案
|
||||||
func (s *Service) AIChat(userID string, petID *string, session, text string) (string, error) {
|
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 小时,建议尽快就医。"
|
reply := "我会先判断风险等级,再建议你记录关键观察项。若出现频繁呕吐、便血、精神明显变差或持续超过 24 小时,建议尽快就医。"
|
||||||
if s.ai != nil && s.ai.Enabled() {
|
if s.ai != nil && s.ai.Enabled() {
|
||||||
if r, err := s.llmChat(petID, text); err == nil && r != "" {
|
if r, err := s.llmChat(petID, text); err == nil && r != "" {
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ type SymptomResult struct {
|
|||||||
|
|
||||||
// AssessSymptom 异常风险评估:启用模型则结构化输出,否则规则化
|
// AssessSymptom 异常风险评估:启用模型则结构化输出,否则规则化
|
||||||
func (s *Service) AssessSymptom(userID, petID string, in SymptomInput) (*SymptomResult, error) {
|
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 {
|
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||||
return nil, err
|
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
|
||||||
|
}
|
||||||
@@ -138,7 +138,7 @@ func (s *Service) Calendar(userID, petID string, year, month int) (*CalendarResu
|
|||||||
set := s.datesWithActivity(petID, start, end)
|
set := s.datesWithActivity(petID, start, end)
|
||||||
days := make([]int, 0, len(set))
|
days := make([]int, 0, len(set))
|
||||||
for k := range 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())
|
days = append(days, t.Day())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,7 @@ func (s *Service) DayPlan(userID, petID, dateStr string) (map[string]any, error)
|
|||||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -192,6 +192,9 @@ func (s *Service) DayPlan(userID, petID, dateStr string) (map[string]any, error)
|
|||||||
|
|
||||||
// CreateAIPlan 基于用户描述做规则化提取,生成待确认的 AI 计划
|
// CreateAIPlan 基于用户描述做规则化提取,生成待确认的 AI 计划
|
||||||
func (s *Service) CreateAIPlan(userID, petID string, input string) (*model.Plan, error) {
|
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)
|
pet, err := s.ownedPet(userID, petID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -137,6 +137,12 @@ export const api = {
|
|||||||
setFeedbackHandled: (id: string, handled: boolean) =>
|
setFeedbackHandled: (id: string, handled: boolean) =>
|
||||||
http.put(`/admin/feedback/${id}/handled`, { handled }),
|
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),
|
||||||
|
|
||||||
communityBot: () =>
|
communityBot: () =>
|
||||||
http.get<any, { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }>(
|
http.get<any, { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }>(
|
||||||
'/admin/community-bot',
|
'/admin/community-bot',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
type Cfg = { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }
|
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() {
|
export default function CommunityBot() {
|
||||||
const [cfg, setCfg] = useState<Cfg>({ enabled: true, daily_count: 3, start_hour: 9, end_hour: 21 })
|
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 [genN, setGenN] = useState(5)
|
||||||
const [gen, setGen] = useState(false)
|
const [gen, setGen] = useState(false)
|
||||||
const [msg, setMsg] = useState('')
|
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(() => {
|
useEffect(() => {
|
||||||
api.communityBot().then((c) => {
|
api.communityBot().then((c) => {
|
||||||
setCfg(c)
|
setCfg(c)
|
||||||
setLoading(false)
|
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) => {
|
const num = (k: keyof Cfg, min: number, max: number) => (e: any) => {
|
||||||
let v = Number(e.target.value)
|
let v = Number(e.target.value)
|
||||||
if (Number.isNaN(v)) v = min
|
if (Number.isNaN(v)) v = min
|
||||||
@@ -144,6 +169,51 @@ export default function CommunityBot() {
|
|||||||
</Card>
|
</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>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ Component({
|
|||||||
proInfo: null,
|
proInfo: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
posterSaving: false,
|
posterSaving: false,
|
||||||
|
centered: false,
|
||||||
|
commentImages: [],
|
||||||
},
|
},
|
||||||
observers: {
|
observers: {
|
||||||
show: function (show) {
|
show: function (show) {
|
||||||
@@ -136,7 +138,8 @@ Component({
|
|||||||
methods: {
|
methods: {
|
||||||
setType(type, fresh) {
|
setType(type, fresh) {
|
||||||
const pet = store.getPet();
|
const pet = store.getPet();
|
||||||
const patch = { innerType: type, pet };
|
// 评论以输入为主,贴底弹层会被键盘顶掉大半屏,改成居中
|
||||||
|
const patch = { innerType: type, pet, centered: type === 'comments' };
|
||||||
if (fresh) {
|
if (fresh) {
|
||||||
patch.segSel = {};
|
patch.segSel = {};
|
||||||
patch.optSel = {};
|
patch.optSel = {};
|
||||||
@@ -161,6 +164,7 @@ Component({
|
|||||||
patch.addColor = '';
|
patch.addColor = '';
|
||||||
patch.addBreed = '';
|
patch.addBreed = '';
|
||||||
patch.commentText = '';
|
patch.commentText = '';
|
||||||
|
patch.commentImages = [];
|
||||||
patch.riskData = null;
|
patch.riskData = null;
|
||||||
patch.saving = false;
|
patch.saving = false;
|
||||||
}
|
}
|
||||||
@@ -925,14 +929,35 @@ Component({
|
|||||||
// 评论
|
// 评论
|
||||||
async onSendComment() {
|
async onSendComment() {
|
||||||
const text = (this.data.commentText || '').trim();
|
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 {
|
try {
|
||||||
await api.createComment(this.data.postId, text);
|
await api.createComment(this.data.postId, text, this.data.commentImages.map((i) => i.url));
|
||||||
|
this.setData({ commentText: '', commentImages: [] });
|
||||||
|
this.loadComments();
|
||||||
this.triggerEvent('commented');
|
this.triggerEvent('commented');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
wx.showToast({ title: e.message || '评论失败', icon: 'none' });
|
wx.showToast({ title: e.message || '评论失败', icon: 'none' });
|
||||||
}
|
}
|
||||||
this.close();
|
},
|
||||||
|
|
||||||
|
// 评论配图,最多 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' });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onRemoveCommentImage(e) {
|
||||||
|
const list = this.data.commentImages.slice();
|
||||||
|
list.splice(e.currentTarget.dataset.index, 1);
|
||||||
|
this.setData({ commentImages: list });
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ module.exports.sel = function (map, key, index, def) {
|
|||||||
</wxs>
|
</wxs>
|
||||||
|
|
||||||
<view class="overlay {{show ? 'show' : ''}}" bindtap="onMaskTap">
|
<view class="overlay {{show ? 'show' : ''}}" bindtap="onMaskTap">
|
||||||
<view class="sheet" catchtap="noop">
|
<view class="sheet {{centered ? 'sheet-center' : ''}}" catchtap="noop">
|
||||||
<view class="sheetbar"></view>
|
<view class="sheetbar"></view>
|
||||||
|
|
||||||
<scroll-view class="sheet-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
|
<scroll-view class="sheet-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
|
||||||
@@ -336,11 +336,25 @@ module.exports.sel = function (map, key, index, def) {
|
|||||||
<view wx:if="{{!comments.length}}" class="empty">还没有评论,来抢个沙发</view>
|
<view wx:if="{{!comments.length}}" class="empty">还没有评论,来抢个沙发</view>
|
||||||
<view wx:if="{{comments.length}}" class="cm-tip">长按自己的评论可删除</view>
|
<view wx:if="{{comments.length}}" class="cm-tip">长按自己的评论可删除</view>
|
||||||
|
|
||||||
<view class="cm-input">
|
<view class="cm-editor">
|
||||||
<input class="input" placeholder="友善交流,分享经验…" placeholder-class="placeholder"
|
<textarea class="textarea cm-ta" placeholder="友善交流,分享你的经验…" placeholder-class="placeholder"
|
||||||
value="{{commentText}}" bindinput="onCommentInput" confirm-type="send" bindconfirm="onSendComment"/>
|
value="{{commentText}}" bindinput="onCommentInput" maxlength="500"
|
||||||
|
auto-height="{{true}}" show-confirm-bar="{{false}}" cursor-spacing="24"></textarea>
|
||||||
|
<view wx:if="{{commentImages.length}}" class="img-picker" style="margin-top:16rpx">
|
||||||
|
<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-bar">
|
||||||
|
<view class="cm-pic" bindtap="onPickCommentImages">
|
||||||
|
<pt-icon name="photo" size="{{34}}"></pt-icon>
|
||||||
|
<text>配图 {{commentImages.length}}/3</text>
|
||||||
|
</view>
|
||||||
|
<text class="cm-count">{{commentText.length}}/500</text>
|
||||||
<view class="cm-send {{commentText ? 'on' : ''}}" bindtap="onSendComment">发送</view>
|
<view class="cm-send {{commentText ? 'on' : ''}}" bindtap="onSendComment">发送</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
</block>
|
</block>
|
||||||
|
|
||||||
<!-- 多宠物管理 -->
|
<!-- 多宠物管理 -->
|
||||||
|
|||||||
@@ -108,3 +108,20 @@
|
|||||||
background:var(--line);color:#fff;font-size:var(--fs-md);font-weight:var(--fw-b);transition:.16s ease;
|
background:var(--line);color:#fff;font-size:var(--fs-md);font-weight:var(--fw-b);transition:.16s ease;
|
||||||
}
|
}
|
||||||
.cm-send.on{background:var(--cta)}
|
.cm-send.on{background:var(--cta)}
|
||||||
|
|
||||||
|
/* 居中弹层:评论这类以输入为主的,贴底会被键盘顶掉大半屏 */
|
||||||
|
.sheet.sheet-center{
|
||||||
|
left:var(--sp-5);right:var(--sp-5);bottom:auto;top:50%;
|
||||||
|
border-radius:var(--r-lg);
|
||||||
|
transform:translateY(-50%) scale(.94);opacity:0;
|
||||||
|
}
|
||||||
|
.overlay.show .sheet.sheet-center{transform:translateY(-50%) scale(1);opacity:1}
|
||||||
|
.sheet-center .sheet-scroll{max-height:62vh;padding:0 var(--sp-5) var(--sp-5)}
|
||||||
|
.sheet-center .sheetbar{display:none}
|
||||||
|
|
||||||
|
/* 评论编辑区 */
|
||||||
|
.cm-editor{margin-top:var(--sp-4);border-top:1rpx solid var(--line);padding-top:var(--sp-4)}
|
||||||
|
.cm-ta{min-height:160rpx;height:auto}
|
||||||
|
.cm-bar{display:flex;align-items:center;gap:var(--sp-3);margin-top:var(--sp-3)}
|
||||||
|
.cm-pic{display:flex;align-items:center;gap:var(--sp-1);color:var(--muted);font-size:var(--fs-sm)}
|
||||||
|
.cm-count{margin-left:auto;color:var(--muted2);font-size:var(--fs-cap)}
|
||||||
|
|||||||
@@ -66,7 +66,11 @@ Page({
|
|||||||
api
|
api
|
||||||
.aiChat({ pet_id: store.currentPetId() || null, session: SESSION, text })
|
.aiChat({ pet_id: store.currentPetId() || null, session: SESSION, text })
|
||||||
.then((res) => this.typewrite(res.reply || '(没有返回内容)'))
|
.then((res) => this.typewrite(res.reply || '(没有返回内容)'))
|
||||||
.catch((e) => this.typewrite(e.message || '网络异常,请稍后再试。'));
|
.catch((e) => {
|
||||||
|
const msg = (e && e.message) || '网络异常,请稍后再试。';
|
||||||
|
// 额度用完不是故障,别让用户以为是网络问题反复重试
|
||||||
|
this.typewrite(msg.indexOf('次数') >= 0 ? msg + '\n\n每天的次数是为了控制成本,明天 0 点恢复。' : msg);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// 逐字把回复填进最后一个气泡
|
// 逐字把回复填进最后一个气泡
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ const api = {
|
|||||||
deletePost: (id) => request({ url: `/api/posts/${id}`, method: 'DELETE' }),
|
deletePost: (id) => request({ url: `/api/posts/${id}`, method: 'DELETE' }),
|
||||||
deleteComment: (id) => request({ url: `/api/comments/${id}`, method: 'DELETE' }),
|
deleteComment: (id) => request({ url: `/api/comments/${id}`, method: 'DELETE' }),
|
||||||
listComments: (id) => request({ url: `/api/posts/${id}/comments` }),
|
listComments: (id) => request({ url: `/api/posts/${id}/comments` }),
|
||||||
createComment: (id, content) =>
|
createComment: (id, content, images) =>
|
||||||
request({ url: `/api/posts/${id}/comments`, method: 'POST', data: { content } }),
|
request({ url: `/api/posts/${id}/comments`, method: 'POST', data: { content, images: images || [] } }),
|
||||||
|
|
||||||
// 文章
|
// 文章
|
||||||
listArticles: () => request({ url: '/api/articles' }),
|
listArticles: () => request({ url: '/api/articles' }),
|
||||||
|
|||||||
Reference in New Issue
Block a user