fix: 带 date 查任务时区错位导致重复生成 + 评论弹窗重做 + AI 每日次数上限
## 首页添加的任务不显示(根因比表象严重)
time.Parse("2006-01-02", q) 返回的是 UTC 时间,dayStart 又保留了
t.Location(),于是带 ?date= 查询时:
查询区间 = 07-29 00:00 UTC ~ 次日 = CST 的 08:00 ~ 次日 08:00
已有任务的 task_date 是 07-29 00:00 CST,落在区间外
后果不只是「新任务不显示」——ensureDayTasks 因此认为今天没有任务,
每刷一次首页就重新生成一批。用户手动加的那条(00:00 CST)永远不在
那个错位窗口里,所以管理页看得到、首页看不到。
全项目 7 处 time.Parse 日期解析统一换成 ParseInLocation + time.Local
(任务、日计划、生日、到家日期、提醒到期日都受影响)。
实测:加一条后带 date 查得到 4 条,连查 4 次仍是 4 条不再增长。
## 评论弹窗
- 改成居中弹出。评论以输入为主,贴底弹层会被键盘顶掉大半屏
- 单行 input 换成自动撑高的 textarea,最多 500 字,带字数
- 支持配图,最多 3 张(评论表加 images 字段)
- 空评论原来会直接把弹层关掉,看起来像发成功了,改成明确提示
- 发完就地刷新列表,不再关闭弹层——连着回复更顺
## AI 每日次数上限
AI 调用是真金白银,不设上限等于把钱包交给用户。新增按「用户 + 自然日
+ 功能」计的额度,后台「社区运营」页可配问问 AI / 异常评估 / AI 计划
三档,改完立即生效。
两个刻意的设计:
- 扣额度在请求大模型之前,失败也算用掉一次。否则刷接口空转照样烧钱
- 配置读不到时回落到保守默认值,绝不「读不到就不限制」——那正是配置
出问题时最不该发生的事
超限返回业务码 42900,小程序端明确说明「明天 0 点恢复」,不当成网络
错误让用户反复重试。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>admin</title>
|
||||
<script type="module" crossorigin src="/admin/assets/index-GGI_ie1V.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-BjLxu16U.css">
|
||||
<script type="module" crossorigin src="/admin/assets/index-DMg3rix-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-D4fxTaca.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -390,3 +390,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)
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func (r petReq) toInput() service.PetInput {
|
||||
AvatarFileID: r.AvatarFileID,
|
||||
}
|
||||
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()) {
|
||||
in.Birthday = &t
|
||||
@@ -42,7 +42,7 @@ func (r petReq) toInput() service.PetInput {
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ type taskReq struct {
|
||||
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 {
|
||||
if t, err := time.ParseInLocation("2006-01-02", r.Date, time.Local); err == nil {
|
||||
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}
|
||||
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"`
|
||||
}
|
||||
@@ -48,5 +48,7 @@ func AllModels() []any {
|
||||
&Feedback{},
|
||||
&Follow{},
|
||||
&RefreshToken{},
|
||||
&AIQuotaConfig{},
|
||||
&AIUsage{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
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"`
|
||||
Status string `gorm:"size:16;default:published" json:"status"`
|
||||
|
||||
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.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)
|
||||
}
|
||||
@@ -146,6 +147,9 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag
|
||||
g.PUT("/care-templates", h.AdminSaveCareTemplate)
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -138,7 +138,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 +156,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 +192,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
|
||||
|
||||
@@ -137,6 +137,12 @@ 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),
|
||||
|
||||
communityBot: () =>
|
||||
http.get<any, { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }>(
|
||||
'/admin/community-bot',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,6 +117,8 @@ Component({
|
||||
proInfo: null,
|
||||
saving: false,
|
||||
posterSaving: false,
|
||||
centered: false,
|
||||
commentImages: [],
|
||||
},
|
||||
observers: {
|
||||
show: function (show) {
|
||||
@@ -136,7 +138,8 @@ Component({
|
||||
methods: {
|
||||
setType(type, fresh) {
|
||||
const pet = store.getPet();
|
||||
const patch = { innerType: type, pet };
|
||||
// 评论以输入为主,贴底弹层会被键盘顶掉大半屏,改成居中
|
||||
const patch = { innerType: type, pet, centered: type === 'comments' };
|
||||
if (fresh) {
|
||||
patch.segSel = {};
|
||||
patch.optSel = {};
|
||||
@@ -161,6 +164,7 @@ Component({
|
||||
patch.addColor = '';
|
||||
patch.addBreed = '';
|
||||
patch.commentText = '';
|
||||
patch.commentImages = [];
|
||||
patch.riskData = null;
|
||||
patch.saving = false;
|
||||
}
|
||||
@@ -925,14 +929,35 @@ 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.setData({ commentText: '', commentImages: [] });
|
||||
this.loadComments();
|
||||
this.triggerEvent('commented');
|
||||
} catch (e) {
|
||||
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>
|
||||
|
||||
<view class="overlay {{show ? 'show' : ''}}" bindtap="onMaskTap">
|
||||
<view class="sheet" catchtap="noop">
|
||||
<view class="sheet {{centered ? 'sheet-center' : ''}}" catchtap="noop">
|
||||
<view class="sheetbar"></view>
|
||||
|
||||
<scroll-view class="sheet-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
|
||||
@@ -336,10 +336,24 @@ module.exports.sel = function (map, key, index, def) {
|
||||
<view wx:if="{{!comments.length}}" class="empty">还没有评论,来抢个沙发</view>
|
||||
<view wx:if="{{comments.length}}" class="cm-tip">长按自己的评论可删除</view>
|
||||
|
||||
<view class="cm-input">
|
||||
<input class="input" placeholder="友善交流,分享经验…" placeholder-class="placeholder"
|
||||
value="{{commentText}}" bindinput="onCommentInput" confirm-type="send" bindconfirm="onSendComment"/>
|
||||
<view class="cm-send {{commentText ? 'on' : ''}}" bindtap="onSendComment">发送</view>
|
||||
<view class="cm-editor">
|
||||
<textarea class="textarea cm-ta" placeholder="友善交流,分享你的经验…" placeholder-class="placeholder"
|
||||
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>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
|
||||
@@ -108,3 +108,20 @@
|
||||
background:var(--line);color:#fff;font-size:var(--fs-md);font-weight:var(--fw-b);transition:.16s ease;
|
||||
}
|
||||
.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
|
||||
.aiChat({ pet_id: store.currentPetId() || null, session: SESSION, text })
|
||||
.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' }),
|
||||
deleteComment: (id) => request({ url: `/api/comments/${id}`, method: 'DELETE' }),
|
||||
listComments: (id) => request({ url: `/api/posts/${id}/comments` }),
|
||||
createComment: (id, content) =>
|
||||
request({ url: `/api/posts/${id}/comments`, method: 'POST', data: { content } }),
|
||||
createComment: (id, content, images) =>
|
||||
request({ url: `/api/posts/${id}/comments`, method: 'POST', data: { content, images: images || [] } }),
|
||||
|
||||
// 文章
|
||||
listArticles: () => request({ url: '/api/articles' }),
|
||||
|
||||
Reference in New Issue
Block a user