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:
Blizzard
2026-07-29 15:50:38 +08:00
parent c7135424e5
commit 5b2f8e50bc
25 changed files with 405 additions and 91 deletions
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
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title>
<script type="module" crossorigin src="/admin/assets/index-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>
+20
View File
@@ -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)
}
+12 -3
View File
@@ -57,9 +57,9 @@ func (h *Handler) ActivatePro(c *gin.Context) {
}
type aiChatReq struct {
PetID *string `json:"pet_id"`
Session string `json:"session"`
Text string `json:"text"`
PetID *string `json:"pet_id"`
Session string `json:"session"`
Text string `json:"text"`
}
// AIChat POST /api/ai/chat
@@ -75,6 +75,10 @@ func (h *Handler) AIChat(c *gin.Context) {
}
reply, err := h.svc.AIChat(middleware.UserID(c), req.PetID, req.Session, req.Text)
if err != nil {
if errors.Is(err, service.ErrAIQuotaExceeded) {
response.Fail(c, 42900, err.Error())
return
}
response.FailErr(c, err)
return
}
@@ -188,3 +192,8 @@ func (h *Handler) ListAIMessages(c *gin.Context) {
}
response.OK(c, msgs)
}
// AIQuota GET /api/ai/quota 今日剩余次数
func (h *Handler) AIQuota(c *gin.Context) {
response.OK(c, h.svc.AIQuotaLeft(middleware.UserID(c)))
}
+2 -2
View File
@@ -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
}
}
+2 -2
View File
@@ -14,7 +14,7 @@ import (
func (h *Handler) ListTasks(c *gin.Context) {
var date *time.Time
if q := c.Query("date"); q != "" {
if t, err := time.Parse("2006-01-02", q); err == nil {
if t, err := time.ParseInLocation("2006-01-02", q, time.Local); err == nil {
date = &t
}
}
@@ -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
}
}
+2 -2
View File
@@ -36,7 +36,7 @@ func (h *Handler) CreateReminder(c *gin.Context) {
}
in := service.ReminderInput{Type: req.Type, Title: req.Title, Frequency: req.Frequency}
if req.NextDueDate != "" {
if t, err := time.Parse("2006-01-02", req.NextDueDate); err == nil {
if t, err := time.ParseInLocation("2006-01-02", req.NextDueDate, time.Local); err == nil {
in.NextDueDate = &t
}
}
@@ -66,7 +66,7 @@ func (h *Handler) UpdateReminder(c *gin.Context) {
fields["frequency"] = req.Frequency
}
if req.NextDueDate != "" {
if t, err := time.Parse("2006-01-02", req.NextDueDate); err == nil {
if t, err := time.ParseInLocation("2006-01-02", req.NextDueDate, time.Local); err == nil {
fields["next_due_date"] = t
}
}
+20
View File
@@ -0,0 +1,20 @@
package model
// AIQuotaConfig 每日 AI 次数上限(单例,固定 id=1)。
// AI 调用是真金白银,不设上限等于把钱包交给用户。
type AIQuotaConfig struct {
Base
Enabled bool `json:"enabled"` // 关掉即不限次
DailyChat int `json:"daily_chat"` // 问问 AI,每人每天
DailySymptom int `json:"daily_symptom"` // 异常观察评估
DailyPlan int `json:"daily_plan"` // AI 生成计划
}
// AIUsage 某人某天某类 AI 的用量。按天存,天然过期,不需要清理任务。
type AIUsage struct {
Base
UserID string `gorm:"size:24;uniqueIndex:idx_user_day_kind" json:"user_id"`
Day string `gorm:"size:10;uniqueIndex:idx_user_day_kind" json:"day"` // YYYY-MM-DD
Kind string `gorm:"size:16;uniqueIndex:idx_user_day_kind" json:"kind"`
Count int `json:"count"`
}
+2
View File
@@ -48,5 +48,7 @@ func AllModels() []any {
&Feedback{},
&Follow{},
&RefreshToken{},
&AIQuotaConfig{},
&AIUsage{},
}
}
+8 -5
View File
@@ -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"` // 计算字段:是不是我自己发的
}
+4
View File
@@ -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)
+4
View File
@@ -8,6 +8,10 @@ import (
// AIChat 记录一问一答:启用模型则真调(注入宠物档案),否则规则化文案
func (s *Service) AIChat(userID string, petID *string, session, text string) (string, error) {
// 先扣额度再请求大模型:失败也算用掉一次,否则刷接口空转照样烧钱
if err := s.consumeAIQuota(userID, aiKindChat); err != nil {
return "", err
}
reply := "我会先判断风险等级,再建议你记录关键观察项。若出现频繁呕吐、便血、精神明显变差或持续超过 24 小时,建议尽快就医。"
if s.ai != nil && s.ai.Enabled() {
if r, err := s.llmChat(petID, text); err == nil && r != "" {
+3
View File
@@ -68,6 +68,9 @@ type SymptomResult struct {
// AssessSymptom 异常风险评估:启用模型则结构化输出,否则规则化
func (s *Service) AssessSymptom(userID, petID string, in SymptomInput) (*SymptomResult, error) {
if err := s.consumeAIQuota(userID, aiKindSymptom); err != nil {
return nil, err
}
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
+110
View File
@@ -0,0 +1,110 @@
package service
import (
"errors"
"fmt"
"time"
"github.com/sundynix/pets-be/internal/model"
)
// ErrAIQuotaExceeded 今日 AI 次数用完
var ErrAIQuotaExceeded = errors.New("今日 AI 次数已用完")
// AI 调用是真金白银,不设上限等于把钱包交给用户。
// 额度按「用户 + 自然日」计,从后台配置读,改完立刻生效。
const (
aiKindChat = "chat" // 问问 AI
aiKindSymptom = "symptom" // 异常观察评估
aiKindPlan = "plan" // AI 生成计划
)
// aiQuotaConfig 从后台配置取每日额度。取不到就用保守默认值,
// 绝不「取不到就不限制」——那正是配置出问题时最不该发生的事。
func (s *Service) aiQuotaConfig() model.AIQuotaConfig {
var c model.AIQuotaConfig
if err := s.db.First(&c, "id = ?", "1").Error; err != nil {
return model.AIQuotaConfig{DailyChat: 20, DailySymptom: 10, DailyPlan: 5, Enabled: true}
}
return c
}
// aiDailyLimit 某类 AI 功能的每日上限;<=0 表示不限
func (s *Service) aiDailyLimit(kind string) (int, bool) {
c := s.aiQuotaConfig()
if !c.Enabled {
return 0, false
}
switch kind {
case aiKindChat:
return c.DailyChat, true
case aiKindSymptom:
return c.DailySymptom, true
case aiKindPlan:
return c.DailyPlan, true
}
return 0, false
}
// consumeAIQuota 扣一次额度。超限返回 ErrAIQuotaExceeded,调用方要在真正
// 请求大模型之前调用它——扣完再调,失败也算用掉一次,避免刷接口空转烧钱。
func (s *Service) consumeAIQuota(userID, kind string) error {
limit, on := s.aiDailyLimit(kind)
if !on || limit <= 0 {
return nil
}
day := time.Now().Format("2006-01-02")
var u model.AIUsage
err := s.db.Where("user_id = ? AND day = ? AND kind = ?", userID, day, kind).First(&u).Error
if err == nil {
if u.Count >= limit {
return fmt.Errorf("%w(每天 %d 次,明天恢复)", ErrAIQuotaExceeded, limit)
}
return s.db.Model(&model.AIUsage{}).Where("id = ?", u.ID).
UpdateColumn("count", u.Count+1).Error
}
return s.db.Create(&model.AIUsage{UserID: userID, Day: day, Kind: kind, Count: 1}).Error
}
// AIQuotaLeft 返回各类今日剩余次数,给小程序显示
func (s *Service) AIQuotaLeft(userID string) map[string]any {
c := s.aiQuotaConfig()
day := time.Now().Format("2006-01-02")
var rows []model.AIUsage
s.db.Where("user_id = ? AND day = ?", userID, day).Find(&rows)
used := map[string]int{}
for _, r := range rows {
used[r.Kind] = r.Count
}
left := func(limit int, kind string) int {
if !c.Enabled || limit <= 0 {
return -1 // -1 表示不限
}
if n := limit - used[kind]; n > 0 {
return n
}
return 0
}
return map[string]any{
"enabled": c.Enabled,
"chat": left(c.DailyChat, aiKindChat),
"symptom": left(c.DailySymptom, aiKindSymptom),
"plan": left(c.DailyPlan, aiKindPlan),
}
}
// GetAIQuotaConfig / SaveAIQuotaConfig 后台读写
func (s *Service) GetAIQuotaConfig() model.AIQuotaConfig { return s.aiQuotaConfig() }
func (s *Service) SaveAIQuotaConfig(in model.AIQuotaConfig) (model.AIQuotaConfig, error) {
c := s.aiQuotaConfig()
c.ID = "1"
c.Enabled = in.Enabled
c.DailyChat = maxInt(in.DailyChat, 0)
c.DailySymptom = maxInt(in.DailySymptom, 0)
c.DailyPlan = maxInt(in.DailyPlan, 0)
if err := s.db.Save(&c).Error; err != nil {
return c, err
}
return c, nil
}
+5 -2
View File
@@ -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
+6
View File
@@ -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',
+71 -1
View File
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
type Cfg = { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }
type Quota = { enabled: boolean; daily_chat: number; daily_symptom: number; daily_plan: number }
export default function CommunityBot() {
const [cfg, setCfg] = useState<Cfg>({ enabled: true, daily_count: 3, start_hour: 9, end_hour: 21 })
@@ -15,14 +16,38 @@ export default function CommunityBot() {
const [genN, setGenN] = useState(5)
const [gen, setGen] = useState(false)
const [msg, setMsg] = useState('')
// AI 额度和社区机器人都是「烧多少 token」的开关,放一页里管
const [q, setQ] = useState<Quota>({ enabled: true, daily_chat: 20, daily_symptom: 10, daily_plan: 5 })
const [qSaving, setQSaving] = useState(false)
const [qMsg, setQMsg] = useState('')
useEffect(() => {
api.communityBot().then((c) => {
setCfg(c)
setLoading(false)
})
api.aiQuota().then(setQ).catch(() => {})
}, [])
const qNum = (k: keyof Quota) => (e: any) => {
let v = Number(e.target.value)
if (Number.isNaN(v) || v < 0) v = 0
setQ({ ...q, [k]: Math.min(999, v) })
}
async function saveQuota() {
setQSaving(true)
setQMsg('')
try {
setQ(await api.saveAIQuota(q))
setQMsg('已保存,立即生效')
} catch (e: any) {
setQMsg(e.message || '保存失败')
} finally {
setQSaving(false)
}
}
const num = (k: keyof Cfg, min: number, max: number) => (e: any) => {
let v = Number(e.target.value)
if (Number.isNaN(v)) v = min
@@ -144,6 +169,51 @@ export default function CommunityBot() {
</Card>
</div>
)}
</div>
<Card className="mt-4">
<CardContent className="pt-6 space-y-4">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
<span className="font-medium">AI </span>
</div>
<p className="text-sm text-muted-foreground flex gap-2">
<Info className="h-4 w-4 shrink-0 mt-0.5" />
<span>
/ 0
AI
</span>
</p>
<div className="flex items-center gap-2">
<input
id="q-enabled"
type="checkbox"
checked={q.enabled}
onChange={(e) => setQ({ ...q, enabled: e.target.checked })}
/>
<Label htmlFor="q-enabled"></Label>
</div>
<div className="grid grid-cols-3 gap-4 max-w-lg">
<div className="space-y-1.5">
<Label> AI</Label>
<Input type="number" value={q.daily_chat} onChange={qNum('daily_chat')} />
</div>
<div className="space-y-1.5">
<Label></Label>
<Input type="number" value={q.daily_symptom} onChange={qNum('daily_symptom')} />
</div>
<div className="space-y-1.5">
<Label>AI </Label>
<Input type="number" value={q.daily_plan} onChange={qNum('daily_plan')} />
</div>
</div>
<div className="flex items-center gap-3">
<Button onClick={saveQuota} disabled={qSaving}>
<Save className="h-4 w-4" /> {qSaving ? '保存中…' : '保存额度'}
</Button>
{qMsg && <span className="text-sm text-muted-foreground">{qMsg}</span>}
</div>
</CardContent>
</Card>
</div>
)
}
@@ -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)}
+5 -1
View File
@@ -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);
});
},
// 逐字把回复填进最后一个气泡
+2 -2
View File
@@ -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' }),