feat(auth): access token 缩到 2 小时 + refresh token 机制 #3

Merged
Blizzard merged 58 commits from feat/dev into main 2026-07-30 10:18:07 +00:00
25 changed files with 405 additions and 91 deletions
Showing only changes of commit 5b2f8e50bc - Show all commits
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' }),