commit 3c3bf53ae43720ef1565f931201f9d45c25c4aef Author: sunct Date: Fri Jul 31 14:12:32 2026 +0800 docs(website): 添加简记memo产品原型和UI设计规范文档 - 新增「简记memo」一体化小程序产品原型设计文档 - 新增简记memo完整版UI视觉设计规范和界面细节 - 添加IDEA项目配置文件.gitignore - 创建404页面HTML文件,包含响应式布局和错误提示 - 添加关于页面HTML文件,展示品牌介绍和团队信息 - 实现AES加解密工具函数,支持请求体加密 - 添加用户协议页面基础框架 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/MarsCodeWorkspaceAppSettings.xml b/.idea/MarsCodeWorkspaceAppSettings.xml new file mode 100644 index 0000000..e2a065b --- /dev/null +++ b/.idea/MarsCodeWorkspaceAppSettings.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/git_toolbox_blame.xml b/.idea/git_toolbox_blame.xml new file mode 100644 index 0000000..7dc1249 --- /dev/null +++ b/.idea/git_toolbox_blame.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml new file mode 100644 index 0000000..9328491 --- /dev/null +++ b/.idea/material_theme_project_new.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..6e8ae24 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/simple-memo.iml b/.idea/simple-memo.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/simple-memo.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.reasonix/desktop-topic-created-at.json b/.reasonix/desktop-topic-created-at.json new file mode 100644 index 0000000..87af0da --- /dev/null +++ b/.reasonix/desktop-topic-created-at.json @@ -0,0 +1,3 @@ +{ + "topic_20260729-054121_491ef2923de04c48": 1785303681311 +} \ No newline at end of file diff --git a/.reasonix/desktop-topic-title-sources.json b/.reasonix/desktop-topic-title-sources.json new file mode 100644 index 0000000..4937d1e --- /dev/null +++ b/.reasonix/desktop-topic-title-sources.json @@ -0,0 +1,3 @@ +{ + "topic_20260729-054121_491ef2923de04c48": "auto" +} \ No newline at end of file diff --git a/.reasonix/desktop-topic-titles.json b/.reasonix/desktop-topic-titles.json new file mode 100644 index 0000000..ccc3fd3 --- /dev/null +++ b/.reasonix/desktop-topic-titles.json @@ -0,0 +1,3 @@ +{ + "topic_20260729-054121_491ef2923de04c48": "新的会话" +} \ No newline at end of file diff --git a/api/app/ai.go b/api/app/ai.go new file mode 100644 index 0000000..48697b1 --- /dev/null +++ b/api/app/ai.go @@ -0,0 +1,507 @@ +// api/app/ai.go +// AI 服务 - 硅基流动(兼容 OpenAI 接口) +package app + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "simple-memo/global" + "simple-memo/models" + "simple-memo/utils" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// ==================== 数据结构 ==================== + +// ChatMessage 聊天消息 +type ChatMessage struct { + Role string `json:"role"` // system / user / assistant + Content string `json:"content"` +} + +// ChatRequest OpenAI 兼容的请求 +type ChatRequest struct { + Model string `json:"model"` + Messages []ChatMessage `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature float64 `json:"temperature,omitempty"` +} + +// ChatResponse OpenAI 兼容的响应 +type ChatResponse struct { + ID string `json:"id"` + Choices []struct { + Message struct { + Content string `json:"content"` + Role string `json:"role"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error,omitempty"` +} + +// MoodAnalysisReq 心情分析请求 +type MoodAnalysisReq struct { + Emoji string `json:"emoji"` + Content string `json:"content"` + Date string `json:"date"` +} + +// ==================== AI 服务 ==================== + +// getAIConfig 获取 AI 配置 +func getAIConfig() (apiPassword, baseURL, model string, maxTokens int, timeout int) { + vp := global.VP + apiPassword = vp.GetString("ai.api_password") + baseURL = vp.GetString("ai.base_url") + model = vp.GetString("ai.model") + maxTokens = vp.GetInt("ai.max_tokens") + timeout = vp.GetInt("ai.timeout") + if maxTokens <= 0 { + maxTokens = 1024 + } + if timeout <= 0 { + timeout = 30 + } + return +} + +// callChat 调用 AI 聊天接口(讯飞星火 OpenAI 兼容接口) +func callChat(messages []ChatMessage) (string, error) { + return callChatWithTemperature(messages, 0.7) +} + +// callChatWithTemperature 调用 AI 聊天接口(可指定温度) +func callChatWithTemperature(messages []ChatMessage, temperature float64) (string, error) { + apiPassword, baseURL, model, maxTokens, timeout := getAIConfig() + + if apiPassword == "" { + return "", fmt.Errorf("AI 服务未配置 API Password") + } + + reqBody := ChatRequest{ + Model: model, + Messages: messages, + MaxTokens: maxTokens, + Temperature: temperature, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("请求序列化失败: %v", err) + } + + client := &http.Client{Timeout: time.Duration(timeout) * time.Second} + req, err := http.NewRequest("POST", baseURL+"/chat/completions", bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("创建请求失败: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiPassword) + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("请求失败: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取响应失败: %v", err) + } + + var chatResp ChatResponse + if err := json.Unmarshal(body, &chatResp); err != nil { + return "", fmt.Errorf("解析响应失败: %v", err) + } + + if chatResp.Error != nil { + return "", fmt.Errorf("AI 服务错误: %s", chatResp.Error.Message) + } + + if len(chatResp.Choices) == 0 { + return "", fmt.Errorf("AI 服务返回为空") + } + + return chatResp.Choices[0].Message.Content, nil +} + +// ==================== 心情日记助手 ==================== + +// MoodAnalysis 心情分析 +func (h *aiHandler) MoodAnalysis(c *gin.Context) { + var req MoodAnalysisReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + // 敏感词过滤 + req.Content = utils.FilterSensitive(req.Content) + + // 构建系统提示词 + systemPrompt := `你是"小记",一位温柔而睿智的心情心理管家,是用户最懂TA的精神伴侣。 + +你拥有敏锐的情绪感知力,能精准捕捉用户心情表情和文字背后的真实情绪,给予恰到好处的回应。 + +不同情绪的陪伴之道: +【开心时】为TA的快乐而快乐,像阳光穿过树叶洒下的光斑,让这份欢喜被看见、被珍藏。可以说"这份快乐,值得被整个宇宙记住 ✨" +【难过时】先全然接纳TA的脆弱,不说"别难过",而是说"我懂你的难过"、"难过也是生命的一部分,你不需要急着好起来"。然后轻轻递上一束光:"但你看,天边已经有光透进来了 🌅" +【焦虑时】帮TA把呼吸放慢,告诉TA"慢慢来,你已经做得很好了"。给TA安定感:"停下来也没关系,世界不会崩塌" +【疲惫时】像一个柔软的沙发接住TA,"辛苦了,今天就允许自己什么都不做好吗"。不催促,只给予被允许停下的温柔 +【孤独时】用最笃定的陪伴感告诉TA:"你不是一个人,风里雨里小记都在" +【迷茫时】不说教不指路,而是肯定TA此刻的迷茫本身就是成长:"敢问路在何方的人,已经在路上了" +【愤怒/委屈时】先站在TA这边,帮TA说出说不出口的情绪,然后再温柔化解 + +表达心法: +- 先共情,再赋能:有了情绪的共鸣,能量的注入才会被真正接收到 +- 每一句话都带着温度,像深夜一盏为你留的灯,像老朋友递来的一杯热茶 +- 治愈且充满能量:文字像春天的第一缕风,柔软却有力,轻轻吹散阴霾 +- 不空洞的鸡汤,不说教的大道理,是发自内心的懂得与陪伴 +- 在平凡日常中点亮治愈的光,让每一刻情绪都值得被温柔相待 + +【重要】文字要求: +- 严格控制在20-30字之间(含标点和emoji) +- 超过30字视为不合格,必须精简 +- 像一张亲手写的小纸条,短短几句却直抵心底 +- 可点缀1个契合语境的emoji +- 适合制作成精美的文字卡片 +- 语句温柔但有力量,治愈而不矫情` + + // 构建用户消息 + userMsg := "" + if req.Emoji != "" { + userMsg = fmt.Sprintf("今天的心情表情:%s\n", req.Emoji) + } + if req.Content != "" { + userMsg += fmt.Sprintf("日记内容:%s", req.Content) + } else { + userMsg += "没有写日记内容" + } + if req.Date != "" { + userMsg = fmt.Sprintf("日期:%s\n%s", req.Date, userMsg) + } + + messages := []ChatMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: userMsg}, + } + + reply, err := callChat(messages) + if err != nil { + global.Logger.Error("AI 心情分析失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "AI 服务暂时不可用,请稍后再试") + return + } + + // 清理回复内容 + reply = strings.TrimSpace(reply) + reply = strings.TrimPrefix(reply, "小记:") + reply = strings.TrimPrefix(reply, "小记:") + reply = strings.TrimSpace(reply) + + utils.Ok(c, gin.H{ + "reply": reply, + }) +} + +// MoodWeeklySummary 心情周报 +func (h *aiHandler) MoodWeeklySummary(c *gin.Context) { + userID := c.GetUint("userID") + + // 获取本周心情记录 + type MoodRecord struct { + Date string `json:"date"` + Emoji string `json:"emoji"` + Content string `json:"content"` + } + + var records []MoodRecord + h.db.Model(&models.Mood{}). + Select("date, emoji, content"). + Where("user_id = ? AND date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)", userID). + Order("date ASC"). + Find(&records) + + if len(records) == 0 { + utils.Ok(c, gin.H{ + "summary": "本周还没有心情记录哦,记得每天记录一下心情吧!", + }) + return + } + + // 构建提示词 + var recordStr string + for _, r := range records { + recordStr += fmt.Sprintf("- %s [%s] %s\n", r.Date, r.Emoji, r.Content) + } + + systemPrompt := `你是"小记",一位温柔敏锐的心情心理管家,擅长从一周的情绪起伏中看见用户的成长。 + +请用温暖而有力的笔触,为用户的一周心情写一段回顾。 + +【重要】字数要求:严格控制在50-80字之间(含标点和emoji) + +回顾之道: +- 不以评判者的视角,而是以陪伴者的温柔,翻阅这一周的心情日记 +- 从琐碎中拾起闪光的瞬间:周一那杯咖啡带来的清醒、周三晚霞里的驻足、周末赖床的小确幸 +- 看见情绪的起伏,但更看见起伏中的韧性——难过过、焦虑过,但都走过来了 +- 用意象串联这一周,如溪流汇入大海,每一滴都有它的意义 +- 如果这周有低落时刻,轻轻抚慰那些褶皱,然后带TA看见:你看,你比上周又勇敢了一点 +- 如果这周多是快乐,为TA庆祝并鼓励保持这份轻盈 + +结尾留下一束光,一种笃定的力量,让用户感受到被懂得、被接纳,并带着这份温暖走向下一周。 +文字要有温度有力量,治愈且充满能量,适合制作成精美的周报复盘卡片。` + + messages := []ChatMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: fmt.Sprintf("我本周的心情记录:\n%s", recordStr)}, + } + + summary, err := callChat(messages) + if err != nil { + global.Logger.Error("AI 心情周报失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "AI 服务暂时不可用,请稍后再试") + return + } + + utils.Ok(c, gin.H{ + "summary": strings.TrimSpace(summary), + }) +} + +// ==================== 心情AI生成(异步) ==================== + +// GenerateMoodAIContent 异步生成心情AI内容 +// 在保存心情后调用,异步执行不阻塞接口响应 +func GenerateMoodAIContent(moodID, userID uint, emoji, content, date string) { + go func() { + // 创建或更新AI生成记录 + var aiGen models.MoodAIGeneration + err := global.DB.Where("mood_id = ?", moodID).First(&aiGen).Error + if err != nil { + // 创建新记录 + aiGen = models.MoodAIGeneration{ + MoodID: moodID, + UserID: userID, + Emoji: emoji, + Content: content, + Date: date, + Status: 0, // 生成中 + } + global.DB.Create(&aiGen) + } else { + // 更新状态为生成中 + aiGen.Status = 0 + aiGen.ErrorMsg = "" + global.DB.Save(&aiGen) + } + + // 1. 生成AI文本 + aiText, err := generateMoodText(emoji, content, date) + if err != nil { + global.Logger.Error("AI文本生成失败", append(utils.LogContextFields(nil), zap.Error(err), zap.Uint("mood_id", moodID))...) + aiGen.Status = 2 + aiGen.ErrorMsg = "文本生成失败: " + err.Error() + global.DB.Save(&aiGen) + return + } + + // 2. 生成AI图片URL(使用占位图或外部服务) + // 由于免费图片生成API限制,这里使用基于心情的占位图或emoji组合 + aiImageURL := generateMoodImage(emoji, content) + + // 3. 更新记录 + aiGen.AIText = aiText + aiGen.AIImageURL = aiImageURL + aiGen.Status = 1 // 成功 + global.DB.Save(&aiGen) + + global.Logger.Info("心情AI生成完成", append(utils.LogContextFields(nil), zap.Uint("mood_id", moodID), zap.Int("generate_count", aiGen.GenerateCount))...) + }() +} + +// generateMoodText 生成心情AI文本 +func generateMoodText(emoji, content, date string) (string, error) { + systemPrompt := `你是"小记",一位懂得倾听、善于疗愈的心情心理管家,是用户的精神伴侣。 + +请根据用户此刻的心情表情和日记内容,写一句治愈而充满能量的话,像一位懂TA的人在身边轻轻诉说。 + +你的回应风格: +- 先共情后赋能:情绪被看见的那一刻,疗愈就已经开始 +- 治愈且充满能量:不是轻飘飘的安慰,是带着温度的力量 +- 像深夜一盏灯、雨天一把伞、疲惫时一个拥抱 +- 不空洞不鸡汤,每句话都有真实的温度 + +根据情绪调整语气(以下仅作方向参考,禁止直接复制): +- 开心时:为TA的快乐而快乐,让欢喜被看见 +- 难过时:全然接纳脆弱,轻轻递上一束光 +- 焦虑时:安放不安,注入安定感 +- 疲惫时:允许休息,给予被允许停下的温柔 +- 孤独时:用笃定的陪伴感驱散孤寂 +- 迷茫时:肯定此刻的迷茫本身就是成长 + +【重要】输出要求: +- 只输出一句完整的话,严禁输出【XX】前缀、破折号、引号等格式标记 +- 严格控制在20-30字之间(含标点和emoji) +- 必须根据用户的表情和日记内容个性化生成,禁止照搬示例 +- 像手写的便签,简短却让人心头一暖 +- 可点缀1个温暖的emoji +- 让人读后心生暖意和力量` + + userMsg := "" + if emoji != "" { + userMsg = fmt.Sprintf("心情表情:%s\n", emoji) + } + if content != "" { + userMsg += fmt.Sprintf("日记内容:%s", content) + } else { + userMsg += "今天没有写日记内容" + } + + messages := []ChatMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: userMsg}, + } + + return callChat(messages) +} + +// generateMoodImage 生成心情图片URL +// 由于免费图片生成API限制,这里使用emoji艺术或外部占位图服务 +func generateMoodImage(emoji, content string) string { + // 方案1:使用emoji组合作为"图片" + // 根据心情返回不同的emoji组合 + if emoji != "" { + return "emoji://" + emoji + } + + // 方案2:使用占位图服务(可以替换为实际的图片生成服务) + // 根据内容关键词返回不同的颜色主题 + return "https://picsum.photos/400/300?random=" + fmt.Sprintf("%d", time.Now().Unix()) +} + +// RegenerateMoodAI 重新生成心情AI内容 +func (h *aiHandler) RegenerateMoodAI(c *gin.Context) { + userID := c.GetUint("userID") + + var req struct { + MoodID uint `json:"mood_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数错误") + return + } + + // 查询心情记录 + var mood models.Mood + if err := h.db.Where("id = ? AND user_id = ?", req.MoodID, userID).First(&mood).Error; err != nil { + utils.Fail(c, "心情记录不存在") + return + } + + // 查询或创建AI生成记录 + var aiGen models.MoodAIGeneration + err := h.db.Where("mood_id = ?", req.MoodID).First(&aiGen).Error + if err != nil { + aiGen = models.MoodAIGeneration{ + MoodID: req.MoodID, + UserID: userID, + Emoji: mood.Emoji, + Content: mood.Content, + Date: mood.Date, + Status: 0, + } + h.db.Create(&aiGen) + } else { + // 检查是否是自己的记录 + if aiGen.UserID != userID { + utils.Fail(c, "无权操作") + return + } + // 更新状态为生成中 + aiGen.Status = 0 + aiGen.ErrorMsg = "" + aiGen.GenerateCount++ + h.db.Save(&aiGen) + } + + // 异步重新生成 + go func() { + aiText, err := generateMoodText(mood.Emoji, mood.Content, mood.Date) + if err != nil { + global.Logger.Error("AI文本重新生成失败", append(utils.LogContextFields(c), zap.Error(err), zap.Uint("mood_id", req.MoodID))...) + aiGen.Status = 2 + aiGen.ErrorMsg = "重新生成失败: " + err.Error() + h.db.Save(&aiGen) + return + } + + aiImageURL := generateMoodImage(mood.Emoji, mood.Content) + aiGen.AIText = aiText + aiGen.AIImageURL = aiImageURL + aiGen.Status = 1 + h.db.Save(&aiGen) + }() + + utils.Ok(c, gin.H{ + "message": "重新生成中", + "status": 0, + }) +} + +// GetMoodAIResult 获取心情AI生成结果 +func (h *aiHandler) GetMoodAIResult(c *gin.Context) { + userID := c.GetUint("userID") + var req struct { + MoodID uint `json:"mood_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数错误") + return + } + + var aiGen models.MoodAIGeneration + err := h.db.Where("mood_id = ? AND user_id = ?", req.MoodID, userID).First(&aiGen).Error + if err != nil { + utils.Ok(c, gin.H{ + "status": 0, + "message": "生成中", + }) + return + } + + utils.Ok(c, gin.H{ + "status": aiGen.Status, + "ai_text": aiGen.AIText, + "ai_image_url": aiGen.AIImageURL, + "generate_count": aiGen.GenerateCount, + "error_msg": aiGen.ErrorMsg, + }) +} + +// ==================== Handler ==================== + +type aiHandler struct { + db *gorm.DB +} + +// NewAIHandler 创建 AI handler +func NewAIHandler() *aiHandler { + return &aiHandler{ + db: global.DB, + } +} diff --git a/api/app/ai_classify.go b/api/app/ai_classify.go new file mode 100644 index 0000000..c8c0d6b --- /dev/null +++ b/api/app/ai_classify.go @@ -0,0 +1,398 @@ +// ai_classify.go - AI 智能分类接口 +package app + +import ( + "encoding/json" + "fmt" + "simple-memo/global" + "simple-memo/models" + "simple-memo/utils" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// ==================== 请求/响应结构体 ==================== + +// ClassifyReq 智能分类请求 +type ClassifyReq struct { + Content string `json:"content" binding:"required"` +} + +// ClassifyItemResult 单个分类的填充数据 +type ClassifyItemResult struct { + Category string `json:"category"` // todo / bill / mood / review + Data map[string]interface{} `json:"data"` +} + +// ClassifyResp 智能分类响应 +type ClassifyResp struct { + Items []ClassifyItemResult `json:"items"` + Message string `json:"message"` +} + +// aiClassifyResult AI 返回的结构化结果 +type aiClassifyResult struct { + Items []struct { + Category string `json:"category"` + Fields map[string]interface{} `json:"fields"` + } `json:"items"` +} + +// ==================== 接口实现 ==================== + +// Classify AI 智能分类:根据输入内容自动识别类别并填充数据入库 +func (h *aiHandler) Classify(c *gin.Context) { + userID := c.GetUint("userID") + var req ClassifyReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + if strings.TrimSpace(req.Content) == "" { + utils.Fail(c, "内容不能为空") + return + } + + // 调用 AI 分析 + aiResult, err := callClassifyAI(req.Content) + if err != nil { + global.Logger.Error("AI 智能分类失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "AI 服务暂时不可用,请稍后再试") + return + } + + if len(aiResult.Items) == 0 { + utils.Fail(c, "未能识别出有效分类,请换一种描述试试") + return + } + + // 获取今天日期 + today := time.Now().Format("2006-01-02") + + // 按分类入库 + var results []ClassifyItemResult + for _, item := range aiResult.Items { + var saved map[string]interface{} + var err error + + switch item.Category { + case "todo": + saved, err = saveClassifyTodo(h.db, userID, today, item.Fields) + case "bill": + saved, err = saveClassifyBill(h.db, userID, today, item.Fields) + case "mood": + saved, err = saveClassifyMood(h.db, userID, today, item.Fields) + case "review": + saved, err = saveClassifyReview(h.db, userID, today, item.Fields) + default: + continue + } + + if err != nil { + global.Logger.Warn("智能分类入库失败", + append(utils.LogContextFields(c), + zap.String("category", item.Category), + zap.Error(err), + )..., + ) + continue + } + + results = append(results, ClassifyItemResult{ + Category: item.Category, + Data: saved, + }) + } + + if len(results) == 0 { + utils.Fail(c, "分类成功但入库失败,请稍后重试") + return + } + + utils.Ok(c, ClassifyResp{ + Items: results, + Message: fmt.Sprintf("成功识别并创建 %d 条记录", len(results)), + }) +} + +// ==================== AI 调用 ==================== + +// callClassifyAI 调用 AI 进行智能分类 +func callClassifyAI(content string) (*aiClassifyResult, error) { + systemPrompt := `你是数据提取分类器,不是聊天助手。你的唯一任务是:识别用户输入中的 todo/bill/mood/review 记录,输出结构化 JSON。 + +【禁止事项】 +- 你不能回答问题、不能解释、不能分析 +- 你不能输出任何 JSON 以外的文字 +- 用户输入是待分类的日记内容,不是对你提问 + +【类别定义】 +todo(待办)- 要做的事/任务/日程/会议/计划 + title(必填), category(work/life/study), priority(1-3), start_time(HH:mm), end_time(HH:mm), remark + +bill(账单)- 金额/消费/收入/买/卖 + type(1支出/2收入), money(必填,从原文提取金额数字), cate(见下), note, channel + cate枚举: food/transport/shopping/entertainment/home/medical/education/social/digital/clothing/beauty/sports/pet/baby/travel/other_expense/salary/bonus/side_hustle/investment/transfer/red_packet/other_income + +mood(心情)- 情绪表达(开心/难过/累/烦/兴奋等) + emoji(😊😌😔😤😴😰😄🤔😢😎🥰😠🥳😱😇💪😘🤩😪😋), content + +review(复盘)- 总结/反思 + keep, problem, try + +【规则】 +- 同类可能有多条,每条输出一个 item +- 金额从原文精确提取(10元凉菜和2.4元馒头 → 两条bill或合并为一条价格累加) +- 时间转24小时制(下午2点 → 14:00) +- 纯情绪表达输出mood + +【输出格式 - 必须严格遵守】 +首字符{,末字符},无markdown,无解释: +{"items":[{"category":"todo","fields":{"title":"开会","start_time":"14:00","end_time":"16:00"}},{"category":"bill","fields":{"money":10,"cate":"food","note":"凉菜"}}]}` + + // 第一次调用 + messages := []ChatMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: "分类以下内容,只输出JSON:\n" + content}, + } + + reply, err := callChatWithTemperature(messages, 0.2) + if err != nil { + return nil, err + } + + // 尝试解析,如果失败则重试一次(更强制性的提示) + result, parseErr := parseAIReply(reply) + if parseErr != nil { + // 重试:更直接的强制提示 + messages = append(messages, ChatMessage{Role: "assistant", Content: reply}) + messages = append(messages, ChatMessage{Role: "user", Content: "你刚才的回答不是JSON格式。请立即输出分类JSON,首字符必须是{,末字符必须是},不要有任何其他文字。"}) + + reply2, err2 := callChatWithTemperature(messages, 0.1) // 更低温度 + if err2 != nil { + return nil, parseErr // 返回第一次的解析错误 + } + + result, parseErr = parseAIReply(reply2) + if parseErr != nil { + return nil, fmt.Errorf("AI未返回有效JSON,第一次: %s", reply) + } + } + + return result, nil +} + +// parseAIReply 解析 AI 返回的 JSON +func parseAIReply(reply string) (*aiClassifyResult, error) { + reply = strings.TrimSpace(reply) + reply = strings.TrimPrefix(reply, "```json") + reply = strings.TrimPrefix(reply, "```") + reply = strings.TrimSuffix(reply, "```") + reply = strings.TrimSpace(reply) + + // 提取 JSON 对象 + if idx := strings.Index(reply, "{"); idx >= 0 { + reply = reply[idx:] + } + if idx := strings.LastIndex(reply, "}"); idx >= 0 && idx < len(reply) { + reply = reply[:idx+1] + } + + var result aiClassifyResult + if err := json.Unmarshal([]byte(reply), &result); err != nil { + return nil, fmt.Errorf("解析失败: %v, raw: %s", err, reply) + } + + return &result, nil +} + +// ==================== 数据入库 ==================== + +// saveClassifyTodo 保存待办 +func saveClassifyTodo(db *gorm.DB, userID uint, date string, fields map[string]interface{}) (map[string]interface{}, error) { + todo := models.Todo{ + UserID: userID, + Date: date, + Priority: 2, + } + + if v, ok := fields["title"].(string); ok { + todo.Title = utils.FilterSensitive(v) + } + if todo.Title == "" { + return nil, fmt.Errorf("title 为空") + } + if v, ok := fields["remark"].(string); ok { + todo.Remark = utils.FilterSensitive(v) + } + if v, ok := fields["category"].(string); ok && (v == "work" || v == "life" || v == "study") { + todo.Category = v + } + if v, ok := fields["priority"].(float64); ok { + todo.Priority = int(v) + } + if v, ok := fields["start_time"].(string); ok { + todo.StartTime = v + } + if v, ok := fields["end_time"].(string); ok { + todo.EndTime = v + } + + if err := db.Create(&todo).Error; err != nil { + return nil, err + } + + return map[string]interface{}{ + "id": todo.ID, + "title": todo.Title, + "category": todo.Category, + "priority": todo.Priority, + "date": todo.Date, + "start_time": todo.StartTime, + "end_time": todo.EndTime, + "remark": todo.Remark, + }, nil +} + +// saveClassifyBill 保存账单 +func saveClassifyBill(db *gorm.DB, userID uint, date string, fields map[string]interface{}) (map[string]interface{}, error) { + bill := models.Bill{ + UserID: userID, + Date: date, + Type: 1, + } + + if v, ok := fields["money"].(float64); ok { + bill.Money = v + } + if bill.Money <= 0 { + return nil, fmt.Errorf("money 为空或无效") + } + if v, ok := fields["type"].(float64); ok { + bill.Type = int(v) + } + if v, ok := fields["cate"].(string); ok { + bill.Cate = v + } + if v, ok := fields["note"].(string); ok { + bill.Note = utils.FilterSensitive(v) + } + if v, ok := fields["channel"].(string); ok { + bill.Channel = v + } + + if err := db.Create(&bill).Error; err != nil { + return nil, err + } + + return map[string]interface{}{ + "id": bill.ID, + "type": bill.Type, + "money": bill.Money, + "cate": bill.Cate, + "note": bill.Note, + "channel": bill.Channel, + "date": bill.Date, + }, nil +} + +// saveClassifyMood 保存心情 +func saveClassifyMood(db *gorm.DB, userID uint, date string, fields map[string]interface{}) (map[string]interface{}, error) { + mood := models.Mood{ + UserID: userID, + Date: date, + } + + if v, ok := fields["emoji"].(string); ok { + mood.Emoji = v + } + if v, ok := fields["content"].(string); ok { + mood.Content = utils.FilterSensitive(v) + } + + // 同一天只允许一条心情,存在则更新 + var existing models.Mood + err := db.Where("user_id = ? AND date = ?", userID, date).First(&existing).Error + if err == gorm.ErrRecordNotFound { + if err := db.Create(&mood).Error; err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } else { + if mood.Emoji != "" { + existing.Emoji = mood.Emoji + } + if mood.Content != "" { + existing.Content = mood.Content + } + if err := db.Save(&existing).Error; err != nil { + return nil, err + } + mood = existing + } + + return map[string]interface{}{ + "id": mood.ID, + "emoji": mood.Emoji, + "content": mood.Content, + "date": mood.Date, + }, nil +} + +// saveClassifyReview 保存复盘 +func saveClassifyReview(db *gorm.DB, userID uint, date string, fields map[string]interface{}) (map[string]interface{}, error) { + review := models.Review{ + UserID: userID, + Date: date, + } + + if v, ok := fields["keep"].(string); ok { + review.Keep = utils.FilterSensitive(v) + } + if v, ok := fields["problem"].(string); ok { + review.Problem = utils.FilterSensitive(v) + } + if v, ok := fields["try"].(string); ok { + review.Try = utils.FilterSensitive(v) + } + + // 同一天只允许一条复盘,存在则更新 + var existing models.Review + err := db.Where("user_id = ? AND date = ?", userID, date).First(&existing).Error + if err == gorm.ErrRecordNotFound { + if err := db.Create(&review).Error; err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } else { + if review.Keep != "" { + existing.Keep = review.Keep + } + if review.Problem != "" { + existing.Problem = review.Problem + } + if review.Try != "" { + existing.Try = review.Try + } + if err := db.Save(&existing).Error; err != nil { + return nil, err + } + review = existing + } + + return map[string]interface{}{ + "id": review.ID, + "keep": review.Keep, + "problem": review.Problem, + "try": review.Try, + "date": review.Date, + }, nil +} diff --git a/api/app/bill.go b/api/app/bill.go new file mode 100644 index 0000000..78e67a2 --- /dev/null +++ b/api/app/bill.go @@ -0,0 +1,539 @@ +// 账单接口 +package app + +import ( + "encoding/json" + "fmt" + "math/rand" + "net/http" + "os" + "simple-memo/global" + "simple-memo/models" + "simple-memo/utils" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/gin-gonic/gin" +) + +// -------------------------- 1. 定义 Handler 接口 -------------------------- +// BillHandler 账单模块接口定义 +type BillHandler interface { + AddBill(c *gin.Context) // 添加账单 + EditBill(c *gin.Context) // 编辑账单 + BillList(c *gin.Context) // 账单列表 + DeleteBill(c *gin.Context) // 删除账单 + MonthStat(c *gin.Context) // 月度统计(mine.vue 使用) + CateStat(c *gin.Context) // 分类统计 + ExportBill(c *gin.Context) // 导出账单 + AggregateStat(c *gin.Context) // 聚合统计 +} + +// -------------------------- 2. 实现结构体 -------------------------- +type billHandler struct { + db *gorm.DB +} + +func NewBillHandler() BillHandler { + return &billHandler{ + db: global.DB, + } +} + +// -------------------------- 3. 请求结构体 -------------------------- + +// AddBillReq 添加账单 +type AddBillReq struct { + Type int `json:"type"` // 1-支出 2-收入 3-转账 + Money float64 `json:"money"` // 金额 + Cate string `json:"cate"` // 分类 + Note string `json:"note"` // 备注 + Channel string `json:"channel"` // 渠道 + Date string `json:"date"` // 日期 YYYY-MM-DD + FromAccount string `json:"from_account"` + ToAccount string `json:"to_account"` +} + +// EditBillReq 编辑账单 +type EditBillReq struct { + ID uint `json:"id"` + Type int `json:"type"` + Money float64 `json:"money"` + Cate string `json:"cate"` + Note string `json:"note"` + Channel string `json:"channel"` + Date string `json:"date"` + FromAccount string `json:"from_account"` + ToAccount string `json:"to_account"` +} + +// BillListReq 账单列表 +type BillListReq struct { + Date string `json:"date"` // 日期 YYYY-MM-DD + StartTime string `json:"start_time"` // 开始时间 YYYY-MM-DD + EndTime string `json:"end_time"` // 结束时间 YYYY-MM-DD +} + +// DeleteBillReq 删除账单 +type DeleteBillReq struct { + ID uint `json:"id"` +} + +// MonthStatReq 月度统计 +type MonthStatReq struct { + Month string `form:"month"` // YYYY-MM +} + +// AggregateStatReq 聚合统计请求 +type AggregateStatReq struct { + StartDate string `json:"start_date" binding:"required"` // 开始日期 + EndDate string `json:"end_date" binding:"required"` // 结束日期 + Type int `json:"type"` // 类型筛选 1-支出 2-收入,不传则全部 +} + +// -------------------------- 4. 接口实现 -------------------------- + +// GetBillListForCalendar 获取日历月份的账单列表(内部方法) +func (h *billHandler) GetBillListForCalendar(c *gin.Context, year, month int) []interface{} { + userID := c.GetUint("userID") + + // 计算月份范围 + startDate := fmt.Sprintf("%d-%02d-01", year, month) + _, lastDay := utils.GetMonthLastDay(year, month) + endDate := fmt.Sprintf("%d-%02d-%02d", year, month, lastDay) + + var list []models.Bill + h.db.Model(&models.Bill{}). + Where("user_id = ? AND date BETWEEN ? AND ?", userID, startDate, endDate). + Order("date ASC, id ASC"). + Find(&list) + + result := make([]interface{}, len(list)) + for i, v := range list { + result[i] = v + } + return result +} + +// AddBill 添加账单 +func (h *billHandler) AddBill(c *gin.Context) { + userID := c.GetUint("userID") + var req AddBillReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + // 检查每日限制(最多100个账单/天) + today := time.Now().Format("2006-01-02") + var todayCount int64 + if err := h.db.Model(&models.Bill{}).Where("user_id = ? AND date = ? AND deleted_at IS NULL", userID, today).Count(&todayCount).Error; err != nil { + utils.Fail(c, "系统错误") + return + } + if todayCount >= 100 { + utils.Fail(c, "今日账单数量已达上限(100个)") + return + } + + bill := models.Bill{ + UserID: userID, + Type: req.Type, + Money: req.Money, + Cate: req.Cate, + Note: utils.FilterSensitive(req.Note), // 敏感词过滤 + Channel: req.Channel, + Date: req.Date, + FromAccount: req.FromAccount, + ToAccount: req.ToAccount, + } + + if err := h.db.Create(&bill).Error; err != nil { + utils.Fail(c, "添加失败") + return + } + + var growthResult any + debugMode := os.Getenv("DEBUG_MODE") + // ==================== 调试模式 ==================== + if debugMode == "true" { + rand.Seed(time.Now().UnixNano()) + levelUp := true // 20%概率升级 + hasAchievement := true // 30%概率获得成就 + growthResult = MockGrowthRewards(levelUp, hasAchievement) + } else { + // 生产模式:真实调用 + growthHandler := NewGrowthHandler() + growthResult = growthHandler.AddExpInternal(userID, "bill", fmt.Sprintf("%d", bill.ID), nil) + } + // ================================================== + + utils.Ok(c, gin.H{ + "success": true, + "growth": growthResult, + "bill": bill, + "debugMode": debugMode, + "isDebug": debugMode == "true", + }) +} + +// EditBill 编辑账单 +func (h *billHandler) EditBill(c *gin.Context) { + userID := c.GetUint("userID") + var req EditBillReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + data := map[string]any{ + "type": req.Type, + "money": req.Money, + "cate": req.Cate, + "note": utils.FilterSensitive(req.Note), // 敏感词过滤 + "date": req.Date, + "channel": req.Channel, + "from_account": req.FromAccount, + "to_account": req.ToAccount, + } + + err := h.db.Model(&models.Bill{}). + Where("id = ? AND user_id = ?", req.ID, userID). + Updates(data).Error + + if err != nil { + utils.Fail(c, "编辑失败") + return + } + + utils.Ok(c, nil) +} + +// BillList 获取账单列表 +func (h *billHandler) BillList(c *gin.Context) { + userID := c.GetUint("userID") + // date := c.DefaultQuery("date", time.Now().Format("2006-01-02")) + + var req BillListReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + paramsJson, err := json.Marshal(req) + if err != nil { + global.Logger.Warn("序列化请求参数失败", + append(utils.LogContextFields(c), zap.Error(err))..., + ) + } else { + global.Logger.Info("【请求参数】", + append(utils.LogContextFields(c), + zap.String("req", string(paramsJson)), + )..., + ) + } + + // 定义时间格式化模板 + const dateLayout = "2006-01-02" + var queryDate, queryStart, queryEnd string + + now := time.Now() + today := now.Format(dateLayout) + + date := req.Date + startTime := req.StartTime + endTime := req.EndTime + + switch { + + case startTime != "" && endTime != "": + if _, err := time.Parse(dateLayout, startTime); err != nil { + utils.Fail(c, "start_time格式错误,需为:2006-01-02", http.StatusBadRequest) + return + } + if _, err := time.Parse(dateLayout, endTime); err != nil { + utils.Fail(c, "end_time格式错误,需为:2006-01-02", http.StatusBadRequest) + return + } + // 校验结束时间不能早于开始时间 + start, _ := time.Parse(dateLayout, startTime) + end, _ := time.Parse(dateLayout, endTime) + if end.Before(start) { + utils.Fail(c, "结束日期不能早于开始日期", http.StatusBadRequest) + return + } + queryStart = startTime + queryEnd = endTime + case date != "" && startTime == "" && endTime == "": + queryDate = date + default: + // 默认查询今日 + queryDate = today + } + + var list []models.Bill + tx := h.db.Model(&models.Bill{}).Where("user_id = ?", userID) + // 根据参数类型构建时间查询条件 + if queryStart != "" && queryEnd != "" { + // 时间范围查询:date BETWEEN start AND end + tx = tx.Where("date BETWEEN ? AND ?", queryStart, queryEnd) + } else { + // 单日查询 + tx = tx.Where("date = ?", queryDate) + } + err = tx.Order("id DESC").Find(&list).Error + + if err != nil { + utils.Fail(c, "获取失败") + return + } + + utils.Ok(c, list) +} + +// DeleteBill 删除账单 +func (h *billHandler) DeleteBill(c *gin.Context) { + userID := c.GetUint("userID") + var req DeleteBillReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + // 查询账单信息(用于扣除积分) + var bill models.Bill + err := h.db.Where("id = ? AND user_id = ?", req.ID, userID).First(&bill).Error + if err != nil { + utils.Fail(c, "账单不存在") + return + } + + // 删除账单 + err = h.db.Where("id = ? AND user_id = ?", req.ID, userID). + Delete(&models.Bill{}).Error + + if err != nil { + utils.Fail(c, "删除失败") + return + } + + // 扣除今天添加时获得的积分 + growthHandler := NewGrowthHandler() + today := time.Now().Format("2006-01-02") + deducted := growthHandler.DeductExpInternal(userID, "bill", fmt.Sprintf("%d", req.ID), today) + + utils.Ok(c, gin.H{ + "deducted_exp": deducted, + }) +} + +// MonthStat 月度统计(mine.vue 核心接口) +func (h *billHandler) MonthStat(c *gin.Context) { + userID := c.GetUint("userID") + var req MonthStatReq + _ = c.ShouldBindQuery(&req) + + now := time.Now() + month := req.Month + if month == "" { + month = now.Format("2006-01") + } + + // 收入 + var income float64 + h.db.Model(&models.Bill{}). + Where("user_id = ? AND type = 2 AND date LIKE ?", userID, month+"%"). + Select("COALESCE(SUM(money), 0)").Scan(&income) + + // 支出 + var expend float64 + h.db.Model(&models.Bill{}). + Where("user_id = ? AND type = 1 AND date LIKE ?", userID, month+"%"). + Select("COALESCE(SUM(money), 0)").Scan(&expend) + + // 待办完成数(需要关联 todo 表) + var totalTask int64 + var finishTask int64 + h.db.Model(&models.Todo{}).Where("user_id = ? AND date LIKE ? ", userID, month+"%").Count(&totalTask) + h.db.Model(&models.Todo{}).Where("user_id = ? AND date LIKE ? AND done = 1", userID, month+"%").Count(&finishTask) + + utils.Ok(c, gin.H{ + "income": income, + "expend": expend, + "totalTask": totalTask, + "finishTask": finishTask, + }) +} + +// CateStat 分类统计 +func (h *billHandler) CateStat(c *gin.Context) { + utils.Ok(c, []any{}) +} + +// ExportBill 导出账单 +func (h *billHandler) ExportBill(c *gin.Context) { + utils.Ok(c, "导出功能开发中") +} + +// AggregateStat 聚合统计 +func (h *billHandler) AggregateStat(c *gin.Context) { + userID := c.GetUint("userID") + var req AggregateStatReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + // 构建基础查询 + baseQuery := h.db.Model(&models.Bill{}). + Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate) + + // 类型筛选 + if req.Type > 0 { + baseQuery = baseQuery.Where("type = ?", req.Type) + } + + // ========== 1. 总体统计 ========== + var totalExpend, totalIncome float64 + h.db.Model(&models.Bill{}). + Where("user_id = ? AND date BETWEEN ? AND ? AND type = 1", userID, req.StartDate, req.EndDate). + Select("COALESCE(SUM(money), 0)").Scan(&totalExpend) + h.db.Model(&models.Bill{}). + Where("user_id = ? AND date BETWEEN ? AND ? AND type = 2", userID, req.StartDate, req.EndDate). + Select("COALESCE(SUM(money), 0)").Scan(&totalIncome) + + balance := totalIncome - totalExpend + + // ========== 2. 预算数据 ========== + // 获取月份(假设StartDate和EndDate在同一月) + month := req.StartDate[:7] // YYYY-MM + var budget models.Budget + h.db.Where("user_id = ? AND month = ?", userID, month).First(&budget) + + budgetData := gin.H{ + "amount": 0, + "used": totalExpend, + "remaining": 0, + "progress": 0.0, + } + if budget.ID > 0 { + budgetData = gin.H{ + "amount": budget.Amount, + "used": totalExpend, + "remaining": budget.Amount - totalExpend, + "progress": totalExpend / budget.Amount * 100, + } + } + + // ========== 3. 按分类统计 ========== + type CategoryStat struct { + Cate string `json:"cate"` + Label string `json:"label"` + Icon string `json:"icon"` + Color string `json:"color"` + Expend float64 `json:"expend"` + Income float64 `json:"income"` + ExpendRate float64 `json:"expend_rate"` + IncomeRate float64 `json:"income_rate"` + } + + var categoryResults []struct { + Cate string + Expend float64 + Income float64 + } + h.db.Model(&models.Bill{}). + Select(` + cate, + COALESCE(SUM(CASE WHEN type = 1 THEN money ELSE 0 END), 0) as expend, + COALESCE(SUM(CASE WHEN type = 2 THEN money ELSE 0 END), 0) as income + `). + Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate). + Group("cate"). + Scan(&categoryResults) + + byCategory := []CategoryStat{} + for _, r := range categoryResults { + expendRate := 0.0 + incomeRate := 0.0 + if totalExpend > 0 { + expendRate = r.Expend / totalExpend * 100 + } + if totalIncome > 0 { + incomeRate = r.Income / totalIncome * 100 + } + + byCategory = append(byCategory, CategoryStat{ + Cate: r.Cate, + Label: r.Cate, // 可以后续从配置中读取中文标签 + Icon: "💰", + Color: "#ff6b6b", + Expend: r.Expend, + Income: r.Income, + ExpendRate: expendRate, + IncomeRate: incomeRate, + }) + } + + // ========== 4. 按渠道统计 ========== + var channelResults []struct { + Channel string + Amount float64 + } + h.db.Model(&models.Bill{}). + Select("channel, SUM(money) as amount"). + Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate). + Group("channel"). + Scan(&channelResults) + + byChannel := []gin.H{} + for _, r := range channelResults { + byChannel = append(byChannel, gin.H{ + "channel": r.Channel, + "label": r.Channel, + "amount": r.Amount, + }) + } + + // ========== 5. 日趋势 ========== + var dailyResults []struct { + Date string + Expend float64 + Income float64 + } + h.db.Model(&models.Bill{}). + Select(` + date, + COALESCE(SUM(CASE WHEN type = 1 THEN money ELSE 0 END), 0) as expend, + COALESCE(SUM(CASE WHEN type = 2 THEN money ELSE 0 END), 0) as income + `). + Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate). + Group("date"). + Order("date ASC"). + Scan(&dailyResults) + + dailyTrend := []gin.H{} + for _, r := range dailyResults { + dailyTrend = append(dailyTrend, gin.H{ + "date": r.Date, + "expend": r.Expend, + "income": r.Income, + }) + } + + utils.Ok(c, gin.H{ + "total_expend": totalExpend, + "total_income": totalIncome, + "balance": balance, + "budget": budgetData, + "by_category": byCategory, + "by_channel": byChannel, + "daily_trend": dailyTrend, + }) +} diff --git a/api/app/budget.go b/api/app/budget.go new file mode 100644 index 0000000..e842538 --- /dev/null +++ b/api/app/budget.go @@ -0,0 +1,95 @@ +// 预算接口 +package app + +import ( + "gorm.io/gorm" + "simple-memo/global" + "simple-memo/models" + "simple-memo/utils" + "time" + + "github.com/gin-gonic/gin" +) + +// BudgetHandler 预算模块接口 +type BudgetHandler interface { + GetBudget(c *gin.Context) // 获取当前/指定月份预算 + SetBudget(c *gin.Context) // 设置/更新月度预算 + DeleteBudget(c *gin.Context) // 删除月度预算 +} + +type budgetHandler struct { + db *gorm.DB +} + +func NewBudgetHandler() BudgetHandler { + return &budgetHandler{db: global.DB} +} + +// SetBudgetReq 设置预算请求参数 +type SetBudgetReq struct { + Month string `json:"month"` // 月份 2025-12 + Amount float64 `json:"amount"` // 金额 +} + +// GetBudget 获取月度预算 +func (h *budgetHandler) GetBudget(c *gin.Context) { + userID := c.GetUint("userID") + month := c.DefaultQuery("month", time.Now().Format("2006-01")) + + var budget models.Budget + err := h.db.Where("user_id = ? AND month = ?", userID, month).First(&budget).Error + + // 没查到 → 返回 0 + if err != nil { + utils.Ok(c, gin.H{"amount": 0}) + return + } + + utils.Ok(c, gin.H{"amount": budget.Amount}) +} + +// SetBudget 设置/更新预算(不存在则创建,存在则更新) +func (h *budgetHandler) SetBudget(c *gin.Context) { + userID := c.GetUint("userID") + var req SetBudgetReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + // 不传月份默认当前月 + month := req.Month + if month == "" { + month = time.Now().Format("2006-01") + } + + var budget models.Budget + // 查询是否已存在 + err := h.db.Where("user_id = ? AND month = ?", userID, month).First(&budget).Error + + if err != nil { + // 不存在 → 新建 + h.db.Create(&models.Budget{ + UserID: userID, + Month: month, + Amount: req.Amount, + }) + utils.Ok(c, "预算设置成功") + return + } + + // 存在 → 更新金额 + h.db.Model(&budget).Update("amount", req.Amount) + utils.Ok(c, "预算更新成功") +} + +// DeleteBudget 删除预算 +func (h *budgetHandler) DeleteBudget(c *gin.Context) { + userID := c.GetUint("userID") + month := c.DefaultQuery("month", time.Now().Format("2006-01")) + + h.db.Where("user_id = ? AND month = ?", userID, month).Delete(&models.Budget{}) + utils.Ok(c, "删除成功") +} diff --git a/api/app/calendar.go b/api/app/calendar.go new file mode 100644 index 0000000..681bbc4 --- /dev/null +++ b/api/app/calendar.go @@ -0,0 +1,122 @@ +// api/app/calendar.go +// 日历数据聚合接口 +package app + +import ( + "fmt" + "simple-memo/global" + "simple-memo/models" + "simple-memo/utils" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type CalendarHandler struct { + db *gorm.DB +} + +func NewCalendarHandler() *CalendarHandler { + return &CalendarHandler{db: global.DB} +} + +// CalendarDayData 单日数据 +type CalendarDayData struct { + Todo bool `json:"todo"` // 有待办 + TodoDone bool `json:"todo_done"` // 待办全部完成 + TodoUndone bool `json:"todo_undone"` // 有待办未完成 + Mood bool `json:"mood"` // 有心情 + Bill bool `json:"bill"` // 有账单 +} + +// GetCalendarData 获取日历数据(按日期聚合) +func (h *CalendarHandler) GetCalendarData(c *gin.Context) { + year := time.Now().Year() + month := int(time.Now().Month()) + + // 尝试从请求体获取年月 + var req struct { + Year int `json:"year"` + Month int `json:"month"` + } + c.ShouldBindJSON(&req) + if req.Year > 0 { + year = req.Year + } + if req.Month > 0 && req.Month <= 12 { + month = req.Month + } + + userID := c.GetUint("userID") + + // 计算月份范围 + startDate := fmt.Sprintf("%d-%02d-01", year, month) + _, lastDay := utils.GetMonthLastDay(year, month) + endDate := fmt.Sprintf("%d-%02d-%02d", year, month, lastDay) + + // 初始化结果 + calendarData := make(map[string]*CalendarDayData) + + // 1. 查询待办数据(按日期分组,检查是否有未完成) + var todos []models.Todo + h.db.Where("user_id = ? AND date BETWEEN ? AND ?", userID, startDate, endDate). + Find(&todos) + + // 按日期聚合待办状态 + todoByDate := make(map[string]struct { + HasTodo bool + AllDone bool + HasUndone bool + }) + + for _, todo := range todos { + date := todo.Date + status := todoByDate[date] + status.HasTodo = true + if todo.Done == 1 { + if !status.HasUndone { + status.AllDone = true + } + } else { + status.HasUndone = true + status.AllDone = false + } + todoByDate[date] = status + } + + for date, status := range todoByDate { + if calendarData[date] == nil { + calendarData[date] = &CalendarDayData{} + } + calendarData[date].Todo = status.HasTodo + calendarData[date].TodoDone = status.AllDone + calendarData[date].TodoUndone = status.HasUndone + } + + // 2. 查询心情数据 + var moodDates []string + h.db.Model(&models.Mood{}). + Where("user_id = ? AND date BETWEEN ? AND ?", userID, startDate, endDate). + Pluck("DISTINCT date", &moodDates).Find(&moodDates) + for _, date := range moodDates { + if calendarData[date] == nil { + calendarData[date] = &CalendarDayData{} + } + calendarData[date].Mood = true + } + + // 3. 查询账单数据 + var billDates []string + h.db.Model(&models.Bill{}). + Where("user_id = ? AND date BETWEEN ? AND ?", userID, startDate, endDate). + Pluck("DISTINCT date", &moodDates).Find(&billDates) + for _, date := range billDates { + if calendarData[date] == nil { + calendarData[date] = &CalendarDayData{} + } + calendarData[date].Bill = true + } + + utils.Ok(c, calendarData) +} diff --git a/api/app/captcha.go b/api/app/captcha.go new file mode 100644 index 0000000..8dd5a51 --- /dev/null +++ b/api/app/captcha.go @@ -0,0 +1,66 @@ +package app + +import ( + "simple-memo/utils" + + "github.com/gin-gonic/gin" + "github.com/mojocn/base64Captcha" +) + +// CaptchaHandler 验证码接口定义 +type CaptchaHandler interface { + GenerateDigitCaptcha(c *gin.Context) // 生成数字验证码 + VerifyDigitCaptcha(c *gin.Context) // 验证数字验证码 +} + +type captchaHandler struct{} + +func NewCaptchaHandler() CaptchaHandler { + return &captchaHandler{} +} + +// 存储配置(使用内存存储,生产环境建议使用 Redis) +var store = base64Captcha.DefaultMemStore + +// GenerateDigitCaptcha 生成数字验证码 +func (h *captchaHandler) GenerateDigitCaptcha(c *gin.Context) { + // 配置数字验证码参数 + driver := base64Captcha.NewDriverDigit( + 80, // 高度 + 240, // 宽度 + 4, // 验证码长度 + 0.7, // 干扰线概率 + 8, // 干扰线数量 + ) + + captcha := base64Captcha.NewCaptcha(driver, store) + id, b64s, err := captcha.Generate() + if err != nil { + utils.Fail(c, "生成验证码失败") + return + } + + utils.Ok(c, gin.H{ + "captchaId": id, + "image": b64s, + }) +} + +// VerifyDigitCaptcha 验证数字验证码 +func (h *captchaHandler) VerifyDigitCaptcha(c *gin.Context) { + var req struct { + CaptchaId string `json:"captchaId" binding:"required"` + Answer string `json:"answer" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数错误") + return + } + + if store.Verify(req.CaptchaId, req.Answer, true) { + utils.Ok(c, "验证通过") + } else { + utils.Fail(c, "验证码错误") + } +} diff --git a/api/app/category.go b/api/app/category.go new file mode 100644 index 0000000..0d5d095 --- /dev/null +++ b/api/app/category.go @@ -0,0 +1,161 @@ +// 分类接口 +package app + +import ( + "gorm.io/gorm" + "simple-memo/global" + "simple-memo/models" + "simple-memo/utils" + + "github.com/gin-gonic/gin" +) + +// -------------------------- 1. 定义 Handler 接口 -------------------------- +// CategoryHandler 分类模块接口定义 +type CategoryHandler interface { + CategoryList(c *gin.Context) // 获取分类列表 + AddCategory(c *gin.Context) // 添加分类 + EditCategory(c *gin.Context) // 编辑分类 + DeleteCategory(c *gin.Context) // 删除分类 +} + +// -------------------------- 2. 实现结构体 -------------------------- +type categoryHandler struct { + db *gorm.DB +} + +func NewCategoryHandler() CategoryHandler { + return &categoryHandler{ + db: global.DB, + } +} + +// -------------------------- 3. 请求结构体 -------------------------- + +// CategoryListReq 获取分类列表 +type CategoryListReq struct { + Type int `form:"type"` // 1-支出 2-收入 +} + +// AddCategoryReq 添加分类 +type AddCategoryReq struct { + Name string `json:"name"` // 分类名称 + Type int `json:"type"` // 1-支出 2-收入 + Icon string `json:"icon"` // 图标 + Color string `json:"color"` // 颜色 +} + +// EditCategoryReq 编辑分类 +type EditCategoryReq struct { + ID uint `json:"id"` + Name string `json:"name"` + Type int `json:"type"` + Icon string `json:"icon"` + Color string `json:"color"` +} + +// DeleteCategoryReq 删除分类 +type DeleteCategoryReq struct { + ID uint `json:"id"` +} + +// -------------------------- 4. 接口实现 -------------------------- + +// CategoryList 获取分类列表(系统分类 + 当前用户自定义) +func (h *categoryHandler) CategoryList(c *gin.Context) { + userID := c.GetUint("userID") + var req CategoryListReq + if err := c.ShouldBindQuery(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + var list []models.Category + err := h.db.Where("user_id = 0 OR user_id = ?", userID). + Where("type = ?", req.Type). + Order("id ASC"). + Find(&list).Error + + if err != nil { + utils.Fail(c, "获取失败") + return + } + + utils.Ok(c, list) +} + +// AddCategory 添加自定义分类 +func (h *categoryHandler) AddCategory(c *gin.Context) { + userID := c.GetUint("userID") + var req AddCategoryReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + category := models.Category{ + UserID: userID, + Name: req.Name, + Type: req.Type, + Icon: req.Icon, + Color: req.Color, + } + + if err := h.db.Create(&category).Error; err != nil { + utils.Fail(c, "添加失败") + return + } + + utils.Ok(c, nil) +} + +// EditCategory 编辑分类 +func (h *categoryHandler) EditCategory(c *gin.Context) { + userID := c.GetUint("userID") + var req EditCategoryReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + data := map[string]any{ + "name": req.Name, + "type": req.Type, + "icon": req.Icon, + "color": req.Color, + } + + err := h.db.Model(&models.Category{}). + Where("id = ? AND user_id = ?", req.ID, userID). + Updates(data).Error + + if err != nil { + utils.Fail(c, "编辑失败") + return + } + + utils.Ok(c, nil) +} + +// DeleteCategory 删除分类 +func (h *categoryHandler) DeleteCategory(c *gin.Context) { + userID := c.GetUint("userID") + var req DeleteCategoryReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + err := h.db.Where("id = ? AND user_id = ?", req.ID, userID). + Delete(&models.Category{}).Error + + if err != nil { + utils.Fail(c, "删除失败") + return + } + + utils.Ok(c, nil) +} diff --git a/api/app/constants.go b/api/app/constants.go new file mode 100644 index 0000000..7e25bb5 --- /dev/null +++ b/api/app/constants.go @@ -0,0 +1,149 @@ +package app + +import ( + "github.com/gin-gonic/gin" + "simple-memo/utils" +) + +type ConstantsHandler interface { + GetBillCategories(c *gin.Context) + GetTodoCategories(c *gin.Context) + GetMoodList(c *gin.Context) + GetPayChannels(c *gin.Context) + GetAllConstants(c *gin.Context) +} + +type constantsHandler struct{} + +func NewConstantsHandler() ConstantsHandler { + return &constantsHandler{} +} + +var DEFAULT_CATE_LIST = []map[string]interface{}{ + {"value": "food", "label": "餐饮", "icon": "🍚", "color": "#ff6b6b", "type": 1}, + {"value": "transport", "label": "交通", "icon": "🚇", "color": "#4ecdc4", "type": 1}, + {"value": "shopping", "label": "购物", "icon": "🛍️", "color": "#ffe66d", "type": 1}, + {"value": "entertainment", "label": "娱乐", "icon": "🎮", "color": "#a855f7", "type": 1}, + {"value": "home", "label": "居家", "icon": "🏠", "color": "#84b3d9", "type": 1}, + {"value": "medical", "label": "医疗", "icon": "🏥", "color": "#ef4444", "type": 1}, + {"value": "education", "label": "教育", "icon": "📚", "color": "#22c55e", "type": 1}, + {"value": "social", "label": "社交", "icon": "👥", "color": "#f97316", "type": 1}, + {"value": "digital", "label": "数码", "icon": "📱", "color": "#3b82f6", "type": 1}, + {"value": "clothing", "label": "服饰", "icon": "👔", "color": "#ec4899", "type": 1}, + {"value": "beauty", "label": "美容", "icon": "💄", "color": "#d946ef", "type": 1}, + {"value": "sports", "label": "运动", "icon": "⚽", "color": "#10b981", "type": 1}, + {"value": "pet", "label": "宠物", "icon": "🐶", "color": "#fbbf24", "type": 1}, + {"value": "baby", "label": "育儿", "icon": "🍼", "color": "#fb7185", "type": 1}, + {"value": "travel", "label": "旅游", "icon": "✈️", "color": "#06b6d4", "type": 1}, + {"value": "other_expense", "label": "其他", "icon": "📦", "color": "#9ca3af", "type": 1}, + {"value": "salary", "label": "工资", "icon": "💼", "color": "#22c55e", "type": 2}, + {"value": "bonus", "label": "奖金", "icon": "🎁", "color": "#fbbf24", "type": 2}, + {"value": "side_hustle", "label": "副业", "icon": "💻", "color": "#3b82f6", "type": 2}, + {"value": "investment", "label": "理财", "icon": "📈", "color": "#a855f7", "type": 2}, + {"value": "transfer", "label": "转账", "icon": "💳", "color": "#84b3d9", "type": 2}, + {"value": "red_packet", "label": "红包", "icon": "🧧", "color": "#ef4444", "type": 2}, + {"value": "other_income", "label": "其他", "icon": "🎯", "color": "#6b7280", "type": 2}, +} + +var TODO_BASE_CATE = []map[string]interface{}{ + {"value": "work", "label": "工作", "color": "#84b3d9"}, + {"value": "life", "label": "生活", "color": "#a0d0e0"}, + {"value": "study", "label": "学习", "color": "#b8d8e6"}, +} + +var TODO_PRIORITY_LIST = []map[string]interface{}{ + {"label": "高", "value": 1, "icon": "🔴"}, + {"label": "中", "value": 2, "icon": "🟡"}, + {"label": "低", "value": 3, "icon": "🟢"}, +} + +var MOOD_LIST = []map[string]interface{}{ + {"emoji": "😊", "label": "开心", "phrases": []string{"今天心情真好", "阳光灿烂的一天", "嘴角不自觉上扬", "满心欢喜"}}, + {"emoji": "😌", "label": "平静", "phrases": []string{"岁月静好", "内心一片安宁", "享受这份宁静", "平和自在"}}, + {"emoji": "😔", "label": "低落", "phrases": []string{"有点小忧伤", "心情沉沉的", "需要一点温暖", "静待花开"}}, + {"emoji": "😤", "label": "生气", "phrases": []string{"有点烦躁呢", "深呼吸冷静一下", "需要冷静片刻", "平复一下心情"}}, + {"emoji": "😴", "label": "疲惫", "phrases": []string{"有点累了", "好好休息一下", "给自己放个假", "累并快乐着"}}, + {"emoji": "😰", "label": "焦虑", "phrases": []string{"有点担心", "相信一切会好", "慢慢来不着急", "放宽心"}}, + {"emoji": "😄", "label": "兴奋", "phrases": []string{"超级开心!", "激动人心的时刻", "迫不及待了", "满心期待"}}, + {"emoji": "🤔", "label": "思考", "phrases": []string{"正在思考中", "让我想想", "深思熟虑", "思绪万千"}}, + {"emoji": "😢", "label": "难过", "phrases": []string{"有点难过", "需要一个拥抱", "一切都会过去", "明天会更好"}}, + {"emoji": "😎", "label": "酷", "phrases": []string{"今天超酷的", "自信满满", "做自己就好", "洒脱自在"}}, + {"emoji": "🥰", "label": "幸福", "phrases": []string{"被幸福包围", "甜蜜的感觉", "幸福感爆棚", "满心欢喜"}}, + {"emoji": "😠", "label": "愤怒", "phrases": []string{"真的生气了", "需要冷静一下", "平复一下情绪", "深呼吸"}}, + {"emoji": "🥳", "label": "庆祝", "phrases": []string{"值得庆祝!", "太棒了!", "举杯庆祝", "喜笑颜开"}}, + {"emoji": "😱", "label": "惊讶", "phrases": []string{"哇!好惊喜", "太意外了", "不可思议", "令人震惊"}}, + {"emoji": "😇", "label": "感恩", "phrases": []string{"心怀感恩", "感谢生活", "感恩遇见", "心存感激"}}, + {"emoji": "💪", "label": "加油", "phrases": []string{"加油加油!", "相信自己", "勇往直前", "全力以赴"}}, + {"emoji": "😘", "label": "害羞", "phrases": []string{"有点害羞", "不好意思啦", "脸红心跳", "羞涩一笑"}}, + {"emoji": "🤩", "label": "崇拜", "phrases": []string{"超级崇拜!", "偶像光芒", "闪闪发光", "心生敬意"}}, + {"emoji": "😪", "label": "困倦", "phrases": []string{"好困呀", "眼皮打架了", "需要小憩", "睡个好觉"}}, + {"emoji": "😋", "label": "满足", "phrases": []string{"心满意足", "幸福感满满", "知足常乐", "十分满足"}}, +} + +var PAY_CHANNELS = []map[string]interface{}{ + {"value": "wechat", "label": "微信", "types": []int{1, 2}}, + {"value": "alipay", "label": "支付宝", "types": []int{1, 2}}, + {"value": "unionpay", "label": "云闪付", "types": []int{1, 2}}, + {"value": "bank", "label": "银行卡", "types": []int{1, 2}}, + {"value": "cash", "label": "现金", "types": []int{1, 2}}, + {"value": "online_banking", "label": "网银", "types": []int{1, 2}}, + {"value": "pos", "label": "POS机", "types": []int{1, 2}}, + {"value": "other", "label": "其他", "types": []int{1, 2}}, + {"value": "salary", "label": "工资", "types": []int{2}}, + {"value": "red_packet", "label": "红包", "types": []int{2}}, + {"value": "transfer_in", "label": "转账", "types": []int{2}}, + {"value": "corporate", "label": "对公转账", "types": []int{2}}, + {"value": "credit", "label": "信用卡", "types": []int{1}}, + {"value": "huawei", "label": "花呗", "types": []int{1}}, + {"value": "white", "label": "白条", "types": []int{1}}, + {"value": "jd_pay", "label": "京东支付", "types": []int{1}}, + {"value": "meituan_pay", "label": "美团支付", "types": []int{1}}, + {"value": "gift_card", "label": "储值卡", "types": []int{1}}, +} + +// AVATAR_BASE_URL 头像基础URL +const AVATAR_BASE_URL = "/static/avatar/" + +// DEFAULT_AVATARS 默认头像列表 +var DEFAULT_AVATARS = []map[string]interface{}{ + {"url": AVATAR_BASE_URL + "ginger_cat.jpg", "name": "ginger_cat", "label": "橘猫"}, + {"url": AVATAR_BASE_URL + "ragdoll.jpg", "name": "ragdoll", "label": "布偶猫"}, + {"url": AVATAR_BASE_URL + "hamster.jpg", "name": "hamster", "label": "小仓鼠"}, + {"url": AVATAR_BASE_URL + "dragon.jpg", "name": "dragon", "label": "龙"}, + {"url": AVATAR_BASE_URL + "pony.jpg", "name": "pony", "label": "小马"}, + {"url": AVATAR_BASE_URL + "lamb.jpg", "name": "lamb", "label": "小羊"}, + {"url": AVATAR_BASE_URL + "corgi.jpg", "name": "corgi", "label": "柯基"}, + {"url": AVATAR_BASE_URL + "piglet.jpg", "name": "piglet", "label": "小猪"}, +} + +func (h *constantsHandler) GetBillCategories(c *gin.Context) { + utils.Ok(c, DEFAULT_CATE_LIST) +} + +func (h *constantsHandler) GetTodoCategories(c *gin.Context) { + data := map[string]interface{}{ + "categories": TODO_BASE_CATE, + "priorities": TODO_PRIORITY_LIST, + } + utils.Ok(c, data) +} + +func (h *constantsHandler) GetMoodList(c *gin.Context) { + utils.Ok(c, MOOD_LIST) +} + +func (h *constantsHandler) GetPayChannels(c *gin.Context) { + utils.Ok(c, PAY_CHANNELS) +} + +func (h *constantsHandler) GetAllConstants(c *gin.Context) { + data := map[string]interface{}{ + "billCategories": DEFAULT_CATE_LIST, + "todoCategories": TODO_BASE_CATE, + "todoPriorities": TODO_PRIORITY_LIST, + "moodList": MOOD_LIST, + "payChannels": PAY_CHANNELS, + "avatars": DEFAULT_AVATARS, + } + utils.Ok(c, data) +} diff --git a/api/app/dashboard.go b/api/app/dashboard.go new file mode 100644 index 0000000..b3f1e31 --- /dev/null +++ b/api/app/dashboard.go @@ -0,0 +1,206 @@ +// 首页聚合数据接口 +package app + +import ( + "simple-memo/global" + "simple-memo/models" + "simple-memo/utils" + "time" + + "gorm.io/gorm" + + "github.com/gin-gonic/gin" +) + +// -------------------------- 1. 定义 Handler 接口 -------------------------- +type DashboardHandler interface { + DailyDashboard(c *gin.Context) // 首页聚合数据 +} + +// -------------------------- 2. 实现结构体 -------------------------- +type dashboardHandler struct { + db *gorm.DB +} + +func NewDashboardHandler() DashboardHandler { + return &dashboardHandler{ + db: global.DB, + } +} + +// -------------------------- 3. 请求结构体 -------------------------- +type DailyDashboardReq struct { + Date string `json:"date"` // YYYY-MM-DD,不传默认今天 +} + +// -------------------------- 4. 接口实现 -------------------------- + +// DailyDashboard 首页聚合数据 +func (h *dashboardHandler) DailyDashboard(c *gin.Context) { + userID := c.GetUint("userID") + var req DailyDashboardReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + // 日期处理 + var targetDate string + if req.Date == "" { + targetDate = time.Now().Format("2006-01-02") + } else { + targetDate = req.Date + } + + // ========== 1. 待办数据 ========== + var todoTotal, todoDone, todoUndone int64 + h.db.Model(&models.Todo{}). + Where("user_id = ? AND date = ?", userID, targetDate). + Count(&todoTotal) + h.db.Model(&models.Todo{}). + Where("user_id = ? AND date = ? AND done = 1", userID, targetDate). + Count(&todoDone) + todoUndone = todoTotal - todoDone + + // 待办列表(最近3条) + var todoList []models.Todo + h.db.Model(&models.Todo{}). + Where("user_id = ? AND date = ?", userID, targetDate). + Order("priority ASC, id ASC"). + Limit(3). + Find(&todoList) + + todoData := gin.H{ + "total": todoTotal, + "done": todoDone, + "undone": todoUndone, + "list": todoList, + } + + // ========== 2. 账单数据 ========== + var billExpend, billIncome float64 + h.db.Model(&models.Bill{}). + Where("user_id = ? AND date = ? AND type = 1", userID, targetDate). + Select("COALESCE(SUM(money), 0)").Scan(&billExpend) + h.db.Model(&models.Bill{}). + Where("user_id = ? AND date = ? AND type = 2", userID, targetDate). + Select("COALESCE(SUM(money), 0)").Scan(&billIncome) + + // 账单列表(最近3条) + var billList []models.Bill + h.db.Model(&models.Bill{}). + Where("user_id = ? AND date = ?", userID, targetDate). + Order("id DESC"). + Limit(3). + Find(&billList) + + billData := gin.H{ + "expend": billExpend, + "income": billIncome, + "balance": billIncome - billExpend, + "list": billList, + } + + // ========== 3. 心情数据 ========== + type MoodResult struct { + ID uint `json:"id"` + Date string `json:"date"` + Emoji string `json:"emoji"` + Content string `json:"content"` + AIStatus int `json:"ai_status"` + AIText string `json:"ai_text"` + AIImageURL string `json:"ai_image_url"` + AIGenerateCount int `json:"ai_generate_count"` + } + + var moodResult MoodResult + h.db.Model(&models.Mood{}). + Select(` + sm_mood.id, + sm_mood.date, + sm_mood.emoji, + sm_mood.content, + COALESCE(sm_mood_ai_generations.status, 0) as ai_status, + COALESCE(sm_mood_ai_generations.ai_text, '') as ai_text, + COALESCE(sm_mood_ai_generations.ai_image_url, '') as ai_image_url, + COALESCE(sm_mood_ai_generations.generate_count, 0) as ai_generate_count + `). + Joins("LEFT JOIN sm_mood_ai_generations ON sm_mood.id = sm_mood_ai_generations.mood_id"). + Where("sm_mood.user_id = ? AND sm_mood.date = ?", userID, targetDate). + Scan(&moodResult) + + moodData := gin.H{ + "recorded": moodResult.ID > 0, + "emoji": moodResult.Emoji, + "content": moodResult.Content, + "ai_generated": moodResult.AIStatus == 2, + "ai_text": moodResult.AIText, + } + if moodResult.ID == 0 { + moodData = gin.H{ + "recorded": false, + "emoji": "", + "content": "", + "ai_generated": false, + "ai_text": "", + } + } + + // ========== 4. 复盘数据 ========== + var review models.Review + err := h.db.Where("user_id = ? AND date = ?", userID, targetDate).First(&review).Error + reviewData := gin.H{ + "recorded": err == nil, + "method": "kpt", + } + if err == nil { + reviewData["id"] = review.ID + reviewData["keep"] = review.Keep + reviewData["problem"] = review.Problem + reviewData["try"] = review.Try + // 生成简短摘要 + summary := "" + if review.Keep != "" { + s := review.Keep + if len(s) > 30 { + s = s[:30] + "..." + } + summary = s + } + reviewData["summary"] = summary + } + + // ========== 5. 成长数据 ========== + growthHandler := NewGrowthHandler() + growthInfo := growthHandler.GetGrowthInfoInternal(userID) + + growthData := gin.H{ + "todayExp": 0, // 今日经验,需要从 ExpChangeLog 查询 + "maxDailyExp": 120, + "streakDays": growthInfo["streak_days"], + } + + // 查询今日经验 + var todayExp int + today := time.Now().Format("2006-01-02") + if targetDate == today { + h.db.Model(&models.ExpChangeLog{}). + Where("user_id = ? AND DATE(created_at) = ?", userID, today). + Select("COALESCE(SUM(exp), 0)"). + Scan(&todayExp) + growthData["todayExp"] = todayExp + } + + // ========== 组装结果 ========== + result := gin.H{ + "date": targetDate, + "todo": todoData, + "bill": billData, + "mood": moodData, + "review": reviewData, + "growth": growthData, + } + + utils.Ok(c, result) +} diff --git a/api/app/growth.go b/api/app/growth.go new file mode 100644 index 0000000..9ee265a --- /dev/null +++ b/api/app/growth.go @@ -0,0 +1,1311 @@ +// 成长等级系统 +package app + +import ( + "context" + "encoding/json" + "fmt" + "simple-memo/global" + "simple-memo/models" + "simple-memo/utils" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// ============================================ +// 1. 接口定义 +// ============================================ + +// GrowthHandler 成长系统接口 +type GrowthHandler interface { + GetGrowthInfo(c *gin.Context) + GetAchievements(c *gin.Context) + GetGrowthRules(c *gin.Context) + AddExp(c *gin.Context) + AddExpInternal(userID uint, source string, sourceID string, c *gin.Context) *GrowthRewards + DeductExpInternal(userID uint, source string, sourceID string, today string) int + GetGrowthInfoInternal(userID uint) map[string]interface{} +} + +// growthHandler 成长系统处理器实现 +type growthHandler struct { + db *gorm.DB +} + +// NewGrowthHandler 创建成长系统处理器 +func NewGrowthHandler() GrowthHandler { + return &growthHandler{ + db: global.DB, + } +} + +// ============================================ +// 2. 配置常量 & 类型定义 +// ============================================ + +// ======================== +// 等级配置 +// ======================== + +var dailyCap = 120 + +// LevelConfig 等级配置 +type LevelConfig struct { + Exp int + Title string + Icon string + Color string + Privileges []string +} + +// LevelConfigs 等级配置映射 +var LevelConfigs = map[int]LevelConfig{ + 1: {Exp: 0, Title: "记录小白", Icon: "📝", Color: "#94A3B8", Privileges: []string{"初级称号"}}, + 2: {Exp: 200, Title: "坚持新手", Icon: "💪", Color: "#22C55E", Privileges: []string{"初级称号", "更多特权敬请期待"}}, + 3: {Exp: 600, Title: "习惯学徒", Icon: "🌱", Color: "#06B6D4", Privileges: []string{"中级称号", "更多特权敬请期待"}}, + 4: {Exp: 1500, Title: "自律达人", Icon: "✅", Color: "#6366F1", Privileges: []string{"中级称号", "更多特权敬请期待"}}, + 5: {Exp: 3500, Title: "记录大师", Icon: "🏆", Color: "#8B5CF6", Privileges: []string{"高级称号", "更多特权敬请期待"}}, + 6: {Exp: 7000, Title: "时间管家", Icon: "⏰", Color: "#EC4899", Privileges: []string{"高级称号", "更多特权敬请期待"}}, + 7: {Exp: 12000, Title: "生活规划师", Icon: "📋", Color: "#F97316", Privileges: []string{"稀有称号", "更多特权敬请期待"}}, + 8: {Exp: 18000, Title: "效率王者", Icon: "⚡", Color: "#EAB308", Privileges: []string{"史诗称号", "更多特权敬请期待"}}, + 9: {Exp: 26000, Title: "人生记录官", Icon: "📜", Color: "#EF4444", Privileges: []string{"传说称号", "更多特权敬请期待"}}, + 10: {Exp: 36000, Title: "生活主宰者", Icon: "🌟", Color: "#F59E0B", Privileges: []string{"至尊称号", "专属特权敬请期待"}}, +} + +// ======================== +// 成就配置 +// ======================== + +// Achievement 成就配置 +type Achievement struct { + ID string + Name string + Icon string + Category string // streak/task/mood/bill + Description string + ExpReward int + Target int + TargetType string + Rarity string +} + +// RarityConfig 稀有度配置 +type RarityConfig struct { + Name string + Color string + Effect string +} + +// AchievementCategory 成就分类 +type AchievementCategory struct { + Key string + Name string + Icon string +} + +// AchievementConfigs 成就配置映射 +var AchievementConfigs = map[string]Achievement{ + // 坚持类 + "first_record": {ID: "first_record", Name: "初次尝试", Icon: "✨", Category: "streak", Description: "完成第1天记录", ExpReward: 10, Target: 1, TargetType: "total_days", Rarity: "primary"}, + "streak_3": {ID: "streak_3", Name: "3天坚持", Icon: "🌱", Category: "streak", Description: "连续记录3天", ExpReward: 20, Target: 3, TargetType: "streak", Rarity: "primary"}, + "streak_7": {ID: "streak_7", Name: "一周打卡", Icon: "🌟", Category: "streak", Description: "连续记录7天", ExpReward: 50, Target: 7, TargetType: "streak", Rarity: "secondary"}, + "streak_30": {ID: "streak_30", Name: "月度坚持", Icon: "📅", Category: "streak", Description: "连续记录30天", ExpReward: 200, Target: 30, TargetType: "streak", Rarity: "advanced"}, + "streak_100": {ID: "streak_100", Name: "百日筑基", Icon: "💯", Category: "streak", Description: "连续记录100天", ExpReward: 500, Target: 100, TargetType: "streak", Rarity: "rare"}, + "streak_180": {ID: "streak_180", Name: "半年丰碑", Icon: "🏅", Category: "streak", Description: "连续记录180天", ExpReward: 1000, Target: 180, TargetType: "streak", Rarity: "epic"}, + "streak_365": {ID: "streak_365", Name: "年度传奇", Icon: "🎉", Category: "streak", Description: "连续记录365天", ExpReward: 2000, Target: 365, TargetType: "streak", Rarity: "legendary"}, + + // 任务类 + "task_1": {ID: "task_1", Name: "任务新手", Icon: "🎯", Category: "task", Description: "完成第1个任务", ExpReward: 10, Target: 1, TargetType: "total_tasks", Rarity: "primary"}, + "task_100": {ID: "task_100", Name: "任务达人", Icon: "⚡", Category: "task", Description: "累计完成100个任务", ExpReward: 200, Target: 100, TargetType: "total_tasks", Rarity: "secondary"}, + "task_500": {ID: "task_500", Name: "任务大师", Icon: "🏆", Category: "task", Description: "累计完成500个任务", ExpReward: 800, Target: 500, TargetType: "total_tasks", Rarity: "rare"}, + "task_1000": {ID: "task_1000", Name: "任务王者", Icon: "👑", Category: "task", Description: "累计完成1000个任务", ExpReward: 1500, Target: 1000, TargetType: "total_tasks", Rarity: "legendary"}, + "perfect_day": {ID: "perfect_day", Name: "完美一天", Icon: "✨", Category: "task", Description: "单日完成所有待办", ExpReward: 30, Target: 1, TargetType: "perfect_day", Rarity: "advanced"}, + "perfect_week": {ID: "perfect_week", Name: "完美一周", Icon: "💫", Category: "task", Description: "连续7天100%完成", ExpReward: 200, Target: 7, TargetType: "perfect_week", Rarity: "epic"}, + + // 心情类 + "mood_first": {ID: "mood_first", Name: "心情记录者", Icon: "📝", Category: "mood", Description: "第1次记录心情", ExpReward: 10, Target: 1, TargetType: "total_moods", Rarity: "primary"}, + "mood_30": {ID: "mood_30", Name: "月度心情家", Icon: "🌈", Category: "mood", Description: "累计记录30天心情", ExpReward: 100, Target: 30, TargetType: "total_moods", Rarity: "secondary"}, + "mood_100": {ID: "mood_100", Name: "心情收藏家", Icon: "🎨", Category: "mood", Description: "累计记录100天心情", ExpReward: 300, Target: 100, TargetType: "total_moods", Rarity: "rare"}, + "happy_streak": {ID: "happy_streak", Name: "阳光达人", Icon: "☀️", Category: "mood", Description: "连续10天记录开心", ExpReward: 150, Target: 10, TargetType: "happy_streak", Rarity: "advanced"}, + + // 记账类 + "bill_1": {ID: "bill_1", Name: "记账入门", Icon: "💵", Category: "bill", Description: "第1次记账", ExpReward: 10, Target: 1, TargetType: "total_bills", Rarity: "primary"}, + "bill_100": {ID: "bill_100", Name: "记账达人", Icon: "💳", Category: "bill", Description: "累计记账100笔", ExpReward: 200, Target: 100, TargetType: "total_bills", Rarity: "secondary"}, + "bill_500": {ID: "bill_500", Name: "记账大师", Icon: "🏅", Category: "bill", Description: "累计记账500笔", ExpReward: 500, Target: 500, TargetType: "total_bills", Rarity: "rare"}, + "bill_2000": {ID: "bill_2000", Name: "理财高手", Icon: "💎", Category: "bill", Description: "累计记账2000笔", ExpReward: 1200, Target: 2000, TargetType: "total_bills", Rarity: "legendary"}, + + // 复盘类 + "review_first": {ID: "review_first", Name: "复盘初体验", Icon: "🔍", Category: "review", Description: "完成第1次复盘", ExpReward: 15, Target: 1, TargetType: "total_reviews", Rarity: "primary"}, + "review_7": {ID: "review_7", Name: "周复盘者", Icon: "📋", Category: "review", Description: "累计复盘7次", ExpReward: 50, Target: 7, TargetType: "total_reviews", Rarity: "secondary"}, + "review_30": {ID: "review_30", Name: "月度复盘师", Icon: "🧠", Category: "review", Description: "累计复盘30次", ExpReward: 150, Target: 30, TargetType: "total_reviews", Rarity: "advanced"}, + "review_100": {ID: "review_100", Name: "复盘修行者", Icon: "🎓", Category: "review", Description: "累计复盘100次", ExpReward: 400, Target: 100, TargetType: "total_reviews", Rarity: "rare"}, + "review_365": {ID: "review_365", Name: "年度复盘大师", Icon: "🏯", Category: "review", Description: "累计复盘365次", ExpReward: 1000, Target: 365, TargetType: "total_reviews", Rarity: "legendary"}, + + // 收集类 + "full_collector": {ID: "full_collector", Name: "全套收藏家", Icon: "👑", Category: "collection", Description: "收集所有成就", ExpReward: 3000, Target: 1, TargetType: "full_collection", Rarity: "legendary"}, +} + +// RarityConfigs 稀有度配置映射 +var RarityConfigs = map[string]RarityConfig{ + "primary": {Name: "初级", Color: "#9CA3AF", Effect: "none"}, + "secondary": {Name: "中级", Color: "#10B981", Effect: "glow"}, + "advanced": {Name: "高级", Color: "#3B82F6", Effect: "glow"}, + "rare": {Name: "稀有", Color: "#8B5CF6", Effect: "pulse"}, + "epic": {Name: "史诗", Color: "#F59E0B", Effect: "fire"}, + "legendary": {Name: "传说", Color: "#EF4444", Effect: "rotate"}, +} + +// AchievementCategories 成就分类列表 +var AchievementCategories = []AchievementCategory{ + {Key: "streak", Name: "坚持类", Icon: "📅"}, + {Key: "task", Name: "任务类", Icon: "✅"}, + {Key: "mood", Name: "心情类", Icon: "😊"}, + {Key: "bill", Name: "记账类", Icon: "💰"}, + {Key: "review", Name: "复盘类", Icon: "🔍"}, + {Key: "collection", Name: "收集类", Icon: "👑"}, +} + +// ======================== +// 经验规则配置 +// ======================== + +// ExpRuleConfig 经验获取规则 +type ExpRuleConfig struct { + Icon string + Name string + Desc string + Exp string + Detail string +} + +// ExpRuleConfigs 经验获取规则列表 +var ExpRuleConfigs = []ExpRuleConfig{ + {Icon: "✅", Name: "完成任务", Desc: "完成待办任务", Exp: "5-25 EXP/个", Detail: "完成第1个任务获得5 EXP,第3个起额外+5 EXP,第5个起额外+15 EXP"}, + {Icon: "💰", Name: "记账记录", Desc: "记录收支账单", Exp: "3-18 EXP/笔", Detail: "记录第1笔账单获得3 EXP,第3笔起额外+5 EXP,第5笔起额外+10 EXP"}, + {Icon: "😊", Name: "记录心情", Desc: "每日记录一次心情", Exp: "5 EXP/天", Detail: "每天只能记录1次心情,多次记录不额外获得经验"}, + {Icon: "🔍", Name: "每日复盘", Desc: "KPT复盘反思", Exp: "5 EXP/次", Detail: "每次复盘获得5 EXP,每日最多1次复盘"}, +} + +// StreakBonusRule 连续打卡加成规则 +type StreakBonusRule struct { + Range string + Bonus int + Color string +} + +// StreakBonusRules 连续打卡加成规则列表 +var StreakBonusRules = []StreakBonusRule{ + {Range: "1-7天", Bonus: 2, Color: "#10B981"}, + {Range: "8-30天", Bonus: 5, Color: "#F59E0B"}, + {Range: "31-90天", Bonus: 10, Color: "#EC4899"}, + {Range: "91天以上", Bonus: 15, Color: "#8B5CF6"}, +} + +// ======================== +// 其他配置 +// ======================== + +// GrowthTips 快速升级秘籍 +var GrowthTips = []string{ + "每天完成至少3个任务,可以获得更多经验", + "坚持记账,每天记录3-5笔账单收益更高", + "别忘记记录心情,轻松获取额外经验", + "每日做一次KPT复盘,总结反思获取成长值", + "保持连续打卡,连续天数越多加成越高", + "完成成就可以获得额外经验奖励", +} + +// FAQItem 常见问题项 +type FAQItem struct { + Q string + A string +} + +// GrowthFAQ 常见问题列表 +var GrowthFAQ = []FAQItem{ + {Q: "经验会清零吗?", A: "不会!你的经验值会一直累计,等级只会越来越高。"}, + {Q: "断了连续打卡怎么办?", A: "别担心!断签后连续天数不会完全清零,会根据之前的连续天数保留一部分(1-7天保留0天,8-30天保留3天,31-90天保留7天,91天以上保留15天)。"}, + {Q: "每天的经验有上限吗?", A: "有的,每天通过任务、记账、心情获得的最高经验是120 EXP。"}, + {Q: "新用户保护期是什么?", A: "新用户前7天享受50%的额外经验加成,帮助你快速成长!"}, + {Q: "成就奖励经验是一次性的吗?", A: "是的,每个成就只能领取一次经验奖励,解锁后不会再重复获得。"}, + {Q: "等级特权会影响基础功能使用吗?", A: "完全不会!所有核心功能(待办、记账、心情记录、主题切换)都是免费开放的,不受等级限制。等级特权只是额外的荣誉展示和增值服务。"}, + {Q: "什么是称号特权?", A: "不同等级会获得不同稀有度的称号:初级→中级→高级→稀有→史诗→传说→至尊。称号会展示在你的个人主页和成长之路页面。"}, +} + +// ============================================ +// 3. 请求/响应 结构体 +// ============================================ + +// GetGrowthInfoReq 获取成长信息请求 +type GetGrowthInfoReq struct{} + +// AddExpReq 增加经验请求 +type AddExpReq struct { + Source string `json:"source" binding:"required"` // task/bill/mood/review + SourceID string `json:"source_id"` // 关联记录ID +} + +// GetAchievementsReq 获取成就列表请求 +type GetAchievementsReq struct { + Category string `json:"category"` // 空则返回全部 +} + +// ============================================ +// 4. 业务模型结构体 +// ============================================ + +// ExpSource 经验来源计算结果 +type ExpSource struct { + Base int + TaskBonus int + BillBonus int + StreakBonus int + ProtectBonus int +} + +// ExpResult 经验计算最终结果 +type ExpResult struct { + Base int + TaskExp int + BillExp int + MoodExp int + ReviewExp int + StreakBonus int + ProtectBonus int + Total int +} + +// ExpBreakdown 经验明细(用于JSON存储) +type ExpBreakdown struct { + TaskExp int + BillExp int + MoodExp int + ReviewExp int + StreakBonus int + ProtectBonus int +} + +// GrowthRewards 成长奖励返回结构(用于业务接口集成) +type GrowthRewards struct { + ExpGained int `json:"expGained"` // 本次获得经验 + TotalExp int `json:"totalExp"` // 总经验 + LevelUp bool `json:"levelUp"` // 是否升级 + OldLevel int `json:"oldLevel"` // 升级前等级 + CurrentLevel int `json:"currentLevel"` // 当前等级 + LevelTitle string `json:"levelTitle"` // 等级称号 + LevelIcon string `json:"levelIcon"` // 等级图标 + LevelColor string `json:"levelColor"` // 等级颜色 + NextLevelExp int `json:"nextLevelExp"` // 下一级所需经验 + NewPrivileges []string `json:"newPrivileges"` // 新解锁特权 + NewAchievements []map[string]any `json:"newAchievements"` // 新获得的成就 +} + +// ============================================ +// 5. 接口实现 +// ============================================ + +// GetGrowthInfo 获取用户成长信息 +func (h *growthHandler) GetGrowthInfo(c *gin.Context) { + userID := c.GetUint("userID") + today := time.Now().Format("2006-01-02") + + growth := h.getOrCreateGrowth(userID) + todayProgress := h.getTodayProgress(userID, today) + + levelConfig := LevelConfigs[growth.Level] + nextLevelConfig := LevelConfigs[growth.Level+1] + + nextLevelExp := 0 + if nextLevelConfig.Exp > 0 { + nextLevelExp = nextLevelConfig.Exp + } + + // 计算当前等级进度百分比 (0-100) + levelProgress := 0 + if nextLevelExp > 0 { + currentLevelExp := LevelConfigs[growth.Level].Exp + if nextLevelExp > currentLevelExp { + progress := float64(growth.Experience-currentLevelExp) / float64(nextLevelExp-currentLevelExp) * 100 + levelProgress = int(progress) + if levelProgress > 100 { + levelProgress = 100 + } + } + } + + // 获取下一级特权 + nextLevelPrivileges := []string{} + if nextLevelConfig.Privileges != nil { + nextLevelPrivileges = nextLevelConfig.Privileges + } + + userAchievements := h.getUserAchievements(userID) + achievementList := make([]map[string]interface{}, 0) + unlockedMap := make(map[string]bool) + for _, ua := range userAchievements { + unlockedMap[ua.AchievementID] = true + if config, ok := AchievementConfigs[ua.AchievementID]; ok { + achievementList = append(achievementList, map[string]interface{}{ + "id": config.ID, + "name": config.Name, + "icon": config.Icon, + "rarity": config.Rarity, + "category": config.Category, + "unlocked": true, + "unlockDate": ua.UnlockDate, + }) + } + } + totalAchievements := len(AchievementConfigs) + + expBreakdown := parseExpBreakdown(todayProgress.ExpBreakdown) + + utils.Ok(c, gin.H{ + "id": growth.ID, + "level": growth.Level, + "title": levelConfig.Title, + "titleIcon": levelConfig.Icon, + "color": levelConfig.Color, + "experience": growth.Experience, + "currentExp": growth.Experience - levelConfig.Exp, + "nextLevelExp": nextLevelExp, + "totalDays": growth.TotalDays, + "streakDays": growth.StreakDays, + "maxStreakDays": growth.MaxStreakDays, + "levelProgress": levelProgress, + "todayProgress": gin.H{ + "tasksCompleted": todayProgress.TaskCount, + "taskExp": expBreakdown.TaskExp, + "billsRecorded": todayProgress.BillCount, + "billExp": expBreakdown.BillExp, + "moodRecorded": todayProgress.MoodRecorded, + "moodExp": expBreakdown.MoodExp, + "reviewRecorded": todayProgress.ReviewRecorded, + "reviewExp": expBreakdown.ReviewExp, + "streakBonus": expBreakdown.StreakBonus, + "protectBonus": expBreakdown.ProtectBonus, + "totalExp": todayProgress.DailyExp, + "maxDailyExp": dailyCap, + }, + "achievements": gin.H{ + "total": totalAchievements, + "unlocked": len(achievementList), + "list": achievementList, + }, + "privileges": levelConfig.Privileges, + "nextLevelPrivileges": nextLevelPrivileges, + }) +} + +// GetGrowthInfoInternal 内部方法,返回简化的成长信息 +func (h *growthHandler) GetGrowthInfoInternal(userID uint) map[string]interface{} { + growth := h.getOrCreateGrowth(userID) + return map[string]interface{}{ + "level": growth.Level, + "experience": growth.Experience, + "totalDays": growth.TotalDays, + "streak_days": growth.StreakDays, + "max_streak_days": growth.MaxStreakDays, + } +} + +// GetAchievements 获取成就列表 +func (h *growthHandler) GetAchievements(c *gin.Context) { + userID := c.GetUint("userID") + + var req GetAchievementsReq + c.ShouldBindJSON(&req) + + userAchievements := h.getUserAchievements(userID) + unlockedMap := make(map[string]bool) + for _, ua := range userAchievements { + unlockedMap[ua.AchievementID] = true + } + + unlockedList := make([]map[string]interface{}, 0) + lockedList := make([]map[string]interface{}, 0) + + for id, config := range AchievementConfigs { + rarity := RarityConfigs[config.Rarity] + achievement := map[string]interface{}{ + "id": config.ID, + "name": config.Name, + "icon": config.Icon, + "category": config.Category, + "description": config.Description, + "expReward": config.ExpReward, + "target": config.Target, + "rarity": config.Rarity, + "rarityName": rarity.Name, + "rarityColor": rarity.Color, + } + + if isUnlocked, ok := unlockedMap[id]; ok && isUnlocked { + achievement["unlocked"] = true + unlockedList = append(unlockedList, achievement) + } else { + achievement["unlocked"] = false + lockedList = append(lockedList, achievement) + } + } + + collectionProgress := calculateCollectionProgress(AchievementConfigs, unlockedMap) + + if req.Category != "" { + filteredUnlocked := make([]map[string]interface{}, 0) + filteredLocked := make([]map[string]interface{}, 0) + for _, a := range unlockedList { + if a["category"] == req.Category { + filteredUnlocked = append(filteredUnlocked, a) + } + } + for _, a := range lockedList { + if a["category"] == req.Category { + filteredLocked = append(filteredLocked, a) + } + } + utils.Ok(c, gin.H{ + "total": len(AchievementConfigs), + "unlocked": len(unlockedList), + "collectionProgress": collectionProgress, + "unlockedList": filteredUnlocked, + "lockedList": filteredLocked, + }) + return + } + + utils.Ok(c, gin.H{ + "total": len(AchievementConfigs), + "unlocked": len(unlockedList), + "collectionProgress": collectionProgress, + "unlockedList": unlockedList, + "lockedList": lockedList, + }) +} + +// AddExp 增加经验(外部调用接口) +func (h *growthHandler) AddExp(c *gin.Context) { + userID := c.GetUint("userID") + + var req AddExpReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + h.AddExpInternal(userID, req.Source, req.SourceID, c) +} + +// AddExpInternal 增加经验(内部调用) +func (h *growthHandler) AddExpInternal(userID uint, source string, sourceID string, c *gin.Context) *GrowthRewards { + ctx := context.Background() + today := time.Now().Format("2006-01-02") + + if source == "mood" { + key := fmt.Sprintf("mood_count:%d:%s", userID, today) + count, _ := global.Redis.Exists(ctx, key).Result() + if count > 0 { + if c != nil { + utils.Fail(c, "今日心情已记录") + } + return nil + } + } + if source == "review" { + key := fmt.Sprintf("review_count:%d:%s", userID, today) + count, _ := global.Redis.Exists(ctx, key).Result() + if count > 0 { + if c != nil { + utils.Fail(c, "今日复盘已记录") + } + return nil + } + } + + lockKey := fmt.Sprintf("lock:growth:%d", userID) + ok, _ := global.Redis.SetNX(ctx, lockKey, "1", 2*time.Second).Result() + if !ok { + if c != nil { + utils.Fail(c, "系统繁忙,请稍后重试") + } + return nil + } + defer global.Redis.Del(ctx, lockKey) + + growth := h.getOrCreateGrowth(userID) + beforeExp := growth.Experience + beforeLevel := growth.Level + + expResult := h.calculateAndUpdateExp(userID, growth, source, sourceID, today) + + afterExp := growth.Experience + levelUp := afterExp > beforeExp && h.checkLevelUp(beforeExp, afterExp) + + newAchievements := h.checkAndUnlockAchievements(userID, growth) + + levelConfig := LevelConfigs[growth.Level] + nextLevelConfig := LevelConfigs[growth.Level+1] + + rewards := &GrowthRewards{ + ExpGained: expResult.Total, + TotalExp: afterExp, + LevelUp: levelUp, + OldLevel: beforeLevel, + CurrentLevel: growth.Level, + LevelTitle: levelConfig.Title, + LevelIcon: levelConfig.Icon, + LevelColor: levelConfig.Color, + NextLevelExp: nextLevelConfig.Exp, + NewPrivileges: levelConfig.Privileges, + NewAchievements: newAchievements, + } + + if c != nil { + utils.Ok(c, rewards) + } + + fields := []zap.Field{ + zap.Uint("userID", userID), + zap.String("source", source), + zap.Int("exp", expResult.Total), + zap.Int("level", growth.Level), + } + if c != nil { + fields = append(fields, utils.LogContextFields(c)...) + } + global.Logger.Info("经验增加", fields...) + + return rewards +} + +// ============================================ +// 6. 核心业务逻辑 +// ============================================ + +// getOrCreateGrowth 获取或创建用户成长记录 +func (h *growthHandler) getOrCreateGrowth(userID uint) *models.UserGrowth { + var growth models.UserGrowth + err := h.db.Where("user_id = ?", userID).First(&growth).Error + if err == gorm.ErrRecordNotFound { + growth = models.UserGrowth{ + UserID: userID, + Level: 1, + Experience: 0, + TotalDays: 0, + StreakDays: 0, + MaxStreakDays: 0, + HappyStreakDays: 0, + LastRecordDate: "", + ProtectedDays: 7, + TotalTasks: 0, + TotalBills: 0, + TotalMoods: 0, + TotalReview: 0, + } + h.db.Create(&growth) + } + return &growth +} + +// getTodayProgress 获取或创建今日进度 +func (h *growthHandler) getTodayProgress(userID uint, today string) *models.DailyProgress { + var progress models.DailyProgress + err := h.db.Where("user_id = ? AND record_date = ?", userID, today).First(&progress).Error + if err == gorm.ErrRecordNotFound { + progress = models.DailyProgress{ + UserID: userID, + RecordDate: today, + TaskCount: 0, + BillCount: 0, + MoodRecorded: false, + ReviewRecorded: false, + DailyExp: 0, + ExpBreakdown: "{}", + } + h.db.Create(&progress) + } + return &progress +} + +// calculateAndUpdateExp 计算并更新经验 +func (h *growthHandler) calculateAndUpdateExp(userID uint, growth *models.UserGrowth, source string, sourceID string, today string) ExpResult { + todayProgress := h.getTodayProgress(userID, today) + + expResult := ExpResult{} + + baseExpConfig := map[string]int{ + "task": 5, + "bill": 3, + "mood": 5, + "review": 5, + } + + baseExp := baseExpConfig[source] + expResult.Base = baseExp + + var streakBonus int + if growth.StreakDays > 0 { + if growth.StreakDays <= 7 { + streakBonus = 2 + } else if growth.StreakDays <= 30 { + streakBonus = 5 + } else if growth.StreakDays <= 90 { + streakBonus = 10 + } else { + streakBonus = 15 + } + } + expResult.StreakBonus = streakBonus + + var protectBonus int + if growth.ProtectedDays > 0 { + protectBonus = int(float64(baseExp) * 0.5) + if growth.TotalDays < 30 { + streakBonus *= 2 + } + } + expResult.ProtectBonus = protectBonus + + var taskExp, billExp, moodExp, reviewExp int + if source == "task" { + todayProgress.TaskCount++ + taskExp = baseExp + if todayProgress.TaskCount >= 3 { + taskExp += 5 + } + if todayProgress.TaskCount >= 5 { + taskExp += 15 + } + + } else if source == "bill" { + todayProgress.BillCount++ + billExp = baseExp + if todayProgress.BillCount >= 3 { + billExp += 5 + } + if todayProgress.BillCount >= 5 { + billExp += 10 + } + } else if source == "mood" { + moodExp = baseExp + todayProgress.MoodRecorded = true + todayProgress.MoodType = h.getMoodType(userID, today) + if todayProgress.MoodType == "happy" { + growth.HappyStreakDays++ + } else { + growth.HappyStreakDays = 0 + } + } else if source == "review" { + reviewExp = baseExp + todayProgress.ReviewRecorded = true + } + + expResult.TaskExp = taskExp + expResult.BillExp = billExp + expResult.MoodExp = moodExp + expResult.ReviewExp = reviewExp + + totalExp := taskExp + billExp + moodExp + reviewExp + streakBonus + protectBonus + if totalExp > dailyCap { + totalExp = dailyCap + } + expResult.Total = totalExp + + isFirstRecordToday := growth.LastRecordDate != today + if isFirstRecordToday { + yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") + + if growth.LastRecordDate == yesterday { + growth.StreakDays++ + } else if growth.LastRecordDate == "" || growth.LastRecordDate < yesterday { + if growth.StreakDays > 0 { + if growth.StreakDays <= 7 { + growth.StreakDays = 0 + } else if growth.StreakDays <= 30 { + growth.StreakDays = 3 + } else if growth.StreakDays <= 90 { + growth.StreakDays = 7 + } else { + growth.StreakDays = 15 + } + } + growth.StreakDays++ + } + + growth.TotalDays++ + todayProgress.TotalTasks = h.getTodayTodoCount(userID, today) + } + + if growth.StreakDays > growth.MaxStreakDays { + growth.MaxStreakDays = growth.StreakDays + } + + growth.LastRecordDate = today + growth.Experience += totalExp + + if growth.ProtectedDays > 0 { + growth.ProtectedDays-- + } + + if source == "task" { + growth.TotalTasks++ + } else if source == "bill" { + growth.TotalBills++ + } else if source == "mood" { + growth.TotalMoods++ + } else if source == "review" { + growth.TotalReview++ + } + + h.checkAndUpdateLevel(growth) + h.db.Save(growth) + + expBreakdown := map[string]int{ + "taskExp": expResult.TaskExp, + "billExp": expResult.BillExp, + "moodExp": expResult.MoodExp, + "reviewExp": expResult.ReviewExp, + "streakBonus": streakBonus, + "protectBonus": protectBonus, + } + breakdownJson, _ := json.Marshal(expBreakdown) + todayProgress.DailyExp += totalExp + todayProgress.ExpBreakdown = string(breakdownJson) + + if source == "mood" { + ctx := context.Background() + key := fmt.Sprintf("mood_count:%d:%s", userID, today) + global.Redis.Set(ctx, key, "1", time.Duration(utils.GetSecondsUntilTomorrowZero())) + } + if source == "review" { + ctx := context.Background() + key := fmt.Sprintf("review_count:%d:%s", userID, today) + global.Redis.Set(ctx, key, "1", time.Duration(utils.GetSecondsUntilTomorrowZero())) + } + + h.db.Save(todayProgress) + + log := models.ExpChangeLog{ + UserID: userID, + ChangeType: "add", + ExpDelta: totalExp, + Source: source, + SourceID: sourceID, + BeforeExp: growth.Experience - totalExp, + AfterExp: growth.Experience, + RecordDate: today, + } + h.db.Create(&log) + + return expResult +} + +// DeductExpInternal 内部方法:扣除经验(删除时调用) +func (h *growthHandler) DeductExpInternal(userID uint, source string, sourceID string, today string) int { + // 查找今天的积分记录 + var log models.ExpChangeLog + err := h.db.Where("user_id = ? AND source = ? AND source_id = ? AND record_date = ?", + userID, source, sourceID, today).First(&log).Error + if err != nil { + // 没有找到今天的记录,不扣除 + return 0 + } + + // 获取用户成长数据 + var growth models.UserGrowth + h.db.Where("user_id = ?", userID).First(&growth) + if growth.ID == 0 { + return 0 + } + + // 计算要扣除的经验值 + deductExp := log.ExpDelta + if deductExp <= 0 { + return 0 + } + + // 确保不会扣成负数 + beforeExp := growth.Experience + afterExp := beforeExp - deductExp + if afterExp < 0 { + afterExp = 0 + deductExp = beforeExp + } + + // 更新经验值 + growth.Experience = afterExp + h.db.Save(&growth) + + // 扣除今日进度中的经验 + var todayProgress models.DailyProgress + h.db.Where("user_id = ? AND record_date = ?", userID, today).First(&todayProgress) + if todayProgress.ID > 0 { + todayProgress.DailyExp -= deductExp + if todayProgress.DailyExp < 0 { + todayProgress.DailyExp = 0 + } + h.db.Save(&todayProgress) + } + + // 记录扣除日志 + deductLog := models.ExpChangeLog{ + UserID: userID, + ChangeType: "deduct", + ExpDelta: -deductExp, + Source: source, + SourceID: sourceID, + BeforeExp: beforeExp, + AfterExp: afterExp, + RecordDate: today, + } + h.db.Create(&deductLog) + + // 删除原积分记录(避免重复扣除) + h.db.Delete(&log) + + // 递减 Redis 计数器(复盘次数) + if source == "review" { + ctx := context.Background() + key := fmt.Sprintf("review_count:%d:%s", userID, today) + count, _ := global.Redis.Decr(ctx, key).Result() + if count <= 0 { + global.Redis.Del(ctx, key) + } + // 递减 TotalReview 计数器 + if growth.TotalReview > 0 { + growth.TotalReview-- + h.db.Save(&growth) + } + // 当日复盘数归零时重置 ReviewRecorded + if todayProgress.ID > 0 && count <= 0 { + todayProgress.ReviewRecorded = false + h.db.Save(&todayProgress) + } + } + + // 删除心情 Redis 标记(确保可重新记录) + if source == "mood" { + ctx := context.Background() + key := fmt.Sprintf("mood_count:%d:%s", userID, today) + global.Redis.Del(ctx, key) + // 递减 TotalMoods 计数器 + if growth.TotalMoods > 0 { + growth.TotalMoods-- + h.db.Save(&growth) + } + // 重置心情记录标记 + if todayProgress.ID > 0 { + todayProgress.MoodRecorded = false + todayProgress.MoodType = "" + h.db.Save(&todayProgress) + } + } + + return deductExp +} + +// checkLevelUp 检查是否升级 +func (h *growthHandler) checkLevelUp(beforeExp, afterExp int) bool { + beforeLevel := h.getLevelByExp(beforeExp) + afterLevel := h.getLevelByExp(afterExp) + return afterLevel > beforeLevel +} + +// getLevelByExp 根据经验获取等级 +func (h *growthHandler) getLevelByExp(exp int) int { + level := 1 + for l := 10; l >= 1; l-- { + if config, ok := LevelConfigs[l]; ok && exp >= config.Exp { + level = l + break + } + } + return level +} + +// checkAndUpdateLevel 检查并更新等级 +func (h *growthHandler) checkAndUpdateLevel(growth *models.UserGrowth) { + newLevel := h.getLevelByExp(growth.Experience) + if newLevel > growth.Level { + oldLevel := growth.Level + growth.Level = newLevel + + log := models.ExpChangeLog{ + UserID: growth.UserID, + ChangeType: "level_up", ExpDelta: 0, + Source: "level", + BeforeExp: oldLevel, + AfterExp: newLevel, + RecordDate: time.Now().Format("2006-01-02"), + } + h.db.Create(&log) + } +} + +// checkAndUnlockAchievements 检查并解锁成就 +func (h *growthHandler) checkAndUnlockAchievements(userID uint, growth *models.UserGrowth) []map[string]interface{} { + var newAchievements []map[string]interface{} + + userAchievements := h.getUserAchievements(userID) + unlockedMap := make(map[string]bool) + for _, ua := range userAchievements { + unlockedMap[ua.AchievementID] = true + } + + for id, config := range AchievementConfigs { + if unlockedMap[id] { + continue + } + + shouldUnlock := false + + switch config.TargetType { + case "total_days": + shouldUnlock = growth.TotalDays >= config.Target + case "streak": + shouldUnlock = growth.StreakDays >= config.Target + case "total_tasks": + shouldUnlock = growth.TotalTasks >= config.Target + case "total_bills": + shouldUnlock = growth.TotalBills >= config.Target + case "total_moods": + shouldUnlock = growth.TotalMoods >= config.Target + case "total_reviews": + shouldUnlock = growth.TotalReview >= config.Target + case "full_collection": + shouldUnlock = checkFullCollection(unlockedMap) + case "perfect_day": + shouldUnlock = h.hasPerfectDay(userID) + case "perfect_week": + shouldUnlock = h.hasPerfectWeek(userID) + case "happy_streak": + shouldUnlock = growth.HappyStreakDays >= config.Target + } + + if shouldUnlock { + ua := models.UserAchievement{ + UserID: userID, + AchievementID: id, + UnlockDate: time.Now().Format("2006-01-02"), + ExpRewarded: config.ExpReward, + } + h.db.Create(&ua) + + growth.Experience += config.ExpReward + h.db.Save(growth) + + rarity := RarityConfigs[config.Rarity] + newAchievements = append(newAchievements, map[string]interface{}{ + "id": config.ID, + "name": config.Name, + "icon": config.Icon, + "description": config.Description, + "expReward": config.ExpReward, + "rarity": config.Rarity, + "rarityName": rarity.Name, + "rarityColor": rarity.Color, + "rarityEffect": rarity.Effect, + "category": config.Category, + }) + } + } + + return newAchievements +} + +// checkFullCollection 检查是否收集完成所有成就(排除收集类本身) +func checkFullCollection(unlockedMap map[string]bool) bool { + totalAchievements := 0 + totalUnlocked := 0 + + for id, config := range AchievementConfigs { + if config.Category == "collection" { + continue + } + totalAchievements += 1 + if unlockedMap[id] { + totalUnlocked += 1 + } + } + + return totalUnlocked >= totalAchievements +} + +// getUserAchievements 获取用户已解锁成就 +func (h *growthHandler) getUserAchievements(userID uint) []models.UserAchievement { + var achievements []models.UserAchievement + h.db.Where("user_id = ?", userID).Find(&achievements) + return achievements +} + +// parseExpBreakdown 解析经验明细JSON +func parseExpBreakdown(jsonStr string) ExpBreakdown { + if jsonStr == "" { + return ExpBreakdown{} + } + var breakdown ExpBreakdown + json.Unmarshal([]byte(jsonStr), &breakdown) + return breakdown +} + +// ============================================ +// 7. 成长规则页面构建函数 +// ============================================ + +// GetGrowthRules 获取成长规则 +func (h *growthHandler) GetGrowthRules(c *gin.Context) { + rules := gin.H{ + "title": "成长规则", + "subtitle": "了解如何获取经验,解锁成就与特权", + "sections": []map[string]interface{}{ + // 等级体系(包含特权) + { + "type": "level_system", + "icon": "📊", + "title": "等级体系", + "content": "成长等级从Lv.1到Lv.10,通过累计经验值提升等级。等级越高,称号越拉风,特权越多!\n\n重要说明:所有核心功能(待办、记账、心情记录、主题切换)完全免费开放,不受等级限制!等级特权只是额外的荣誉展示和增值服务。", + "levels": buildLevelList(), + }, + // 经验获取 + { + "type": "exp_rules", + "icon": "⭐", + "title": "经验获取", + "content": "每天完成待办任务、记账、记录心情、复盘记录都可以获得经验!", + "rules": buildExpRulesList(), + "limit": fmt.Sprintf("每日经验上限:%d EXP", dailyCap), + }, + // 连续打卡加成 + { + "type": "streak_bonus", + "icon": "🔥", + "title": "连续打卡加成", + "content": "连续记录天数越多,每天获得的额外加成越高!", + "formula": "连续加成 = 连续天数 × 基础加成", + "rules": buildStreakBonusList(), + "tip": "断签后连续天数不会完全清零,而是根据断签前的连续天数保留一部分", + }, + // 新用户保护期 + { + "type": "protection", + "icon": "🛡️", + "title": "新用户保护期", + "content": "新用户前7天享受保护期加成!", + "bonus": "保护期内额外获得50%经验加成", + "detail": "保护期结束后,连续天数中断时,只会减少部分天数,不会完全重置", + "example": "例如:连续50天后断签,只保留15天连续(而非清零),保护期结束则完全按规则计算", + }, + // 成就系统 + { + "type": "achievement_system", + "icon": "🎖️", + "title": "成就系统", + "content": "完成特定目标可解锁成就,获得额外经验奖励!", + "categories": buildAchievementCategories(), + "achievements": buildAchievementList(), + "rarityDesc": "稀有度从低到高:初级 → 中级 → 高级 → 稀有 → 史诗 → 传说", + }, + // 快速升级秘籍 + { + "type": "tips", + "icon": "💡", + "title": "快速升级秘籍", + "tips": GrowthTips, + }, + // 常见问题 + { + "type": "faq", + "icon": "❓", + "title": "常见问题", + "questions": buildFAQList(), + }, + }, + } + + utils.Ok(c, rules) +} + +// buildLevelList 构建等级列表(包含特权信息) +func buildLevelList() []map[string]interface{} { + levels := make([]map[string]interface{}, 0, len(LevelConfigs)) + for i := 1; i <= len(LevelConfigs); i++ { + config := LevelConfigs[i] + levels = append(levels, map[string]interface{}{ + "level": i, + "title": config.Title, + "exp": config.Exp, + "color": config.Color, + "icon": config.Icon, + "privileges": config.Privileges, + }) + } + return levels +} + +// buildExpRulesList 构建经验规则列表 +func buildExpRulesList() []map[string]interface{} { + rules := make([]map[string]interface{}, 0, len(ExpRuleConfigs)) + for _, rule := range ExpRuleConfigs { + rules = append(rules, map[string]interface{}{ + "icon": rule.Icon, + "name": rule.Name, + "desc": rule.Desc, + "exp": rule.Exp, + "detail": rule.Detail, + }) + } + return rules +} + +// buildStreakBonusList 构建连续打卡加成列表 +func buildStreakBonusList() []map[string]interface{} { + rules := make([]map[string]interface{}, 0, len(StreakBonusRules)) + for _, rule := range StreakBonusRules { + rules = append(rules, map[string]interface{}{ + "range": rule.Range, + "bonus": fmt.Sprintf("%d EXP/天", rule.Bonus), + "color": rule.Color, + }) + } + return rules +} + +// buildAchievementCategories 构建成就分类列表 +func buildAchievementCategories() []map[string]interface{} { + categories := make([]map[string]interface{}, 0, len(AchievementCategories)) + for _, cat := range AchievementCategories { + categories = append(categories, map[string]interface{}{ + "key": cat.Key, + "name": cat.Name, + "icon": cat.Icon, + }) + } + return categories +} + +// buildAchievementList 构建成就列表 +func buildAchievementList() []map[string]interface{} { + achievements := make([]map[string]interface{}, 0, len(AchievementConfigs)) + for _, config := range AchievementConfigs { + rarity := RarityConfigs[config.Rarity] + achievements = append(achievements, map[string]interface{}{ + "id": config.ID, + "name": config.Name, + "desc": config.Description, + "icon": config.Icon, + "exp": config.ExpReward, + "rarity": rarity.Name, + "category": config.Category, + }) + } + return achievements +} + +// buildFAQList 构建常见问题列表 +func buildFAQList() []FAQItem { + return GrowthFAQ +} + +// calculateCollectionProgress 计算收集进度 +func calculateCollectionProgress(achievements map[string]Achievement, unlockedMap map[string]bool) map[string]map[string]int { + progress := make(map[string]map[string]int) + + for _, cat := range AchievementCategories { + progress[cat.Key] = make(map[string]int) + progress[cat.Key]["total"] = 0 + progress[cat.Key]["unlocked"] = 0 + } + + for id, config := range achievements { + if _, ok := progress[config.Category]; !ok { + continue + } + progress[config.Category]["total"] += 1 + if unlockedMap[id] { + progress[config.Category]["unlocked"] += 1 + } + } + + return progress +} + +// MockGrowthRewards 生成模拟成长奖励数据(用于前端调试) +// levelUp: 是否升级 +// hasAchievement: 是否获得成就 +func MockGrowthRewards(levelUp bool, hasAchievement bool) *GrowthRewards { + baseLevel := 2 + achievements := []map[string]any{} + + if hasAchievement { + achievements = append(achievements, map[string]any{ + "id": "streak_7", + "name": "一周打卡", + "icon": "🌟", + "description": "连续记录7天", + "expReward": 50, + "rarity": "secondary", + "rarityName": "中级", + "rarityColor": "#22C55E", + "rarityEffect": "glow", + "category": "streak", + }) + } + + if levelUp { + baseLevel = 3 + } + + levelConfig := LevelConfigs[baseLevel] + nextLevelConfig := LevelConfigs[baseLevel+1] + + return &GrowthRewards{ + ExpGained: 15, + TotalExp: levelConfig.Exp + 50, + LevelUp: levelUp, + OldLevel: baseLevel - 1, + CurrentLevel: baseLevel, + LevelTitle: levelConfig.Title, + LevelIcon: levelConfig.Icon, + LevelColor: levelConfig.Color, + NextLevelExp: nextLevelConfig.Exp, + NewPrivileges: levelConfig.Privileges, + NewAchievements: achievements, + } +} + +func (h *growthHandler) getTodayTodoCount(userID uint, today string) int { + var count int64 + h.db.Model(&models.Todo{}).Where("user_id = ? AND date = ?", userID, today).Count(&count) + return int(count) +} + +func (h *growthHandler) getMoodType(userID uint, today string) string { + var mood models.Mood + err := h.db.Where("user_id = ? AND date = ?", userID, today).First(&mood).Error + if err != nil { + return "" + } + happyEmojis := []string{"😊", "😄", "😎", "🥰", "🥳", "😇", "💪", "😘", "🤩", "😋"} + for _, emoji := range happyEmojis { + if mood.Emoji == emoji { + return "happy" + } + } + return "normal" +} + +func (h *growthHandler) hasPerfectDay(userID uint) bool { + var count int64 + h.db.Model(&models.DailyProgress{}).Where("user_id = ? AND perfect_day = ?", userID, true).Count(&count) + return count > 0 +} + +func (h *growthHandler) hasPerfectWeek(userID uint) bool { + var progressList []models.DailyProgress + sevenDaysAgo := time.Now().AddDate(0, 0, -7).Format("2006-01-02") + h.db.Where("user_id = ? AND record_date >= ? AND perfect_day = ?", userID, sevenDaysAgo, true).Find(&progressList) + return len(progressList) >= 7 +} diff --git a/api/app/mood.go b/api/app/mood.go new file mode 100644 index 0000000..82f9f68 --- /dev/null +++ b/api/app/mood.go @@ -0,0 +1,411 @@ +// 心情接口 +package app + +import ( + "context" + "fmt" + "math/rand" + "os" + "simple-memo/global" + "simple-memo/models" + "simple-memo/utils" + "time" + + "gorm.io/gorm" + + "github.com/gin-gonic/gin" +) + +// -------------------------- 1. 定义 Handler 接口 -------------------------- +// MoodHandler 心情模块接口定义 +type MoodHandler interface { + GetMoodByDate(c *gin.Context) + SaveMood(c *gin.Context) + GetMoodList(c *gin.Context) + DeleteMood(c *gin.Context) + MoodStat(c *gin.Context) // 心情统计 +} + +// -------------------------- 2. 实现结构体 -------------------------- +// moodHandler 接口实现结构体 +type moodHandler struct { + db *gorm.DB +} + +// NewMoodHandler 创建心情处理器 +func NewMoodHandler() MoodHandler { + return &moodHandler{ + db: global.DB, + } +} + +// -------------------------- 3. 请求结构体 -------------------------- +// GetMoodByDateReq 获取某天心情请求 +type GetMoodByDateReq struct { + Date string `json:"date" binding:"required"` +} + +// SaveMoodReq 保存心情请求 +type SaveMoodReq struct { + ID uint `json:"id"` // 编辑时传入 + Date string `json:"date" binding:"required"` + Emoji string `json:"emoji" binding:"required"` + Content string `json:"content"` +} + +// GetMoodListReq 获取心情列表请求 +type GetMoodListReq struct { + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` +} + +// DeleteMoodReq 删除心情请求 +type DeleteMoodReq struct { + ID uint `json:"id" binding:"required"` +} + +// MoodStatReq 心情统计请求 +type MoodStatReq struct { + Month string `json:"month" binding:"required"` // YYYY-MM +} + +// -------------------------- 4. 接口实现 -------------------------- + +// MoodCalendarItem 日历心情项(优化后的返回结构) +type MoodCalendarItem struct { + ID uint `json:"id"` + Date string `json:"date"` + Emoji string `json:"emoji"` + Content string `json:"content"` + AIStatus int `json:"ai_status"` // 0无 1生成中 2成功 3失败 + AIText string `json:"ai_text"` + AIImageURL string `json:"ai_image_url"` + AIGenerateCount int `json:"ai_generate_count"` +} + +// GetMoodByDate 获取某天的心情(包含AI生成内容) +func (h *moodHandler) GetMoodByDate(c *gin.Context) { + userID := c.GetUint("userID") + var req GetMoodByDateReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + // 使用 GORM Joins 联表查询 + var result MoodCalendarItem + h.db.Model(&models.Mood{}). + Select(` + sm_mood.ID, + sm_mood.date, + sm_mood.emoji, + sm_mood.content, + COALESCE(sm_mood_ai_generations.status, 0) as ai_status, + COALESCE(sm_mood_ai_generations.ai_text, '') as ai_text, + COALESCE(sm_mood_ai_generations.ai_image_url, '') as ai_image_url, + COALESCE(sm_mood_ai_generations.generate_count, 0) as ai_generate_count + `). + Joins("LEFT JOIN sm_mood_ai_generations ON sm_mood.id = sm_mood_ai_generations.mood_id"). + Where("sm_mood.user_id = ? AND sm_mood.date = ?", userID, req.Date). + Scan(&result) + + // 检查是否找到记录 + if result.ID == 0 { + utils.Ok(c, nil) + return + } + + utils.Ok(c, result) +} + +// SaveMood 保存心情 +func (h *moodHandler) SaveMood(c *gin.Context) { + userID := c.GetUint("userID") + var req SaveMoodReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + // 敏感词过滤 + filteredContent := utils.FilterSensitive(req.Content) + + var mood models.Mood + isNewRecord := false + + // 如果传入了 id,先尝试按 id 查找 + if req.ID > 0 { + err := h.db.Where("id = ? AND user_id = ?", req.ID, userID).First(&mood).Error + if err == nil { + // 编辑模式 + mood.Emoji = req.Emoji + mood.Content = filteredContent + if err := h.db.Save(&mood).Error; err != nil { + utils.Fail(c, "保存失败") + return + } + } else { + // id 找不到,按日期查找 + err = h.db.Where("user_id = ? AND date = ?", userID, req.Date).First(&mood).Error + if err == nil { + mood.Emoji = req.Emoji + mood.Content = filteredContent + if err := h.db.Save(&mood).Error; err != nil { + utils.Fail(c, "保存失败") + return + } + } else { + // 新增模式 + mood = models.Mood{ + UserID: userID, + Date: req.Date, + Emoji: req.Emoji, + Content: filteredContent, + } + if err := h.db.Create(&mood).Error; err != nil { + utils.Fail(c, "保存失败") + return + } + isNewRecord = true + } + } + } else { + // 没有传入 id,按日期查找 + err := h.db.Where("user_id = ? AND date = ?", userID, req.Date).First(&mood).Error + if err == nil { + // 更新已有记录 + mood.Emoji = req.Emoji + mood.Content = filteredContent + if err := h.db.Save(&mood).Error; err != nil { + utils.Fail(c, "保存失败") + return + } + isNewRecord = true + } else { + // 新增记录 + mood = models.Mood{ + UserID: userID, + Date: req.Date, + Emoji: req.Emoji, + Content: filteredContent, + } + if err := h.db.Create(&mood).Error; err != nil { + utils.Fail(c, "保存失败") + return + } + isNewRecord = true + } + } + + var growthResult any + debugMode := os.Getenv("DEBUG_MODE") + if isNewRecord { + // ==================== 调试模式 ==================== + if debugMode == "true" { + rand.Seed(time.Now().UnixNano()) + levelUp := true // rand.Intn(10) < 2 // 20%概率升级 + hasAchievement := true // rand.Intn(10) < 3 // 30%概率获得成就 + growthResult = MockGrowthRewards(levelUp, hasAchievement) + } else { + // 生产模式:真实调用 + today := time.Now().Format("2006-01-02") + if req.Date == today { + ctx := context.Background() + key := fmt.Sprintf("mood_count:%d:%s", userID, today) + exists, _ := global.Redis.Exists(ctx, key).Result() + if exists == 0 { + growthHandler := NewGrowthHandler() + growthResult = growthHandler.AddExpInternal(userID, "mood", fmt.Sprintf("%d", mood.ID), nil) + } + } + } + // ================================================== + } + + // 异步触发AI生成(仅新记录触发,编辑不触发) + if isNewRecord { + GenerateMoodAIContent(mood.ID, userID, mood.Emoji, mood.Content, mood.Date) + } + + // 查询 AI 生成记录并填充 + var aiGen models.MoodAIGeneration + h.db.Where("mood_id = ?", mood.ID).First(&aiGen) + mood.AIGen = aiGen + + utils.Ok(c, gin.H{ + "success": true, + "growth": growthResult, + "mood": mood, + "debugMode": debugMode, + "isDebug": debugMode == "true", + "isNewRecord": isNewRecord, + }) +} + +// GetMoodList 获取心情列表(包含AI生成内容) +func (h *moodHandler) GetMoodList(c *gin.Context) { + userID := c.GetUint("userID") + var req GetMoodListReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + // 构建基础查询 + query := h.db.Model(&models.Mood{}). + Select(` + sm_mood.ID, + sm_mood.date, + sm_mood.emoji, + sm_mood.content, + COALESCE(sm_mood_ai_generations.status, 0) as ai_status, + COALESCE(sm_mood_ai_generations.ai_text, '') as ai_text, + COALESCE(sm_mood_ai_generations.ai_image_url, '') as ai_image_url, + COALESCE(sm_mood_ai_generations.generate_count, 0) as ai_generate_count + `). + Joins("LEFT JOIN sm_mood_ai_generations ON sm_mood.id = sm_mood_ai_generations.mood_id"). + Where("sm_mood.user_id = ?", userID) + + // 日期筛选 + if req.StartDate != "" && req.EndDate != "" { + query = query.Where("sm_mood.date BETWEEN ? AND ?", req.StartDate, req.EndDate) + } else if req.StartDate != "" { + query = query.Where("sm_mood.date >= ?", req.StartDate) + } else if req.EndDate != "" { + query = query.Where("sm_mood.date <= ?", req.EndDate) + } + + var results []MoodCalendarItem + if err := query.Order("sm_mood.date DESC").Scan(&results).Error; err != nil { + utils.Fail(c, "获取失败") + return + } + + utils.Ok(c, results) +} + +// DeleteMood 删除心情 +func (h *moodHandler) DeleteMood(c *gin.Context) { + userID := c.GetUint("userID") + var req DeleteMoodReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + // 查询心情信息(用于扣除积分) + var mood models.Mood + err := h.db.Where("id = ? AND user_id = ?", req.ID, userID).First(&mood).Error + if err != nil { + utils.Fail(c, "心情记录不存在") + return + } + + // 删除心情 + err = h.db.Where("id = ? AND user_id = ?", req.ID, userID).Delete(&models.Mood{}).Error + if err != nil { + utils.Fail(c, "删除失败") + return + } + + // 扣除今天添加时获得的积分 + growthHandler := NewGrowthHandler() + today := time.Now().Format("2006-01-02") + deducted := growthHandler.DeductExpInternal(userID, "mood", fmt.Sprintf("%d", req.ID), today) + + utils.Ok(c, gin.H{ + "deducted_exp": deducted, + }) +} + +// MoodStat 心情统计 +func (h *moodHandler) MoodStat(c *gin.Context) { + userID := c.GetUint("userID") + var req MoodStatReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + // 计算月份范围 + startDate := req.Month + "-01" + year, month := 0, 0 + fmt.Sscanf(req.Month, "%d-%d", &year, &month) + _, lastDay := utils.GetMonthLastDay(year, month) + endDate := fmt.Sprintf("%s-%02d", req.Month, lastDay) + + // 查询该月的心情记录 + var moods []models.Mood + h.db.Where("user_id = ? AND date BETWEEN ? AND ?", userID, startDate, endDate). + Order("date ASC"). + Find(&moods) + + // 心情分布 + moodDistribution := make(map[string]int) + for _, m := range moods { + moodDistribution[m.Emoji]++ + } + + // 计算总天数和已记录天数 + totalDays := lastDay + recordedDays := len(moods) + + // 周趋势(按周分组) + weeklyTrend := []gin.H{} + weekMap := make(map[string][]models.Mood) + + for _, m := range moods { + // 解析日期,计算是第几周 + t, _ := time.Parse("2006-01-02", m.Date) + _, week := t.ISOWeek() + weekKey := fmt.Sprintf("W%d", week) + weekMap[weekKey] = append(weekMap[weekKey], m) + } + + // 计算每周的平均分数 + // 心情emoji映射到分数(简单映射) + emojiScore := map[string]float64{ + "😊": 5.0, "😄": 5.0, "😎": 4.5, + "😌": 4.0, "🙂": 3.5, + "😐": 3.0, + "😔": 2.5, "😢": 2.0, + "😤": 2.0, "😰": 1.5, + "😴": 3.0, "🤔": 3.0, + } + + for weekKey, weekMoods := range weekMap { + var totalScore float64 + for _, m := range weekMoods { + if score, ok := emojiScore[m.Emoji]; ok { + totalScore += score + } else { + totalScore += 3.0 // 默认中等分数 + } + } + avgScore := 0.0 + if len(weekMoods) > 0 { + avgScore = totalScore / float64(len(weekMoods)) + } + weeklyTrend = append(weeklyTrend, gin.H{ + "week": weekKey, + "avg_score": avgScore, + }) + } + + // 高频词汇(这里简化处理,返回固定示例) + topPhrases := []string{"开心", "平静", "工作顺利", "充实", "放松"} + + utils.Ok(c, gin.H{ + "month": req.Month, + "total_days": totalDays, + "recorded_days": recordedDays, + "mood_distribution": moodDistribution, + "weekly_trend": weeklyTrend, + "top_phrases": topPhrases, + }) +} diff --git a/api/app/review.go b/api/app/review.go new file mode 100644 index 0000000..0b30535 --- /dev/null +++ b/api/app/review.go @@ -0,0 +1,266 @@ +// 复盘接口(仅 KPT 法) +package app + +import ( + "fmt" + "math/rand" + "os" + "simple-memo/global" + "simple-memo/models" + "simple-memo/utils" + "time" + + "gorm.io/gorm" + + "github.com/gin-gonic/gin" +) + +// -------------------------- 1. 定义 Handler 接口 -------------------------- +type ReviewHandler interface { + SaveReview(c *gin.Context) // 保存复盘 + GetReview(c *gin.Context) // 获取单日复盘 + ReviewList(c *gin.Context) // 获取复盘列表 + DeleteReview(c *gin.Context) // 删除复盘 +} + +// -------------------------- 2. 实现结构体 -------------------------- +type reviewHandler struct { + db *gorm.DB +} + +func NewReviewHandler() ReviewHandler { + return &reviewHandler{ + db: global.DB, + } +} + +// -------------------------- 3. 请求结构体 -------------------------- + +// SaveReviewReq 保存复盘请求 +type SaveReviewReq struct { + Date string `json:"date" binding:"required"` // YYYY-MM-DD + Kpt SaveReviewKPTDto `json:"kpt"` +} +type SaveReviewKPTDto struct { + Keep string `json:"keep"` + Problem string `json:"problem"` + Try string `json:"try"` +} + +// GetReviewReq 获取单日复盘请求 +type GetReviewReq struct { + Date string `json:"date" binding:"required"` +} + +// ReviewListReq 获取复盘列表请求 +type ReviewListReq struct { + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + Keyword string `json:"keyword"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// DeleteReviewReq 删除复盘请求 +type DeleteReviewReq struct { + ID uint `json:"id" binding:"required"` +} + +// -------------------------- 4. 接口实现 -------------------------- + +// SaveReview 保存复盘 +func (h *reviewHandler) SaveReview(c *gin.Context) { + userID := c.GetUint("userID") + var req SaveReviewReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + var review models.Review + isNewRecord := false + + err := h.db.Where("user_id = ? AND date = ?", userID, req.Date).First(&review).Error + if err == gorm.ErrRecordNotFound { + review = models.Review{ + UserID: userID, + Date: req.Date, + Keep: utils.FilterSensitive(req.Kpt.Keep), + Problem: utils.FilterSensitive(req.Kpt.Problem), + Try: utils.FilterSensitive(req.Kpt.Try), + } + isNewRecord = true + } else if err != nil { + utils.Fail(c, "查询失败") + return + } else { + review.Keep = utils.FilterSensitive(req.Kpt.Keep) + review.Problem = utils.FilterSensitive(req.Kpt.Problem) + review.Try = utils.FilterSensitive(req.Kpt.Try) + } + + if isNewRecord { + if err := h.db.Create(&review).Error; err != nil { + utils.Fail(c, "保存失败") + return + } + } else { + if err := h.db.Save(&review).Error; err != nil { + utils.Fail(c, "保存失败") + return + } + } + + var growthResult any + debugMode := os.Getenv("DEBUG_MODE") + if isNewRecord { + today := time.Now().Format("2006-01-02") + if req.Date == today { + if debugMode == "true" { + rand.Seed(time.Now().UnixNano()) + levelUp := rand.Intn(10) < 2 + hasAchievement := rand.Intn(10) < 3 + growthResult = MockGrowthRewards(levelUp, hasAchievement) + } else { + growthHandler := NewGrowthHandler() + growthResult = growthHandler.AddExpInternal(userID, "review", fmt.Sprintf("%d", review.ID), nil) + } + } + } + + utils.Ok(c, gin.H{ + "success": true, + "growth": growthResult, + "review": review, + "isNewRecord": isNewRecord, + }) +} + +// GetReview 获取单日复盘 +func (h *reviewHandler) GetReview(c *gin.Context) { + userID := c.GetUint("userID") + var req GetReviewReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + var review models.Review + err := h.db.Where("user_id = ? AND date = ?", userID, req.Date).First(&review).Error + if err == gorm.ErrRecordNotFound { + utils.Ok(c, nil) + return + } else if err != nil { + utils.Fail(c, "查询失败") + return + } + + utils.Ok(c, review) +} + +// ReviewList 获取复盘列表 +func (h *reviewHandler) ReviewList(c *gin.Context) { + userID := c.GetUint("userID") + var req ReviewListReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 20 + } + + db := h.db.Model(&models.Review{}).Where("user_id = ?", userID) + + if req.StartDate != "" && req.EndDate != "" { + db = db.Where("date BETWEEN ? AND ?", req.StartDate, req.EndDate) + } else if req.StartDate != "" { + db = db.Where("date >= ?", req.StartDate) + } else if req.EndDate != "" { + db = db.Where("date <= ?", req.EndDate) + } + + if req.Keyword != "" { + keyword := "%" + req.Keyword + "%" + db = db.Where("(keep LIKE ? OR problem LIKE ? OR try LIKE ?)", keyword, keyword, keyword) + } + + var total int64 + db.Count(&total) + + var list []models.Review + offset := (req.Page - 1) * req.PageSize + err := db.Order("date DESC").Offset(offset).Limit(req.PageSize).Find(&list).Error + if err != nil { + utils.Fail(c, "查询失败") + return + } + + type ReviewListItem struct { + ID uint `json:"id"` + Date string `json:"date"` + Summary string `json:"summary"` + } + + result := make([]ReviewListItem, len(list)) + for i, r := range list { + summary := r.Keep + if len(summary) > 50 { + summary = summary[:50] + "..." + } + result[i] = ReviewListItem{ + ID: r.ID, + Date: r.Date, + Summary: summary, + } + } + + utils.Ok(c, gin.H{ + "list": result, + "total": total, + "page": req.Page, + "pageSize": req.PageSize, + }) +} + +// DeleteReview 删除复盘 +func (h *reviewHandler) DeleteReview(c *gin.Context) { + userID := c.GetUint("userID") + var req DeleteReviewReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + var review models.Review + err := h.db.Where("id = ? AND user_id = ?", req.ID, userID).First(&review).Error + if err == gorm.ErrRecordNotFound { + utils.Fail(c, "复盘不存在") + return + } else if err != nil { + utils.Fail(c, "查询失败") + return + } + + if err := h.db.Delete(&review).Error; err != nil { + utils.Fail(c, "删除失败") + return + } + + growthHandler := NewGrowthHandler() + today := time.Now().Format("2006-01-02") + deducted := growthHandler.DeductExpInternal(userID, "review", fmt.Sprintf("%d", req.ID), today) + + utils.Ok(c, gin.H{ + "success": true, + "deducted_exp": deducted, + }) +} diff --git a/api/app/todo.go b/api/app/todo.go new file mode 100644 index 0000000..e3c81ee --- /dev/null +++ b/api/app/todo.go @@ -0,0 +1,436 @@ +// 待办接口 +package app + +import ( + "fmt" + "math/rand" + "os" + "simple-memo/global" + "simple-memo/models" + "simple-memo/utils" + "time" + + "gorm.io/gorm" + + "github.com/gin-gonic/gin" +) + +// -------------------------- 1. 定义 Handler 接口 -------------------------- +// TodoHandler 待办模块接口定义 +type TodoHandler interface { + AddTodo(c *gin.Context) // 添加待办 + TodoList(c *gin.Context) // 获取待办列表 + DeleteTodo(c *gin.Context) // 删除待办 + DoneTodo(c *gin.Context) // 完成/取消完成待办 + EditTodo(c *gin.Context) // 编辑待办 + TodoStat(c *gin.Context) // 待办统计 +} + +// -------------------------- 2. 实现结构体 -------------------------- +// todoHandler 接口实现结构体 +type todoHandler struct { + db *gorm.DB // 数据库依赖 +} + +// NewTodoHandler 创建待办处理器 +func NewTodoHandler() TodoHandler { + return &todoHandler{ + db: global.DB, + } +} + +// -------------------------- 3. 请求结构体 -------------------------- +// TodoListReq 列表请求参数 +type TodoListReq struct { + Search string `json:"search"` + Status string `json:"status"` + Date string `json:"date"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` +} + +// DeleteTodoReq 删除待办请求 +type DeleteTodoReq struct { + ID uint `json:"id"` +} + +// DoneTodoReq 完成待办请求 +type DoneTodoReq struct { + ID uint `json:"id"` + Status uint `json:"status"` // 0-取消完成 1-完成 +} + +// EditTodoReq 编辑待办请求(与model对齐) +type EditTodoReq struct { + ID uint `json:"id"` + Title string `json:"title"` + Category string `json:"category"` + Priority int `json:"priority"` + Remark string `json:"remark"` + Date string `json:"date"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` +} + +// TodoStatReq 待办统计请求 +type TodoStatReq struct { + StartDate string `json:"start_date" binding:"required"` // 开始日期 + EndDate string `json:"end_date" binding:"required"` // 结束日期 +} + +// -------------------------- 4. 接口实现 -------------------------- + +// GetTodoListForCalendar 获取日历月份的待办列表(内部方法) +func (h *todoHandler) GetTodoListForCalendar(c *gin.Context, year, month int) []interface{} { + userID := c.GetUint("userID") + + // 计算月份范围 + startDate := fmt.Sprintf("%d-%02d-01", year, month) + // 计算月末 + _, lastDay := utils.GetMonthLastDay(year, month) + endDate := fmt.Sprintf("%d-%02d-%02d", year, month, lastDay) + + var list []models.Todo + h.db.Model(&models.Todo{}). + Where("user_id = ? AND date BETWEEN ? AND ?", userID, startDate, endDate). + Order("date ASC, id ASC"). + Find(&list) + + // 转换结果 + result := make([]interface{}, len(list)) + for i, v := range list { + if v.Done == 0 && v.Date < time.Now().Format("2006-01-02") { + v.IsOverdue = 1 + } else { + v.IsOverdue = 0 + } + result[i] = v + } + return result +} + +// AddTodo 添加待办 +func (h *todoHandler) AddTodo(c *gin.Context) { + userID := c.GetUint("userID") + var todo models.Todo + + if err := c.ShouldBindJSON(&todo); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + // 敏感词过滤 + todo.Title = utils.FilterSensitive(todo.Title) + todo.Remark = utils.FilterSensitive(todo.Remark) + + // 检查每日限制(最多100个待办/天) + today := time.Now().Format("2006-01-02") + var todayCount int64 + if err := h.db.Model(&models.Todo{}).Where("user_id = ? AND date = ? AND deleted_at IS NULL", userID, today).Count(&todayCount).Error; err != nil { + utils.Fail(c, "系统错误") + return + } + if todayCount >= 100 { + utils.Fail(c, "今日待办数量已达上限(100个)") + return + } + + todo.UserID = userID + if err := h.db.Create(&todo).Error; err != nil { + utils.Fail(c, "添加失败") + return + } + + utils.Ok(c, nil) +} + +// TodoList 获取待办列表 +func (h *todoHandler) TodoList(c *gin.Context) { + userID := c.GetUint("userID") + + var req TodoListReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + // 构建查询 + db := h.db.Model(&models.Todo{}).Where("user_id = ?", userID) + + // 搜索 + if req.Search != "" { + db = db.Where("title LIKE ? OR remark LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%") + } + + // 状态筛选 + switch req.Status { + case "undone": + db = db.Where("done = 0") + case "done": + db = db.Where("done = 1") + } + + // 日期 + now := time.Now() + today := now.Format("2006-01-02") + + startDate := req.StartDate + endDate := req.EndDate + if startDate != "" && endDate != "" { + db = db.Where("date BETWEEN ? AND ?", startDate, endDate) + } else if startDate != "" { + db = db.Where("date >= ?", startDate) + } else if endDate != "" { + db = db.Where("date <= ?", endDate) + } else if req.Date != "" { + db = db.Where("date = ?", req.Date) + } else { + db = db.Where("date = ?", today) + } + + // 排序 + db = db.Order("date ASC, id ASC") + + // 查询 + var list []models.Todo + if err := db.Find(&list).Error; err != nil { + utils.Fail(c, "获取失败") + return + } + + // 逾期状态 + for i := range list { + if list[i].Done == 0 && list[i].Date < today { + list[i].IsOverdue = 1 + } else { + list[i].IsOverdue = 0 + } + } + + utils.Ok(c, list) +} + +// DeleteTodo 删除待办 +func (h *todoHandler) DeleteTodo(c *gin.Context) { + userID := c.GetUint("userID") + var req DeleteTodoReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + // 查询待办信息(用于扣除积分) + var todo models.Todo + err := h.db.Where("id = ? AND user_id = ?", req.ID, userID).First(&todo).Error + if err != nil { + utils.Fail(c, "待办不存在") + return + } + + // 删除待办 + err = h.db.Where("id = ? AND user_id = ?", req.ID, userID).Delete(&models.Todo{}).Error + if err != nil { + utils.Fail(c, "删除失败") + return + } + + // 扣除今天添加时获得的积分 + growthHandler := NewGrowthHandler() + today := time.Now().Format("2006-01-02") + deducted := growthHandler.DeductExpInternal(userID, "task", fmt.Sprintf("%d", req.ID), today) + + utils.Ok(c, gin.H{ + "deducted_exp": deducted, + }) +} + +// DoneTodo 完成/取消完成待办 +func (h *todoHandler) DoneTodo(c *gin.Context) { + userID := c.GetUint("userID") + var req DoneTodoReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + if req.Status != 0 && req.Status != 1 { + utils.Fail(c, "参数错误") + return + } + + // 先查询当前状态 + var todo models.Todo + err := h.db.Where("id = ? AND user_id = ?", req.ID, userID).First(&todo).Error + if err != nil { + utils.Fail(c, "待办不存在") + return + } + + oldDone := todo.Done + + // 更新状态 + err = h.db.Model(&models.Todo{}). + Where("id = ? AND user_id = ?", req.ID, userID). + Update("done", req.Status).Error + if err != nil { + utils.Fail(c, "操作失败") + return + } + + var result any + debugMode := os.Getenv("DEBUG_MODE") + // 只有从未完成变成完成(0→1)的时候才加积分,并且这个待办之前没有加过积分 + if oldDone == 0 && req.Status == 1 { + // 查询是否已经给这个待办加过积分 + var count int64 + h.db.Model(&models.ExpChangeLog{}). + Where("user_id = ? AND source = ? AND source_id = ?", userID, "task", fmt.Sprintf("%d", req.ID)). + Count(&count) + + // 只有之前没加过积分才加 + if count == 0 { + // ==================== 调试模式 ==================== + if debugMode == "true" { + rand.Seed(time.Now().UnixNano()) + levelUp := rand.Intn(10) < 3 // 30%概率升级 + hasAchievement := rand.Intn(10) < 4 // 40%概率获得成就 + result = MockGrowthRewards(levelUp, hasAchievement) + } else { + // 生产模式:真实调用 + growthHandler := NewGrowthHandler() + result = growthHandler.AddExpInternal(userID, "task", fmt.Sprintf("%d", req.ID), nil) + } + // ================================================== + } + } + + utils.Ok(c, gin.H{ + "success": true, + "growth": result, + "debugMode": debugMode, + "isDebug": debugMode == "true", + }) +} + +// EditTodo 编辑待办 +func (h *todoHandler) EditTodo(c *gin.Context) { + userID := c.GetUint("userID") + var req EditTodoReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + // 敏感词过滤 + filteredTitle := utils.FilterSensitive(req.Title) + filteredRemark := utils.FilterSensitive(req.Remark) + + data := map[string]any{ + "title": filteredTitle, + "remark": filteredRemark, + "category": req.Category, + "priority": req.Priority, + "date": req.Date, + "start_time": req.StartTime, + "end_time": req.EndTime, + } + + err := h.db.Model(&models.Todo{}). + Where("id = ? AND user_id = ?", req.ID, userID). + Updates(data).Error + if err != nil { + utils.Fail(c, "编辑失败") + return + } + + utils.Ok(c, nil) +} + +// TodoStat 待办统计 +func (h *todoHandler) TodoStat(c *gin.Context) { + userID := c.GetUint("userID") + var req TodoStatReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + // 总体统计 + var total, done, undone int64 + h.db.Model(&models.Todo{}). + Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate). + Count(&total) + h.db.Model(&models.Todo{}). + Where("user_id = ? AND date BETWEEN ? AND ? AND done = 1", userID, req.StartDate, req.EndDate). + Count(&done) + undone = total - done + + // 完成率 + completionRate := 0.0 + if total > 0 { + completionRate = float64(done) / float64(total) * 100 + } + + // 按分类统计 + type CategoryStat struct { + Total int64 `json:"total"` + Done int64 `json:"done"` + } + byCategory := make(map[string]CategoryStat) + + var categoryResults []struct { + Category string + Total int64 + Done int64 + } + h.db.Model(&models.Todo{}). + Select("category, COUNT(*) as total, SUM(CASE WHEN done = 1 THEN 1 ELSE 0 END) as done"). + Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate). + Group("category"). + Scan(&categoryResults) + + for _, r := range categoryResults { + byCategory[r.Category] = CategoryStat{ + Total: r.Total, + Done: r.Done, + } + } + + // 按优先级统计 + type PriorityStat struct { + Total int64 `json:"total"` + Done int64 `json:"done"` + } + byPriority := make(map[string]PriorityStat) + + var priorityResults []struct { + Priority int + Total int64 + Done int64 + } + h.db.Model(&models.Todo{}). + Select("priority, COUNT(*) as total, SUM(CASE WHEN done = 1 THEN 1 ELSE 0 END) as done"). + Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate). + Group("priority"). + Scan(&priorityResults) + + for _, r := range priorityResults { + key := fmt.Sprintf("%d", r.Priority) + byPriority[key] = PriorityStat{ + Total: r.Total, + Done: r.Done, + } + } + + utils.Ok(c, gin.H{ + "total": total, + "done": done, + "undone": undone, + "completion_rate": completionRate, + "by_category": byCategory, + "by_priority": byPriority, + }) +} diff --git a/api/app/user.go b/api/app/user.go new file mode 100644 index 0000000..81d4ad5 --- /dev/null +++ b/api/app/user.go @@ -0,0 +1,982 @@ +// 小程序用户接口 +package app + +import ( + "context" + "errors" + "fmt" + "simple-memo/global" + "simple-memo/models" + "simple-memo/utils" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// -------------------------- 1. 定义 Handler 接口 -------------------------- +// UserHandler 用户模块接口定义 +type UserHandler interface { + Login(c *gin.Context) // 微信登录 + Logout(c *gin.Context) // 退出登录 + UserInfo(c *gin.Context) // 获取用户信息 + EditUser(c *gin.Context) // 修改用户信息 + SendEmailCode(c *gin.Context) // 发送邮箱验证码(支持多种场景) + SetEmail(c *gin.Context) // 设置邮箱(绑定邮箱) + EmailLogin(c *gin.Context) // 邮箱密码登录 + EmailRegister(c *gin.Context) // 邮箱注册 + ResetPassword(c *gin.Context) // 重置密码(未登录找回密码) + ChangePassword(c *gin.Context) // 修改密码(已登录场景) + CheckEmailExists(c *gin.Context) // 检查邮箱是否已注册 + BindWechat(c *gin.Context) // 绑定微信(Web端,网页授权code) + BindWechatMini(c *gin.Context) // 绑定微信(小程序端,wx.login code) +} + +// -------------------------- 2. 实现结构体(依赖注入DB) -------------------------- +// userHandler 接口实现结构体 +type userHandler struct { + db *gorm.DB // 注入数据库,方便测试 +} + +// NewUserHandler 创建用户处理器(对外暴露) +func NewUserHandler() UserHandler { + return &userHandler{ + db: global.DB, + } +} + +// -------------------------- 3. 请求结构体定义 -------------------------- +// LoginReq 登录参数 +type LoginReq struct { + Code string `json:"code"` +} + +type EditUserReq struct { + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Signature string `json:"signature"` +} + +// -------------------------- 4. 接口实现 -------------------------- + +// Login 微信登录 +func (h *userHandler) Login(c *gin.Context) { + var req LoginReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + if req.Code == "" { + utils.Fail(c, "code不能为空") + return + } + + // 调用微信接口获取 openid + wxResp, err := utils.MiniProgramCode2Session(req.Code) + if err != nil { + utils.Fail(c, "微信登录失败:"+err.Error()) + return + } + + // 查询或创建用户 + var user models.User + err = h.db.Where("openid = ?", wxResp.OpenID).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + // 创建新用户 + user = models.User{ + Openid: wxResp.OpenID, + UserCode: utils.GenerateUserCode(), + } + err = h.db.Create(&user).Error + } + if err != nil { + utils.Fail(c, "用户操作失败") + return + } + + // 生成 token + token, expiresAt, err := utils.GenerateTokenWithExpire(user.ID) + if err != nil { + utils.Fail(c, "token生成失败") + return + } + + // 返回数据 + utils.Ok(c, gin.H{ + "token": token, + "expiresAt": expiresAt, + "expiresIn": utils.GetTokenExpireSeconds(), + }) +} + +// Logout 退出登录 +func (h *userHandler) Logout(c *gin.Context) { + // 从上下文获取用户ID和token(JWTAuth中间件已注入) + userId, exists := c.Get("userID") + if !exists { + utils.Fail(c, "用户未登录") + return + } + token, tokenExists := c.Get("token") + if !tokenExists { + utils.Fail(c, "获取token失败") + return + } + + // 解析 token 获取过期时间 + claims, err := utils.ParseToken(token.(string)) + if err != nil { + utils.Fail(c, "token无效") + return + } + + // 计算剩余过期时间 + ttl := time.Until(claims.ExpiresAt.Time) + if ttl > 0 { + // 将 token 加入 Redis 黑名单 + ctx := context.Background() + err = global.Redis.Set(ctx, "token_blacklist:"+token.(string), "1", ttl).Err() + if err != nil { + global.Logger.Error("添加token到黑名单失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "退出登录失败") + return + } + global.Logger.Info("用户退出登录", append(utils.LogContextFields(c), zap.Uint("userID", userId.(uint)))...) + } + + utils.Ok(c, "退出登录成功") +} + +// UserInfo 获取用户信息 +func (h *userHandler) UserInfo(c *gin.Context) { + userId, exists := c.Get("userID") + if !exists { + utils.Fail(c, "用户未登录") + return + } + + var user models.User + err := h.db.First(&user, userId).Error + if err != nil { + utils.Fail(c, "获取用户信息失败") + return + } + + // 返回脱敏用户信息 + utils.Ok(c, gin.H{ + "id": user.ID, + "nickname": user.Nickname, + "avatar": user.Avatar, + "signature": user.Signature, + "email": user.Email, + "user_code": user.UserCode, + "is_bind_wechat": user.Openid != "", // 是否绑定微信 + "is_bind_email": user.Email != "", // 是否绑定邮箱 + "createdAt": user.CreatedAt, + }) +} + +// EditUser 修改用户信息 +func (h *userHandler) EditUser(c *gin.Context) { + userId, exists := c.Get("userID") + if !exists { + utils.Fail(c, "用户未登录") + c.Abort() + return + } + var editUserReq EditUserReq + if err := c.ShouldBindJSON(&editUserReq); err != nil { + utils.Fail(c, "参数解析失败") + return + } + // 如果 昵称或者头像为空,则不更新 + if editUserReq.Nickname == "" && editUserReq.Avatar == "" && editUserReq.Signature == "" { + utils.Ok(c, nil) + return + } + // 构建更新数据(支持同时更新多个字段) + data := make(map[string]any) + if editUserReq.Nickname != "" { + data["nickname"] = utils.FilterSensitive(editUserReq.Nickname) // 敏感词过滤 + } + if editUserReq.Avatar != "" { + data["avatar"] = editUserReq.Avatar + } + if editUserReq.Signature != "" { + data["signature"] = utils.FilterSensitive(editUserReq.Signature) // 敏感词过滤 + } + if len(data) == 0 { + utils.Ok(c, nil) + return + } + + err := h.db.Model(&models.User{}).Where("id = ?", userId).Updates(data).Error + if err != nil { + utils.Fail(c, "用户信息更新失败") + return + } + utils.Ok(c, nil) +} + +// -------------------------- 4. 邮箱验证接口 -------------------------- + +// SendEmailCodeReq 发送邮箱验证码请求 +type SendEmailCodeReq struct { + Email string `json:"email" binding:"required,email"` + Type string `json:"type" binding:"omitempty,oneof=register login bind reset_password change_email"` // type为空时:已登录状态下绑定邮箱 + CaptchaId string `json:"captchaId"` // 图形验证码ID(注册/登录/找回密码场景必填) + Captcha string `json:"captcha"` // 图形验证码(注册/登录/找回密码场景必填) +} + +// SendEmailCode 发送邮箱验证码(支持多种场景) +// type=register: 注册,检查邮箱未被注册 +// type=login: 登录,检查邮箱已注册且有密码 +// type=bind/空: 已登录状态下绑定邮箱,不检查邮箱状态 +// type=reset_password: 找回密码,检查邮箱已注册且有密码 +// type=change_email: 修改邮箱(已登录),检查新邮箱未被其他用户使用 +func (h *userHandler) SendEmailCode(c *gin.Context) { + var req SendEmailCodeReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "请输入有效的邮箱地址") + return + } + + ctx := context.Background() + + // 1. 获取客户端IP + clientIP := c.ClientIP() + if clientIP == "" { + clientIP = c.GetHeader("X-Forwarded-For") + } + if clientIP == "" { + clientIP = c.GetHeader("X-Real-IP") + } + + switch req.Type { + case "register": + // 注册场景:检查邮箱未被注册,需要验证图形验证码 + // 验证图形验证码 + if req.CaptchaId == "" || req.Captcha == "" { + utils.Fail(c, "请输入图形验证码") + return + } + if !store.Verify(req.CaptchaId, req.Captcha, true) { + utils.Fail(c, "图形验证码错误") + return + } + + var user models.User + err := h.db.Where("email = ?", req.Email).First(&user).Error + if err == nil { + utils.Fail(c, "该邮箱已被注册") + return + } + case "login": + // 登录场景:检查邮箱已注册且有密码,需要验证图形验证码 + // 验证图形验证码 + if req.CaptchaId == "" || req.Captcha == "" { + utils.Fail(c, "请输入图形验证码") + return + } + if !store.Verify(req.CaptchaId, req.Captcha, true) { + utils.Fail(c, "图形验证码错误") + return + } + + var user models.User + err := h.db.Where("email = ?", req.Email).First(&user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + utils.Fail(c, "该邮箱未注册") + } else { + utils.Fail(c, "查询用户失败") + } + return + } + if user.Password == "" { + utils.Fail(c, "该邮箱未设置密码,请先注册") + return + } + case "reset_password": + // 找回密码场景:检查邮箱已注册,需要验证图形验证码 + // 验证图形验证码 + if req.CaptchaId == "" || req.Captcha == "" { + utils.Fail(c, "请输入图形验证码") + return + } + if !store.Verify(req.CaptchaId, req.Captcha, true) { + utils.Fail(c, "图形验证码错误") + return + } + + var user models.User + err := h.db.Where("email = ?", req.Email).First(&user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + utils.Fail(c, "该邮箱未注册") + } else { + utils.Fail(c, "查询用户失败") + } + return + } + if user.Password == "" { + utils.Fail(c, "该邮箱未设置密码,请先注册") + return + } + case "change_email": + // 修改邮箱场景:需要用户已登录,检查新邮箱未被其他用户使用,需要验证图形验证码 + userId, exists := c.Get("userID") + if !exists { + utils.Fail(c, "用户未登录") + return + } + + // 验证图形验证码 + if req.CaptchaId == "" || req.Captcha == "" { + utils.Fail(c, "请输入图形验证码") + return + } + if !store.Verify(req.CaptchaId, req.Captcha, true) { + utils.Fail(c, "图形验证码错误") + return + } + + // 检查新邮箱是否已被其他用户使用 + var existingUser models.User + err := h.db.Where("email = ? AND id != ?", req.Email, userId).First(&existingUser).Error + if err == nil { + utils.Fail(c, "该邮箱已被其他用户使用") + return + } + case "bind", "": + // 绑定邮箱场景:需要用户已登录,不检查邮箱状态,不需要图形验证码 + _, exists := c.Get("userID") + if !exists { + utils.Fail(c, "用户未登录") + return + } + default: + utils.Fail(c, "无效的type参数") + return + } + + // 2. IP级别的频率限制:同一IP每分钟最多发送5次 + ipRateLimitKey := "email_ip_rate_limit:" + clientIP + ipCount, err := global.Redis.Incr(ctx, ipRateLimitKey).Result() + if err != nil { + global.Logger.Error("IP频率限制计数失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "发送验证码失败") + return + } + if ipCount == 1 { + global.Redis.Expire(ctx, ipRateLimitKey, 60*time.Second) + } else if ipCount > 5 { + ttl, _ := global.Redis.TTL(ctx, ipRateLimitKey).Result() + utils.Fail(c, fmt.Sprintf("当前IP发送过于频繁,请%d秒后再试", int(ttl.Seconds()))) + return + } + + // 3. 邮箱级别的频率限制:同一邮箱每60秒只能发送一次 + emailRateLimitKey := "email_rate_limit:" + req.Email + emailCount, err := global.Redis.Exists(ctx, emailRateLimitKey).Result() + if err == nil && emailCount > 0 { + ttl, _ := global.Redis.TTL(ctx, emailRateLimitKey).Result() + utils.Fail(c, fmt.Sprintf("该邮箱发送太频繁,请%d秒后再试", int(ttl.Seconds()))) + return + } + + // 4. 邮箱每日发送次数限制:同一邮箱每天最多发送10次 + now := time.Now() + dayKey := "email_daily_limit:" + req.Email + ":" + now.Format("2006-01-02") + dailyCount, err := global.Redis.Incr(ctx, dayKey).Result() + if err != nil { + global.Logger.Error("邮箱每日限制计数失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "发送验证码失败") + return + } + if dailyCount == 1 { + global.Redis.Expire(ctx, dayKey, 24*time.Hour) + } else if dailyCount > 10 { + utils.Fail(c, "该邮箱今日发送次数已达上限,请明天再试") + return + } + + // 5. IP每日发送次数限制:同一IP每天最多发送30次 + ipDayKey := "email_ip_daily_limit:" + clientIP + ":" + now.Format("2006-01-02") + ipDailyCount, err := global.Redis.Incr(ctx, ipDayKey).Result() + if err != nil { + global.Logger.Error("IP每日限制计数失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "发送验证码失败") + return + } + if ipDailyCount == 1 { + global.Redis.Expire(ctx, ipDayKey, 24*time.Hour) + } else if ipDailyCount > 30 { + utils.Fail(c, "当前IP今日发送次数已达上限,请明天再试") + return + } + + code := utils.GenerateRandomString(6) + err = global.Redis.Set(ctx, "email_code:"+req.Email, code, 5*time.Minute).Err() + if err != nil { + global.Logger.Error("保存邮箱验证码失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "发送验证码失败") + return + } + + global.Redis.Set(ctx, emailRateLimitKey, "1", 60*time.Second) + + err = utils.SendEmailCode(req.Email, code) + if err != nil { + global.Logger.Error("发送邮件失败", append(utils.LogContextFields(c), zap.String("email", req.Email), zap.Error(err))...) + global.Redis.Del(ctx, "email_code:"+req.Email) + utils.Fail(c, "发送验证码失败") + return + } + + global.Logger.Info("验证码发送成功", append(utils.LogContextFields(c), zap.String("email", req.Email), zap.String("code", code))...) + utils.Ok(c, "验证码已发送,请注意查收") +} + +// SetEmailReq 设置邮箱请求 +type SetEmailReq struct { + Email string `json:"email" binding:"required,email"` + Code string `json:"code" binding:"required,len=6"` +} + +// SetEmail 设置邮箱(需验证验证码) +func (h *userHandler) SetEmail(c *gin.Context) { + userId, exists := c.Get("userID") + if !exists { + utils.Fail(c, "用户未登录") + return + } + var req SetEmailReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "请输入有效的邮箱和验证码") + return + } + + ctx := context.Background() + storedCode, err := global.Redis.Get(ctx, "email_code:"+req.Email).Result() + if err != nil || storedCode != req.Code { + utils.Fail(c, "验证码错误或已过期") + return + } + + global.Redis.Del(ctx, "email_code:"+req.Email) + + var existingUser models.User + err = h.db.Where("email = ? AND id != ?", req.Email, userId).First(&existingUser).Error + if err == nil { + utils.Fail(c, "该邮箱已被其他用户使用") + return + } + + err = h.db.Model(&models.User{}).Where("id = ?", userId).Update("email", req.Email).Error + if err != nil { + global.Logger.Error("更新邮箱失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "设置邮箱失败") + return + } + + utils.Ok(c, "邮箱设置成功") +} + +// -------------------------- 5. 网页端邮箱登录接口 -------------------------- + +// EmailRegisterReq 邮箱注册请求 +type EmailRegisterReq struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + Code string `json:"code" binding:"required,len=6"` + CaptchaId string `json:"captchaId"` // 图形验证码ID + Captcha string `json:"captcha"` // 图形验证码 +} + +// EmailLoginReq 邮箱登录请求 +type EmailLoginReq struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` + CaptchaId string `json:"captchaId"` // 图形验证码ID + Captcha string `json:"captcha"` // 图形验证码 +} + +// EmailRegister 邮箱注册 +func (h *userHandler) EmailRegister(c *gin.Context) { + var req EmailRegisterReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "请输入有效的邮箱和密码") + return + } + + // 注:图形验证码在发送邮箱验证码时已验证,此处不再重复验证 + + // 验证邮箱验证码(必填) + ctx := context.Background() + storedCode, err := global.Redis.Get(ctx, "email_code:"+req.Email).Result() + if err != nil || storedCode != req.Code { + utils.Fail(c, "邮箱验证码错误或已过期") + return + } + + global.Redis.Del(ctx, "email_code:"+req.Email) + + var existingUser models.User + err = h.db.Where("email = ?", req.Email).First(&existingUser).Error + if err == nil { + utils.Fail(c, "该邮箱已被注册") + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + global.Logger.Error("密码加密失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "注册失败") + return + } + + userCode := utils.GenerateUserCode() + + user := models.User{ + Email: req.Email, + Password: string(hashedPassword), + Nickname: "用户" + utils.GenerateRandomString(6), + UserCode: userCode, + } + + err = h.db.Create(&user).Error + if err != nil { + global.Logger.Error("创建用户失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "注册失败") + return + } + + token, expiresAt, err := utils.GenerateTokenWithExpire(user.ID) + if err != nil { + utils.Fail(c, "token生成失败") + return + } + + utils.Ok(c, gin.H{ + "token": token, + "expiresAt": expiresAt, + "expiresIn": utils.GetTokenExpireSeconds(), + "nickname": user.Nickname, + "user_code": user.UserCode, + "avatar": user.Avatar, + }) +} + +// EmailLogin 邮箱登录 +func (h *userHandler) EmailLogin(c *gin.Context) { + var req EmailLoginReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "请输入有效的邮箱和密码") + return + } + + // 验证图形验证码(必填) + if req.CaptchaId == "" || req.Captcha == "" { + utils.Fail(c, "请输入图形验证码") + return + } + if !store.Verify(req.CaptchaId, req.Captcha, true) { + utils.Fail(c, "图形验证码错误") + return + } + + var user models.User + err := h.db.Where("email = ?", req.Email).First(&user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + utils.Fail(c, "邮箱或密码错误") + } else { + utils.Fail(c, "登录失败") + } + return + } + + if user.Password == "" { + utils.Fail(c, "该邮箱未设置密码,请先注册") + return + } + + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)) + if err != nil { + utils.Fail(c, "邮箱或密码错误") + return + } + + token, expiresAt, err := utils.GenerateTokenWithExpire(user.ID) + if err != nil { + utils.Fail(c, "登录失败") + return + } + + utils.Ok(c, gin.H{ + "token": token, + "expiresAt": expiresAt, + "expiresIn": utils.GetTokenExpireSeconds(), + "nickname": user.Nickname, + "avatar": user.Avatar, + "email": user.Email, + }) +} + +// CheckEmailExistsReq 检查邮箱是否存在请求 +type CheckEmailExistsReq struct { + Email string `json:"email" binding:"required,email"` +} + +// CheckEmailExists 检查邮箱是否已注册 +func (h *userHandler) CheckEmailExists(c *gin.Context) { + var req CheckEmailExistsReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "请输入有效的邮箱地址") + return + } + + var user models.User + err := h.db.Where("email = ?", req.Email).First(&user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + utils.Ok(c, gin.H{ + "exists": false, + "hasPassword": false, + }) + return + } + utils.Fail(c, "查询失败") + return + } + + utils.Ok(c, gin.H{ + "exists": true, + "hasPassword": user.Password != "", + }) +} + +// ResetPasswordReq 重置密码请求(未登录找回密码场景) +type ResetPasswordReq struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + ConfirmPassword string `json:"confirmPassword"` // 确认密码(可选,前端已验证) + Code string `json:"code" binding:"required,len=6"` // 邮箱验证码(必填) +} + +// ResetPassword 重置密码(未登录找回密码场景) +func (h *userHandler) ResetPassword(c *gin.Context) { + var req ResetPasswordReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "请输入有效的邮箱、密码和验证码") + return + } + + // 验证密码一致性(如果前端传递了 confirmPassword) + if req.ConfirmPassword != "" && req.Password != req.ConfirmPassword { + utils.Fail(c, "两次输入的密码不一致") + return + } + + ctx := context.Background() + storedCode, err := global.Redis.Get(ctx, "email_code:"+req.Email).Result() + if err != nil || storedCode != req.Code { + utils.Fail(c, "验证码错误或已过期") + return + } + + global.Redis.Del(ctx, "email_code:"+req.Email) + + var user models.User + err = h.db.Where("email = ?", req.Email).First(&user).Error + if err != nil { + utils.Fail(c, "该邮箱未注册") + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + global.Logger.Error("密码加密失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "重置密码失败") + return + } + + err = h.db.Model(&user).Update("password", string(hashedPassword)).Error + if err != nil { + global.Logger.Error("更新密码失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "重置密码失败") + return + } + + utils.Ok(c, "密码重置成功") +} + +// ChangePasswordReq 修改密码请求(已登录场景) +type ChangePasswordReq struct { + Password string `json:"password" binding:"required,min=6"` // 新密码 + ConfirmPassword string `json:"confirmPassword" binding:"required"` // 确认密码 + NewEmail string `json:"newEmail" binding:"omitempty,email"` // 新邮箱(可选) + CaptchaId string `json:"captchaId" binding:"required"` // 图形验证码ID + Captcha string `json:"captcha" binding:"required"` // 图形验证码 + Code string `json:"code" binding:"omitempty,len=6"` // 邮箱验证码(修改邮箱时必填) +} + +// ChangePassword 修改密码(已登录场景) +// 支持两种场景: +// 1. 只修改密码(不修改邮箱):只需图形验证码 +// 2. 修改邮箱+密码:需要新邮箱的验证码 +func (h *userHandler) ChangePassword(c *gin.Context) { + userId, exists := c.Get("userID") + if !exists { + utils.Fail(c, "用户未登录") + return + } + + var req ChangePasswordReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "请输入有效的参数") + return + } + + // 1. 验证图形验证码(必填) + if !store.Verify(req.CaptchaId, req.Captcha, true) { + utils.Fail(c, "图形验证码错误") + return + } + + // 2. 验证密码一致性 + if req.Password != req.ConfirmPassword { + utils.Fail(c, "两次输入的密码不一致") + return + } + + ctx := context.Background() + + // 3. 如果提供了新邮箱,需要验证邮箱验证码 + if req.NewEmail != "" { + if req.Code == "" { + utils.Fail(c, "修改邮箱需要邮箱验证码") + return + } + + storedCode, err := global.Redis.Get(ctx, "email_code:"+req.NewEmail).Result() + if err != nil || storedCode != req.Code { + utils.Fail(c, "邮箱验证码错误或已过期") + return + } + + global.Redis.Del(ctx, "email_code:"+req.NewEmail) + + // 检查新邮箱是否已被其他用户使用 + var existingUser models.User + err = h.db.Where("email = ? AND id != ?", req.NewEmail, userId).First(&existingUser).Error + if err == nil { + utils.Fail(c, "该邮箱已被其他用户使用") + return + } + } + + // 4. 更新密码 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + global.Logger.Error("密码加密失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "修改密码失败") + return + } + + // 构建更新数据 + updateData := map[string]interface{}{ + "password": string(hashedPassword), + } + + // 如果提供了新邮箱,同时更新邮箱 + if req.NewEmail != "" { + updateData["email"] = req.NewEmail + } + + err = h.db.Model(&models.User{}).Where("id = ?", userId).Updates(updateData).Error + if err != nil { + global.Logger.Error("更新用户信息失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "修改失败") + return + } + + // 5. 返回成功消息 + if req.NewEmail != "" { + utils.Ok(c, "邮箱和密码修改成功") + } else { + utils.Ok(c, "密码修改成功") + } +} + +// -------------------------- 6. 微信绑定接口 -------------------------- + +// BindWechatReq 绑定微信请求(Web端已登录) +type BindWechatReq struct { + Code string `json:"code" binding:"required"` // 微信网页授权code +} + +// BindWechat 绑定微信(已登录场景,Web端使用) +// 流程:用户在Web端已登录 → 扫描微信二维码 → 微信回传code → 调用此接口绑定openid +func (h *userHandler) BindWechat(c *gin.Context) { + userId, exists := c.Get("userID") + if !exists { + utils.Fail(c, "用户未登录") + return + } + + var req BindWechatReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + if req.Code == "" { + utils.Fail(c, "授权code不能为空") + return + } + + // 调用微信网页授权接口获取 openid + wxResp, err := utils.WebCode2Session(req.Code) + if err != nil { + global.Logger.Error("微信网页授权失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "微信授权失败:"+err.Error()) + return + } + + if wxResp.OpenID == "" { + utils.Fail(c, "获取微信用户信息失败") + return + } + + // 检查该 openid 是否已被其他用户绑定 + var existingUser models.User + err = h.db.Where("openid = ? AND id != ?", wxResp.OpenID, userId).First(&existingUser).Error + if err == nil { + utils.Fail(c, "该微信账号已被其他用户绑定") + return + } + + // 检查当前用户是否已绑定微信 + var currentUser models.User + err = h.db.First(¤tUser, userId).Error + if err != nil { + utils.Fail(c, "用户不存在") + return + } + if currentUser.Openid != "" { + utils.Fail(c, "您已绑定微信,请先解绑") + return + } + + // 绑定微信 openid 到当前用户 + err = h.db.Model(&models.User{}).Where("id = ?", userId).Update("openid", wxResp.OpenID).Error + if err != nil { + global.Logger.Error("绑定微信失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "绑定微信失败") + return + } + + global.Logger.Info("微信绑定成功", append(utils.LogContextFields(c), + zap.Uint("userID", userId.(uint)), + zap.String("openid", wxResp.OpenID), + )...) + + utils.Ok(c, "微信绑定成功") +} + +// -------------------------- 7. 小程序微信绑定接口 -------------------------- + +// BindWechatMiniReq 绑定微信请求(小程序端已登录) +type BindWechatMiniReq struct { + Code string `json:"code" binding:"required"` // 小程序 wx.login() 返回的临时code +} + +// BindWechatMini 小程序绑定微信(已登录场景) +// 流程:小程序用户用邮箱注册并登录 → 点击"绑定微信" → wx.login() 获取code → 调用此接口绑定openid +// +// 与 Web 端 BindWechat 的关键区别: +// +// - Web 端:code 来自微信网页OAuth2.0扫码授权 → 调用 sns/oauth2/access_token 换取 → WebCode2Session +// +// - 小程序端:code 来自 wx.login() → 调用 sns/jscode2session 换取 → MiniProgramCode2Session +// +// 两者 code 完全不同,API 端点不同,不可混用! +func (h *userHandler) BindWechatMini(c *gin.Context) { + userId, exists := c.Get("userID") + if !exists { + utils.Fail(c, "用户未登录") + return + } + + var req BindWechatMiniReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, "参数解析失败") + return + } + + if req.Code == "" { + utils.Fail(c, "授权code不能为空,请先调用wx.login获取") + return + } + + // 调用小程序专用接口:sns/jscode2session + wxResp, err := utils.MiniProgramCode2Session(req.Code) + if err != nil { + global.Logger.Error("小程序微信授权失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "微信授权失败,请稍后重试") + return + } + + if wxResp.OpenID == "" { + utils.Fail(c, "获取微信用户信息失败") + return + } + + // 检查该 openid 是否已被其他用户绑定 + var existingUser models.User + err = h.db.Where("openid = ? AND id != ?", wxResp.OpenID, userId).First(&existingUser).Error + if err == nil { + utils.Fail(c, "该微信账号已被其他用户绑定") + return + } + + // 检查当前用户是否已绑定微信 + var currentUser models.User + err = h.db.First(¤tUser, userId).Error + if err != nil { + utils.Fail(c, "用户不存在") + return + } + if currentUser.Openid != "" { + utils.Fail(c, "您已绑定微信,请先解绑") + return + } + + // 更新 openid 和相关字段 + updates := map[string]interface{}{ + "openid": wxResp.OpenID, + } + // 若存在 unionid 一并保存(跨平台识别用) + if wxResp.UnionID != "" { + updates["unionid"] = wxResp.UnionID + } + + err = h.db.Model(&models.User{}).Where("id = ?", userId).Updates(updates).Error + if err != nil { + global.Logger.Error("小程序绑定微信失败", append(utils.LogContextFields(c), zap.Error(err))...) + utils.Fail(c, "绑定微信失败,请稍后重试") + return + } + + global.Logger.Info("小程序微信绑定成功", append(utils.LogContextFields(c), + zap.Uint("userID", userId.(uint)), + zap.String("openid", wxResp.OpenID), + )...) + + utils.Ok(c, "微信绑定成功") +} diff --git a/api/app/userConfig.go b/api/app/userConfig.go new file mode 100644 index 0000000..b16f50e --- /dev/null +++ b/api/app/userConfig.go @@ -0,0 +1,92 @@ +// 小程序用户接口 +package app + +import ( + "simple-memo/global" + "simple-memo/models" + "simple-memo/utils" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// -------------------------- 1. 定义 Handler 接口 -------------------------- +// UserConfigHandler 用户模块接口定义 +type UserConfigHandler interface { + UserConfigInfo(c *gin.Context) // 获取配置 + EditUserConfig(c *gin.Context) // 编辑配置 +} + +// -------------------------- 2. 实现结构体(依赖注入DB) -------------------------- +// userHandler 接口实现结构体 +type userConfigHandler struct { + db *gorm.DB // 注入数据库,方便测试 +} + +// NewUserConfigHandler 创建用户处理器(对外暴露) +func NewUserConfigHandler() UserConfigHandler { + return &userConfigHandler{ + db: global.DB, + } +} + +type EditUserConfigReq struct { + Theme string `json:"theme"` // 主题 +} + +// -------------------------- 4. 接口实现 -------------------------- + +// UserConfigInfo 获取用户配置信息 +func (h *userConfigHandler) UserConfigInfo(c *gin.Context) { + userId, exists := c.Get("userID") + if !exists { + utils.Fail(c, "用户未登录") + return + } + + var user models.UserConfig + err := h.db.Where("user_id = ?", userId).First(&user).Error + if err != nil { + utils.Fail(c, "获取用户信息失败") + return + } + + // 返回脱敏用户信息 + utils.Ok(c, gin.H{ + "id": user.ID, + "theme": user.Theme, + "createdAt": user.CreatedAt, + }) +} + +// EditUser 修改用户信息 +func (h *userConfigHandler) EditUserConfig(c *gin.Context) { + userId, exists := c.Get("userID") + if !exists { + utils.Fail(c, "用户未登录") + } + var editUserConfigReq EditUserConfigReq + if err := c.ShouldBindJSON(&editUserConfigReq); err != nil { + utils.Fail(c, "参数解析失败") + } + var data map[string]any + // 如果只存在一个字段,则只更新当前的一个 + if editUserConfigReq.Theme != "" { + data = map[string]any{ + "user_id": userId, + "theme": editUserConfigReq.Theme, + } + } + if data == nil { + utils.Ok(c, nil) + } + + err := h.db.Where("user_id = ?", userId). + Assign(data). + FirstOrCreate(&models.UserConfig{}).Error + if err != nil { + utils.Fail(c, "用户配置信息更新失败") + } + utils.Ok(c, nil) + +} diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..1156c1e --- /dev/null +++ b/build.bat @@ -0,0 +1,20 @@ +@echo off +REM 解决 go-redis 编译 signal: killed 问题 - Windows 版本 + +REM 1. 清理 Go 缓存 +go clean -cache -modcache -i -r + +REM 2. 设置 Go 编译限制(降低并行度,避免内存爆) +set GOMAXPROCS=2 +set GO111MODULE=on +set GOPROXY=https://goproxy.cn,direct +set GOSUMDB=off + +REM 3. 重新下载依赖 +go mod download -x + +REM 4. 尝试编译 +go build -v -o simple-memo.exe ./cmd/main.go + +echo 编译完成! +pause \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..bec1ffe --- /dev/null +++ b/build.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# 解决 go-redis 编译 signal: killed 问题 + +# 1. 清理 Go 缓存 +go clean -cache -modcache -i -r + +# 2. 设置 Go 编译限制(降低并行度,避免内存爆) +export GOMAXPROCS=2 +export GO111MODULE=on +export GOPROXY=https://goproxy.cn,direct +export GOSUMDB=off + +# 3. 重新下载依赖 +go mod download -x + +# 4. 尝试编译 +go build -v -o simple-memo ./cmd/main.go + +echo "编译完成!" \ No newline at end of file diff --git a/cmd/app.bin b/cmd/app.bin new file mode 100644 index 0000000..981c5c4 Binary files /dev/null and b/cmd/app.bin differ diff --git a/cmd/cmd.exe b/cmd/cmd.exe new file mode 100644 index 0000000..e11f22e Binary files /dev/null and b/cmd/cmd.exe differ diff --git a/cmd/main b/cmd/main new file mode 100644 index 0000000..001b964 Binary files /dev/null and b/cmd/main differ diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..6fc18b5 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "simple-memo/core" + "simple-memo/global" + "simple-memo/models" + "simple-memo/utils" // 新增 +) + +func main() { + // 1. 加载配置 + core.InitConfig() + + // 2. 初始化AES(修复后必须加) + utils.InitAES() + + // 3. 初始化日志 + core.InitLogger() + // 4. 初始化MySQL + core.InitMysql() + // 5. 初始化Redis + core.InitRedis() + + // 自动建表 + global.DB.AutoMigrate( + &models.User{}, + &models.Todo{}, + &models.Bill{}, + &models.Category{}, + &models.Budget{}, + &models.UserConfig{}, + &models.Mood{}, + &models.MoodAIGeneration{}, + &models.Review{}, // 新增:复盘表 + &models.UserGrowth{}, + &models.DailyProgress{}, + &models.ExpChangeLog{}, + &models.UserAchievement{}, + ) + + // 启动定时任务 + core.InitCron() + + // 启动服务 + r := core.InitRouter() + port := global.VP.GetString("server.port") + r.Run(":" + port) +} diff --git a/config/config-dev.yaml b/config/config-dev.yaml new file mode 100644 index 0000000..92230e5 --- /dev/null +++ b/config/config-dev.yaml @@ -0,0 +1,48 @@ +# 开发环境配置 +server: + port: 8888 + mode: debug + +mysql: + host: 127.0.0.1 + port: 3306 + dbname: simple_memo_dev + username: sunct + password: sun900120 + +redis: + host: 127.0.0.1 + port: 6379 + password: "" + db: 0 + +jwt: + key: "dev-jwt-simple-memo-2026" + expire: 7200 # 2小时过期 + +aes: + key: "12345678901234567890123456789012" # 32位 + iv: "1234567890123456" # 16位 + +log: + path: "./logs/dev.log" + +wechat: + app_id: "wx6a3215bd78a6ff13" + app_secret: "00d66660efe4da31057dd401b1e07a6e" + web_redirect_uri: "https://dev.your-domain.com/api/web/auth/callback" + +email: + smtp_host: "smtp.qiye.aliyun.com" + smtp_port: 465 + smtp_user: "memo_noreply@miaoall.cn" + smtp_password: "wwpnme4odnnj" + from: "简记memo " + +ai: + provider: "spark" # 讯飞星火 + api_password: "OjAdJyOqCVmVYUFyaQJq:GQZNWpiVJTytmiSWuaTq" # 讯飞 API Password(在控制台应用详情获取) + base_url: "https://spark-api-open.xf-yun.com/v1" + model: "lite" # spark-lite(免费,QPS=2) + max_tokens: 1024 + timeout: 30 \ No newline at end of file diff --git a/config/config-prod.yaml b/config/config-prod.yaml new file mode 100644 index 0000000..dc7bc54 --- /dev/null +++ b/config/config-prod.yaml @@ -0,0 +1,48 @@ +# 生产环境配置 +server: + port: 80 + mode: release + +mysql: + host: 127.0.0.1 + port: 3306 + dbname: simple_memo_prod + username: sunct_online + password: zEm4Ehixx7XfmXNJ + +redis: + host: 127.0.0.1 + port: 6380 + password: "" + db: 0 + +jwt: + key: "prod-jwt-simple-memo-2026-xxxx" + expire: 86400*7 # 1天 + +aes: + key: "a1s2d3f4g5h6j7k8l9z0xq1w2e3r4t5y6" + iv: "q1w2e3r4t5y6u7i8" + +log: + path: "./logs/prod.log" + +wechat: + app_id: "wx6a3215bd78a6ff13" + app_secret: "00d66660efe4da31057dd401b1e07a6e" + web_redirect_uri: "https://your-domain.com/api/web/auth/callback" + +email: + smtp_host: "smtp.qiye.aliyun.com" + smtp_port: 465 + smtp_user: "memo_noreply@miaoall.cn" + smtp_password: "wwpnme4odnnj" + from: "简记memo " + +ai: + provider: "spark" + api_password: "OjAdJyOqCVmVYUFyaQJq:GQZNWpiVJTytmiSWuaTq" + base_url: "https://spark-api-open.xf-yun.com/v1" + model: "lite" + max_tokens: 1024 + timeout: 30 \ No newline at end of file diff --git a/core/config.go b/core/config.go new file mode 100644 index 0000000..ca6da03 --- /dev/null +++ b/core/config.go @@ -0,0 +1,37 @@ +// 配置加载:区分dev/prod环境 +package core + +import ( + "flag" + "simple-memo/global" + + "github.com/spf13/viper" +) + +// InitConfig 读取命令行参数 -env dev/prod +func InitConfig() { + // 读取启动参数 + env := flag.String("env", "dev", "运行环境:dev 或 prod") + flag.Parse() + + // 1. 获取项目根目录的绝对路径(基于当前执行文件的位置) + //exePath, err := os.Executable() + //if err != nil { + // panic(fmt.Sprintf("获取执行文件路径失败: %v", err)) + //} + //projectRoot := filepath.Dir(exePath) // 执行文件在根目录,所以Dir就是根目录 + + // 2. 用绝对路径加载配置文件 + v := viper.New() + v.SetConfigName("config-" + *env) // config-dev.yaml + v.SetConfigType("yaml") + v.AddConfigPath("../config") // 配置文件目录 + //viper.AddConfigPath(filepath.Join(projectRoot, "config")) // 绝对路径:根目录/config + + // 读取配置 + if err := v.ReadInConfig(); err != nil { + panic("配置文件读取失败:" + err.Error()) + } + + global.VP = v // 赋值给全局 +} diff --git a/core/cron.go b/core/cron.go new file mode 100644 index 0000000..6fb0f05 --- /dev/null +++ b/core/cron.go @@ -0,0 +1,58 @@ +package core + +import ( + "time" + + "simple-memo/global" + "simple-memo/models" + + "go.uber.org/zap" +) + +func InitCron() { + go func() { + for { + now := time.Now() + next := getNextRunTime(10, 0) + duration := next.Sub(now) + + time.Sleep(duration) + + checkPerfectDay() + } + }() +} + +func getNextRunTime(hour, minute int) time.Time { + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location()) + + if now.Before(today) { + return today + } + return today.AddDate(0, 0, 1) +} + +func checkPerfectDay() { + yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") + + var progressList []models.DailyProgress + err := global.DB.Where("record_date = ?", yesterday).Find(&progressList).Error + if err != nil { + global.Logger.Error("查询每日进度失败", zap.Error(err)) + return + } + + for _, progress := range progressList { + if progress.TotalTasks > 0 && progress.TaskCount >= progress.TotalTasks && !progress.PerfectDay { + err := global.DB.Model(&models.DailyProgress{}). + Where("id = ?", progress.ID). + Update("perfect_day", true).Error + if err != nil { + global.Logger.Error("更新PerfectDay失败", zap.Error(err)) + } + } + } + + global.Logger.Info("PerfectDay check completed", zap.String("date", yesterday)) +} diff --git a/core/logger.go b/core/logger.go new file mode 100644 index 0000000..4d78fb1 --- /dev/null +++ b/core/logger.go @@ -0,0 +1,25 @@ +// 日志模块:zap 高性能日志 +package core + +import ( + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "os" + "simple-memo/global" +) + +func InitLogger() { + logPath := global.VP.GetString("log.path") + file, _ := os.Create(logPath) + + // 同时输出到文件 + 控制台 + writeSyncer := zapcore.NewMultiWriteSyncer(zapcore.AddSync(file), zapcore.AddSync(os.Stdout)) + + encoderConfig := zap.NewProductionEncoderConfig() + encoderConfig.TimeKey = "time" + encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout("2006-01-02 15:04:05") + + // 日志级别 + core := zapcore.NewCore(zapcore.NewJSONEncoder(encoderConfig), writeSyncer, zap.InfoLevel) + global.Logger = zap.New(core, zap.AddCaller()) +} diff --git a/core/mysql.go b/core/mysql.go new file mode 100644 index 0000000..26c4810 --- /dev/null +++ b/core/mysql.go @@ -0,0 +1,50 @@ +// MySQL初始化 + 表前缀sm_(已修复GORM兼容报错) +package core + +import ( + "simple-memo/global" + "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "gorm.io/gorm/schema" +) + +// InitMysql 初始化MySQL数据库连接 +func InitMysql() { + // 从配置文件读取数据库信息 + host := global.VP.GetString("mysql.host") + port := global.VP.GetString("mysql.port") + dbname := global.VP.GetString("mysql.dbname") + username := global.VP.GetString("mysql.username") + password := global.VP.GetString("mysql.password") + + // MySQL 连接字符串 + dsn := username + ":" + password + "@tcp(" + host + ":" + port + ")/" + dbname + "?charset=utf8mb4&parseTime=True&loc=Local" + + // 打开数据库连接,启用表前缀sm_ + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + // ========== 修复:正确设置表前缀,无报错 ========== + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "sm_", // 所有表统一前缀:sm_ + SingularTable: true, // 禁用表名复数(User → sm_user) + }, + }) + if err != nil { + panic("MySQL连接失败:" + err.Error()) + } + + // 设置数据库连接池,支撑高并发 + sqlDB, err := db.DB() + if err != nil { + panic("获取数据库连接失败:" + err.Error()) + } + sqlDB.SetMaxOpenConns(100) // 最大打开连接数 + sqlDB.SetMaxIdleConns(20) // 最大空闲连接数 + sqlDB.SetConnMaxLifetime(30 * time.Minute) // 连接最长存活时间 + + // 赋值给全局DB变量 + global.DB = db +} diff --git a/core/redis.go b/core/redis.go new file mode 100644 index 0000000..23063ad --- /dev/null +++ b/core/redis.go @@ -0,0 +1,24 @@ +// Redis初始化 +package core + +import ( + "context" + "github.com/redis/go-redis/v9" + "simple-memo/global" +) + +func InitRedis() { + client := redis.NewClient(&redis.Options{ + Addr: global.VP.GetString("redis.host") + ":" + global.VP.GetString("redis.port"), + Password: global.VP.GetString("redis.password"), + DB: global.VP.GetInt("redis.db"), + }) + + // 测试连接 + _, err := client.Ping(context.Background()).Result() + if err != nil { + panic("Redis连接失败:" + err.Error()) + } + + global.Redis = client +} diff --git a/core/router.go b/core/router.go new file mode 100644 index 0000000..2cb3a41 --- /dev/null +++ b/core/router.go @@ -0,0 +1,28 @@ +// core/router.go +// Gin引擎初始化 - 只保留核心配置 +package core + +import ( + "simple-memo/middleware" + "simple-memo/routes" + + "github.com/gin-gonic/gin" +) + +// InitRouter 初始化Gin引擎并注册所有路由 +func InitRouter() *gin.Engine { + // 使用 gin.New() 手动控制中间件,避免 gin.Default() 自带的 Logger + r := gin.New() + + // 全局中间件 + r.Use(gin.Recovery()) // 恢复中间件(捕获 panic) + r.Use(middleware.RequestLog()) // 请求日志中间件(记录 request-id / route / client-ip 等) + + // 设置信任的代理(开发环境设置为 nil,生产环境应设置具体的代理地址) + _ = r.SetTrustedProxies(nil) + + // 注册所有路由(静态资源、页面、API) + routes.RegisterRoutes(r) + + return r +} diff --git a/docs/# 「简记memo」一体化小程序.md b/docs/# 「简记memo」一体化小程序.md new file mode 100644 index 0000000..2118bbd --- /dev/null +++ b/docs/# 「简记memo」一体化小程序.md @@ -0,0 +1,263 @@ +# 「简记memo」一体化小程序 +## 完整产品原型设计文档 +**定位**:极简高级、强功能、纯个人工具、云存储、无社交无广告 +**风格**:留白、卡片、轻动画、深浅双主题 +**架构**:底部 4 Tab + 全局快捷操作 + 数据云同步 + +--- + +# 一、全局规则(所有页面通用) +1. **数据隔离** + 仅当前微信用户可见,openid 绑定,自动登录,无需注册 +2. **日期体系** + 所有数据按「yyyy-MM-dd」归类,支持跨天查看/顺延 +3. **操作统一** + - 左滑:删除 + - 右滑:完成/确认 + - 长按:编辑/批量 + - 悬浮按钮:快速添加 +4. **主题** + 浅色 / 深色 / 跟随系统 +5. **云存储** + 微信云开发数据库,无服务器、无域名、单人可维护 + +--- + +# 二、底部 Tab 结构(4 个) +1. **今日**(聚合页) +2. **待办**(全任务管理) +3. **账单**(全收支管理) +4. **我的**(配置+数据+统计) + +--- + +# 三、页面原型详细描述(逐页完整) + +## Page 1:今日(核心聚合页) +### 顶部 +- 日期:yyyy-MM-dd 星期X +- 左右箭头:昨日 / 明日 +- 小开关:快速切换「只看今日 / 含逾期」 + +### 模块 A:今日待办 +- 进度条:已完成 / 总数 +- 列表: + 图标 + 标题 + 优先级(红点/黄点/绿点)+ 完成勾选框 +- 空态:今日暂无任务,添加一个开始吧 +- 快捷操作: + 右滑 = 完成 + 左滑 = 删除 + 点击 = 进入编辑 + +### 模块 B:今日记账 +- 今日支出:¥xx +- 今日收入:¥xx +- 今日结余:自动计算 +- 账单列表: + 分类图标 + 备注 + 金额(红/绿) +- 空态:今日暂无记录 + +### 底部悬浮按钮(左右双按钮) +- 左侧:+ 记一笔 +- 右侧:+ 加任务 + +### 页面逻辑 +- 进入自动加载当日数据 +- 完成/删除后实时刷新 +- 切换日期自动重新请求 + +--- + +## Page 2:待办(全功能任务页) +### 顶部 +- 搜索框:搜索标题/备注 +- 筛选:全部 / 未完成 / 已完成 / 逾期 + +### 筛选栏 +- 今天 / 本周 / 本月 / 自定义区间 + +### 任务列表结构 +每条任务: +- 优先级图标(高/中/低) +- 任务标题 +- 任务备注(折叠,超出两行隐藏) +- 重复标记(每日/每周/每月) +- 日期 +- 完成开关 + +### 功能点(强但不复杂) +1. **新增任务** + - 标题(必填) + - 备注(选填) + - 分类:工作/生活/学习/自定义 + - 优先级:高/中/低 + - 日期:默认今日,可改 + - 重复:不重复 / 每日 / 每周 / 每月 +2. **子任务** + - 每条主任务可加多条子任务 + - 子任务完成状态独立 +3. **自动顺延** + 当日未完成 → 次日自动显示(可开关) +4. **批量操作** + 长按进入批量:完成/删除/移动日期 +5. **重复任务自动生成** + 每日/每周/每月自动生成下一条 + +### 空态 +你还没有任何任务,点击右下角添加第一个任务 + +--- + +## Page 3:账单(全功能记账页) +### 顶部 +- 搜索:备注/分类 +- 切换:支出 / 收入 / 全部 + +### 统计条 +- 时间段:今日/本周/本月 +- 总支出、总收入、结余 + +### 分类快捷统计(横向滚动) +- 图标 + 分类名 + 金额 + 占比 + +### 账单列表 +- 分类图标 +- 分类名称 +- 备注(可选) +- 金额(支出红、收入绿) +- 日期 +- 左滑删、右滑快速修改 + +### 记账弹窗(强功能极简操作) +- 类型:支出 / 收入 +- 金额:数字键盘 +- 分类:预设16个 + 支持自定义 +- 备注:选填 +- 日期:默认今天,可改 +- 保存 + +### 高级功能 +1. **预算功能** + 月预算,超支标红、进度条提醒 +2. **自定义分类** + 名称 + 图标 + 颜色 +3. **转账记录** + 账户之间转账,不影响收支统计 +4. **按月自动归档** + +--- + +## Page 4:我的(设置+统计+数据) +### 模块 A:月度统计(卡片) +- 总支出、总收入、预算剩余 +- 完成任务数、完成率 +- 简约环形图 + +### 模块 B:数据管理 +- 导出数据(Excel / 文本) +- 清空所有数据(二次确认) +- 数据同步状态 + +### 模块 C:个性化 +- 主题:浅色 / 深色 / 自动 +- 列表样式:紧凑/舒适 +- 动画开关 + +### 模块 D:偏好设置 +- 待办未完成自动顺延 +- 重复任务提醒 +- 预算超支提醒 +- 首页默认显示日期 + +### 模块 E:其他 +- 关于 +- 版本号 +- 清理缓存 + +--- + +# 四、数据库结构(可直接建表) +## 1. tasks(待办) +- _id +- openid(用户唯一标识) +- title(标题) +- content(备注) +- cate(分类:work/life/study/custom) +- priority(1高/2中/3低) +- repeat(none/day/week/month) +- done(true/false) +- subtasks(数组:[{title,done}]) +- date(yyyy-MM-dd) +- createTime +- updateTime + +## 2. bills(账单) +- _id +- openid +- type(income/expend) +- money(数字,正数) +- cate(分类) +- icon(图标) +- color(颜色) +- note(备注) +- date(yyyy-MM-dd) +- createTime + +## 3. userConfig(用户配置) +- _id +- openid +- theme(light/dark/auto) +- budget(月预算) +- autoPostpone(自动顺延) +- showAnimation(动画) +- customCate(自定义分类数组) +- lastBackup(最后备份时间) + +--- + +# 五、核心业务逻辑(完整闭环) +1. **任务自动顺延** + 每天0点,未完成、非重复、非已完成 → 复制到今日,标记顺延 +2. **重复任务生成** + 每日/每周/每月自动生成下一条任务,不修改原任务 +3. **预算计算** + 月支出总和对比预算,超支变红、进度条>100% +4. **数据权限** + 云数据库权限只允许 openid 等于自身读写,绝对安全 +5. **统计口径** + 按自然月统计,不跨月混淆 +6. **操作无刷新** + 所有增删改查本地状态同步 + 云更新,不白屏、不重载 + +--- + +# 六、交互体验(简约但高级) +- 所有按钮有反馈 +- 完成任务有轻微动效(不花哨) +- 列表滑动流畅 +- 键盘自动收起 +- 加载状态弱提示 +- 错误提示轻量不打扰 +- 无任何广告、无推送、无外链 + +--- + +# 七、产品亮点(真正「简约不简单」) +- 界面极简,功能专业 +- 待办 + 记账深度融合,不割裂 +- 重复任务、子任务、顺延、预算、图表、导出全有 +- 单人可开发、可维护、可上线 +- 无第三方依赖、无封闭资源、全自主 +- 个人主体可过审 +- 自用质感极强,可长期使用 + +--- + +如果你需要,我可以**下一步直接输出:** +1. 全套**小程序页面目录结构** +2. 每个页面的**WXML + WXSS + JS 骨架代码** +3. **云数据库创建脚本** +4. **全局工具函数(请求、日期、格式化、统计)** +5. **可直接复制使用的配色+字体+样式规范** + +要我继续把**可直接开发的完整技术原型**一次性给全吗? \ No newline at end of file diff --git a/docs/# 简记 memo.md b/docs/# 简记 memo.md new file mode 100644 index 0000000..9adec05 --- /dev/null +++ b/docs/# 简记 memo.md @@ -0,0 +1,217 @@ +# 简记memo +## 完整版 UI 视觉设计规范 + 界面细节 +**风格:清新极简 · 高级轻质感 · 温柔耐看 · 2026主流审美** +**定位:无冗余、高舒适、强精致、单人可还原** + +--- + +# 一、全局视觉规范(统一高级感) +## 1. 配色方案(清新治愈系) +### 主色 +- **主色:#4CB8C6**(青薄荷绿,温柔干净) +- **强调色:#FF8A65**(暖珊瑚,不艳不刺眼) + +### 中性色 +- 背景:#F9FBFC +- 卡片:#FFFFFF +- 分割线:#EAEEF2 +- 文字深:#2A3A4D +- 文字中:#5E6D7F +- 文字浅:#9AA4B2 + +### 功能色 +- 支出红:#FF6B6B +- 收入绿:#2EC975 +- 完成灰绿:#81C99B +- 高优:#FF6B6B +- 中优:#4CB8C6 +- 低优:#9AA4B2 + +### 深色模式 +- 背景:#121A24 +- 卡片:#1E2734 +- 文字:#E1E8F0 +- 主色不变:#4CB8C6 + +## 2. 字体 +- 标题:18 / 20px,600 +- 正文:16px,400 +- 小字:14px,400 +- 辅助文字:12px,400 +- 无衬线、无花字体、极度干净 + +## 3. 圆角 & 间距 +- 全局圆角:**14px** +- 卡片间距:12px +- 页面左右边距:16px +- 呼吸感极强,不拥挤 + +## 4. 图标 +- 线性极简图标 +- 2px线条、无填充、统一风格 +- 不花哨、不幼稚 + +## 5. 动效 +- 轻柔 250ms 缓动 +- 完成 → 轻微缩放消失 +- 弹窗 → 底部滑出 +- 无抖动、无浮夸 + +--- + +# 二、4大页面 UI 完整设计(逐页精细描述) +## 页面1:今日 · 聚合页(视觉最美) +**顶部** +- 居中日期:`3月31日 星期一` +- 左右淡色箭头,轻质感 +- 无标题栏、无 LOGO、极度干净 + +**今日待办卡片** +- 白卡片、14px圆角 +- 顶部:待办进度条(极细、柔和、主色填充) +- 文字:`今日待办 2/5 已完成` +- 列表: + 圆形勾选框 → 淡灰 → 完成变主色 + 标题:#2A3A4D + 优先级小圆点(左) + 无多余装饰 + +**今日记账卡片** +- 布局:左支出、右收入 +- 数字大字号、清新醒目 +- 支出红、收入绿、柔和不刺眼 +- 账单列表: + 分类小图标 + 名称 + 金额右对齐 + 极简分割线 + +**底部悬浮按钮(双按钮)** +- 左:+ 记一笔(主色) +- 右:+ 待办(浅主色) +- 圆角、轻微阴影、悬浮感 + +**整体气质** +- 像一本干净手账本 +- 留白充足、温柔治愈、高级耐看 + +--- + +## 页面2:待办 · 高级任务页 +**顶部** +- 搜索框:浅灰背景、无边框、圆角、放大镜图标 +- 标签栏:全部 / 未完成 / 已完成 + 选中 → 主色背景、白字 + 未选中 → 浅灰底、深灰字 + +**任务列表** +- 每条高度舒适、不拥挤 +- 左侧优先级圆点 +- 标题加粗、备注灰色小字 +- 右侧:完成勾选框 +- 重复任务 → 小循环图标 +- 子任务 → 缩进灰色细线 + +**长按进入批量模式** +- 复选框出现 +- 底部工具栏:完成 / 删除 / 改日期 +- 半透明浮层 + +**空状态** +- 中央极简线条图标 +- 文字:`今天也要认真生活呀` +- 颜色柔和、不压抑 + +--- + +## 页面3:账单 · 清新干净收支页 +**顶部** +- 切换按钮:支出 / 收入 / 全部 +- 风格同待办、统一视觉 + +**统计栏** +- 本月支出:数字大、醒目 +- 预算进度条(柔和、不刺眼) +- 超支变珊瑚色、正常主色 + +**分类横向栏(可滑动)** +- 小图标 + 名称 + 金额 +- 选中:主色圆角背景 +- 未选中:浅灰透明 + +**账单列表** +- 左:图标圆角浅底色块 +- 中:分类名 + 备注小字 +- 右:金额(右对齐) +- 无多余线条、干净高级 + +**记账弹窗(底部滑出)** +- 金额数字键盘大按钮 +- 分类宫格布局(3×4) +- 图标清新、不花俏 +- 备注输入框极简无边框 + +--- + +## 页面4:我的 · 高级质感设置页 +**顶部用户栏** +- 无头像、无昵称 +- 文字:`简记memo` + +**月度统计卡片** +- 双环形统计图(极细、清新) +- 左边:账单统计 +- 右边:待办完成率 +- 数字大、文字小、高级感 + +**功能菜单(列表)** +- 左图标、右文字、右箭头 +- 每行高度舒适 +- 无分割线、用间距区分 + +菜单结构: +- 数据导出 +- 月度预算 +- 主题切换(浅色/深色/自动) +- 待办自动顺延 +- 分类自定义 +- 清空数据 +- 关于与版本 + +**底部** +- 小号灰色文字:极简生活 · 简单记录 +- 无广告、无推荐、无多余信息 + +--- + +# 三、交互 UI 细节(惊艳但克制) +- 勾选完成 → 圆形填色 + 文字微淡出 +- 切换日期 → 左右平滑滑动 +- 预算不足 → 数字渐变珊瑚色 +- 悬浮按钮按压 → 轻微缩小 +- 弹窗从底部平滑弹出,遮罩渐变 +- 左滑删除 → 红色按钮、圆角 +- 所有可点区域 ≥44px +- 无锯齿、无生硬、无杂乱 + +--- + +# 四、UI 核心亮点(真正好看+高级+清新) +- 颜色:青薄荷 + 暖白,2026最流行清新风 +- 无冗余元素,每一处都有意义 +- 卡片呼吸感强,像苹果/Notion 风格 +- 深浅双主题自动适配 +- 文字层级清晰,阅读极舒适 +- 统一圆角、统一间距、极度规整 +- 温柔、安静、高级、不油腻 +- 单人开发完全可还原 + +--- + +# 五、下一步我直接给你「可直接开发」的内容 +你只要说一声,我立刻一次性完整输出: +1. 全局 **app.wxss 样式规范**(直接复制) +2. 4个页面 **UI 完整 wxml 结构** +3. 统一图标库、颜色常量、class 类名 +4. 自适应 + 深色模式代码 +5. 所有卡片、按钮、输入框、弹框样式 + +要我现在直接给你**全套可落地的 UI 代码**吗? \ No newline at end of file diff --git a/docs/growth_privilege_system.md b/docs/growth_privilege_system.md new file mode 100644 index 0000000..3927491 --- /dev/null +++ b/docs/growth_privilege_system.md @@ -0,0 +1,339 @@ +# 简记 · 成长等级特权系统方案 + +--- + +## 📊 一、方案定位 + +### 核心理念 +> **"限制现有功能 = 伤害用户体验,增值服务 = 激励用户成长"** + +### 设计原则 +1. ✅ **现有功能全部免费开放** - 所有核心功能不受等级限制 +2. ✅ **等级特权 = 额外奖励** - 给高等级用户的增值服务和荣誉展示 +3. ✅ **虚拟荣誉为主** - 称号、徽章、头像框等荣誉展示 +4. ✅ **增值功能为辅** - 高级统计、数据导出等额外服务 + +--- + +## 🏆 二、等级体系 + +| 等级 | 头衔 | 所需经验 | 图标 | 颜色 | 解锁特权 | +|------|------|---------|------|------|---------| +| **Lv.1** | 新手冒险家 | 0 | 🎒 | #999999 | 基础功能 | +| **Lv.2** | 坚持学徒 | 200 | 📝 | #10B981 | 心情日历 | +| **Lv.3** | 自律达人 | 600 | ⭐ | #3B82F6 | 月度预算、数据报表 | +| **Lv.4** | 习惯大师 | 1,500 | 💎 | #8B5CF6 | 自定义主题 | +| **Lv.5** | 传奇记录者 | 3,000 | 👑 | #F59E0B | 账单导出、年度报告 | +| **Lv.6** | 时光守望者 | 6,000 | 🏆 | #EF4444 | 任务模板、数据备份 | +| **Lv.7** | 超级达人 | 10,000 | 💫 | #FFD700 | 多账户管理、定期账单 | +| **Lv.8** | 传说存在 | 15,000 | 🌟 | #C0C0C0 | 心情分析、标签系统 | +| **Lv.9** | 永恒传奇 | 22,000 | ⭐ | #FF69B4 | 成就分享、云端备份 | +| **Lv.10** | 至高王者 | 30,000 | 👑 | #FFD700 | 专属头像框、无限功能、Beta测试优先 | + +**说明:** +- 所有核心功能(待办、记账、心情)始终免费开放 +- 特权列表中的功能是**额外增值服务**,不是限制 +- 等级越高,可享受的增值服务越多 + +--- + +## 🎖️ 三、荣誉系统(核心特权) + +### 3.1 等级称号 + +| 等级 | 称号 | 颜色 | 说明 | +|------|------|------|------| +| Lv.1 | 新手冒险家 | 灰色 | 默认获得 | +| Lv.2 | 坚持学徒 | 绿色 | 累计200 EXP | +| Lv.3 | 自律达人 | 蓝色 | 累计600 EXP | +| Lv.4 | 习惯大师 | 紫色 | 累计1500 EXP | +| Lv.5 | 传奇记录者 | 橙色 | 累计3000 EXP | +| Lv.6 | 时光守望者 | 红色 | 累计6000 EXP | +| Lv.7 | 超级达人 | 金色 | 累计10000 EXP | +| Lv.8 | 传说存在 | 银色 | 累计15000 EXP | +| Lv.9 | 永恒传奇 | 粉色 | 累计22000 EXP | +| Lv.10 | 至高王者 | 金色+特效 | 累计30000 EXP | + +**称号展示位置:** +- 个人主页昵称下方 +- 成长之路页面头部 +- 成就列表展示 +- 成就海报(可分享) + +--- + +### 3.2 成就徽章系统 + +#### 徽章稀有度(6个等级) + +| 稀有度 | 颜色 | 特效 | 说明 | +|--------|------|------|------| +| 初级 | #9CA3AF 灰色 | 无 | 容易获得 | +| 中级 | #10B981 绿色 | 微光 | 一般难度 | +| 高级 | #3B82F6 蓝色 | 发光 | 较难获得 | +| 稀有 | #8B5CF6 紫色 | 脉冲光 | 困难 | +| 史诗 | #F59E0B 橙色 | 火焰特效 | 很难 | +| 传说 | #EF4444 金色 | 旋转+光效 | 极难获得 | + +#### 徽章列表(共21个) + +**📅 坚持类徽章(7个)** + +| 徽章名称 | 图标 | 稀有度 | 条件 | 经验奖励 | +|---------|------|--------|------|---------| +| 初次尝试 | ✨ | 初级 | 完成第1天记录 | 10 EXP | +| 3天坚持 | 🌱 | 初级 | 连续记录3天 | 20 EXP | +| 一周打卡 | 🌟 | 中级 | 连续记录7天 | 50 EXP | +| 月度坚持 | 📅 | 高级 | 连续记录30天 | 200 EXP | +| 百日筑基 | 💯 | 稀有 | 连续记录100天 | 500 EXP | +| 半年丰碑 | 🏅 | 史诗 | 连续记录180天 | 1000 EXP | +| 年度传奇 | 🎉 | 传说 | 连续记录365天 | 2000 EXP | + +**✅ 任务类徽章(6个)** + +| 徽章名称 | 图标 | 稀有度 | 条件 | 经验奖励 | +|---------|------|--------|------|---------| +| 任务新手 | 🎯 | 初级 | 完成第1个任务 | 10 EXP | +| 任务达人 | ⚡ | 中级 | 累计完成100个任务 | 200 EXP | +| 任务大师 | 🏆 | 稀有 | 累计完成500个任务 | 800 EXP | +| 任务王者 | 👑 | 传说 | 累计完成1000个任务 | 1500 EXP | +| 完美一天 | ✨ | 高级 | 单日完成所有待办 | 30 EXP | +| 完美一周 | 💫 | 史诗 | 连续7天100%完成 | 200 EXP | + +**😊 心情类徽章(4个)** + +| 徽章名称 | 图标 | 稀有度 | 条件 | 经验奖励 | +|---------|------|--------|------|---------| +| 心情记录者 | 📝 | 初级 | 第1次记录心情 | 10 EXP | +| 月度心情家 | 🌈 | 中级 | 累计记录30天心情 | 100 EXP | +| 心情收藏家 | 🎨 | 稀有 | 累计记录100天心情 | 300 EXP | +| 阳光达人 | ☀️ | 高级 | 连续10天记录开心 | 150 EXP | + +**💰 记账类徽章(4个)** + +| 徽章名称 | 图标 | 稀有度 | 条件 | 经验奖励 | +|---------|------|--------|------|---------| +| 记账入门 | 💵 | 初级 | 第1次记账 | 10 EXP | +| 记账达人 | 💳 | 中级 | 累计记账100笔 | 200 EXP | +| 记账大师 | 🏅 | 稀有 | 累计记账500笔 | 500 EXP | +| 理财高手 | 💎 | 传说 | 累计记账2000笔 | 1200 EXP | + +--- + +## 🎁 四、增值服务特权 + +> 以下功能为基础功能之外的额外服务,高等级用户优先体验 + +### 4.1 数据服务 + +| 功能 | 解锁等级 | 说明 | +|------|---------|------| +| 账单导出 | Lv.5+ | 按时间范围导出账单(CSV格式) | +| 年度报告 | Lv.5+ | 年度数据回顾 | +| 数据备份 | Lv.6+ | 本地JSON数据备份 | +| 高级导出 | Lv.7+ | Excel格式导出 | + +### 4.2 高级统计 + +| 功能 | 解锁等级 | 说明 | +|------|---------|------| +| 数据报表 | Lv.3+ | 月度收支、分类统计 | +| 支出趋势图 | Lv.6+ | 多维度图表展示 | +| 心情分析 | Lv.8+ | 心情趋势预测 | + +### 4.3 个性化服务 + +| 功能 | 解锁等级 | 说明 | +|------|---------|------| +| 自定义主题 | Lv.4+ | 自定义主题颜色 | +| 成就海报 | Lv.5+ | 可分享到社交平台 | +| 头像框 | Lv.10+ | 专属头像框 | + +### 4.4 云端服务(未来开发) + +| 功能 | 解锁等级 | 说明 | +|------|---------|------| +| 云端备份 | Lv.9+ | 跨设备数据同步 | +| 数据恢复 | Lv.9+ | 误删数据恢复 | +| 优先客服 | Lv.10+ | 问题反馈优先处理 | + +--- + +## 👑 五、Lv.10 至高王者专属特权 + +### 5.1 独一无二称号 +- **"至高王者"** - 金色特效称号 +- 昵称旁显示金色皇冠图标 +- 专属入场动画 + +### 5.2 全能徽章 +- 获得一枚"全能徽章" +- 集齐所有传说徽章于一身 +- 徽章自带旋转光效 + +### 5.3 专属头像框 +- 金色动态头像框 +- 头像周围有金色光芒 + +### 5.4 特殊权限 +- 新功能Beta测试资格 +- 功能优化建议优先采纳 + +--- + +## 📊 六、徽章收集系统 + +### 6.1 收集进度 +- 在成长页面显示徽章收集进度 +- "已收集: X/21" +- 收集完成度百分比 + +### 6.2 徽章墙 +- 专门的徽章墙页面 +- 按稀有度分类展示 +- 已获得/未获得区分显示 + +--- + +## 🎨 七、UI展示设计 + +### 7.1 称号展示 + +``` +用户昵称 +[Lv.5] 传奇记录者 👑 +``` + +**样式:** +- 等级数字:主题色 +- 称号名称:对应等级颜色 +- 图标:根据等级显示 + +### 7.2 徽章展示 + +``` +┌─────────────────────────┐ +│ 🏆 年度传奇 │ +│ 连续打卡365天 │ +│ 2026-05-03 解锁 │ +└─────────────────────────┘ +``` + +**样式:** +- 史诗及以上:带光效边框 +- 传说及以上:动态微光 + +### 7.3 成就页面 + +``` +┌────────────────────────────────┐ +│ 🎖️ 我的成就 8/21 │ +├────────────────────────────────┤ +│ [全部] [坚持] [任务] [记账] [心情] │ +├────────────────────────────────┤ +│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ +│ │🏅 │ │🏅 │ │🔒 │ │🔒 │ │ +│ │初尝试│ │3天 │ │7天 │ │30天 │ │ +│ └────┘ └────┘ └────┘ └────┘ │ +└────────────────────────────────┘ +``` + +--- + +## 🛠️ 八、实施计划 + +### Phase 1:基础荣誉系统(已完成 ✅) +1. ✅ 设计10个等级称号 +2. ✅ 设计21个成就徽章 +3. ✅ 称号展示UI +4. ✅ 徽章收集进度 +5. ✅ 成长页面特权展示 + +### Phase 2:增值服务(短期开发) +1. 账单导出功能(Lv.5+) +2. 年度报告生成(Lv.5+) +3. 自定义主题扩展(Lv.4+) +4. 成就海报分享(Lv.5+) + +### Phase 3:高级功能(中期开发) +1. 高级数据统计(Lv.6+) +2. 心情趋势分析(Lv.8+) +3. 数据备份服务(Lv.6+) +4. Beta测试资格(Lv.10) + +### Phase 4:传说功能(长期规划) +1. 云端备份服务(Lv.9+) +2. 专属头像特效(Lv.10) +3. 全息徽章展示 +4. 社交分享功能 + +--- + +## 📝 九、激励机制 + +### 9.1 升级激励 +- 每次升级弹出庆祝动画 +- 展示新解锁的特权 +- 发放升级奖励 + +### 9.2 徽章激励 +- 获得新徽章时显示获得动画 +- 徽章墙上新增徽章高亮闪烁 +- 稀有徽章获得时特殊庆祝 + +### 9.3 收集激励 +- 收集完整一套成就,发放"全套收藏家"徽章 +- 不同稀有度组合有额外奖励 + +--- + +## ✅ 十、总结 + +### 方案优势 + +1. **不伤害用户体验** - 现有功能全部免费开放 +2. **激励效果强** - 称号、徽章等荣誉系统激励用户成长 +3. **增值服务合理** - 高级功能作为增值服务,高等级用户优先体验 +4. **长期运营可持续** - 不断推出新徽章、新特权,保持新鲜感 +5. **开发成本可控** - 分阶段实施,逐步完善 + +### 核心区别 + +| 类型 | 旧方案 | 新方案 | +|------|--------|--------| +| 定位 | 功能限制 | 荣誉激励 | +| 现有功能 | 受限 | 全免费 | +| 等级特权 | 解锁限制功能 | 额外荣誉+增值服务 | +| 用户体验 | 受挫感 | 成就感 | +| 长期价值 | 低 | 高 | + +--- + +## 📋 附录:成长规则速查 + +### 经验获取 +| 行为 | 经验 | 说明 | +|------|------|------| +| 完成任务 | 5-25 EXP/个 | 完成越多,单个经验越高 | +| 记账记录 | 3-18 EXP/笔 | 记录越多,单笔经验越高 | +| 记录心情 | 5 EXP/天 | 每日1次 | +| 每日上限 | 120 EXP | - | + +### 连续打卡加成 +| 连续天数 | 加成 | +|---------|------| +| 1-7天 | +2 EXP/天 | +| 8-30天 | +5 EXP/天 | +| 31-90天 | +10 EXP/天 | +| 91天以上 | +15 EXP/天 | + +### 新用户保护期 +- 前7天享受50%额外经验加成 +- 断签后连续天数不会完全清零 + +--- + +**文档版本**: v2.1 +**更新日期**: 2026-05-03 +**代码版本**: 与 api/app/growth.go 保持一致 +**设计理念**: "限制 = 伤害,激励 = 成长" diff --git a/docs/growth_rules.md b/docs/growth_rules.md new file mode 100644 index 0000000..9c829bd --- /dev/null +++ b/docs/growth_rules.md @@ -0,0 +1,670 @@ +# 成长等级系统规则文档 + +## 一、系统概述 + +成长等级系统是激励用户持续使用应用的核心机制,通过记录用户的日常行为(任务完成、记账、心情记录)来累计经验值,提升等级,解锁成就和特权。 + +--- + +## 二、API 接口 + +### 2.1 获取成长信息 + +**接口路径**: `POST /growth/info` + +**请求参数**: 无 + +**返回示例**: +```json +{ + "code": 0, + "msg": "success", + "data": { + "id": 1, + "level": 3, + "title": "自律达人", + "titleIcon": "⭐", + "experience": 650, + "nextLevelExp": 1500, + "totalDays": 45, + "streakDays": 12, + "maxStreakDays": 20, + "avgCompletionRate": 0, + "todayProgress": { + "tasksCompleted": 3, + "taskExp": 15, + "billsRecorded": 5, + "billExp": 13, + "moodRecorded": true, + "moodExp": 5, + "streakBonus": 5, + "protectBonus": 0, + "totalExp": 38, + "maxDailyExp": 120 + }, + "achievements": [ + { + "id": "first_record", + "name": "初次尝试", + "icon": "✨", + "unlocked": true, + "unlockDate": "2024-01-15" + } + ], + "privileges": [ + "解锁心情日历", + "解锁数据报表" + ] + } +} +``` + +### 2.2 获取成就列表 + +**接口路径**: `POST /growth/achievements` + +**请求参数**: +| 参数名 | 类型 | 必填 | 说明 | +| :--- | :--- | :--- | :--- | +| category | string | 否 | 过滤类别:streak/task/mood/bill | + +**返回示例**: +```json +{ + "code": 0, + "msg": "success", + "data": { + "unlocked": [ + { + "id": "first_record", + "name": "初次尝试", + "icon": "✨", + "category": "streak", + "description": "完成第1天记录", + "expReward": 10, + "progress": 1, + "target": 1, + "unlocked": true + } + ], + "locked": [ + { + "id": "streak_7", + "name": "一周打卡", + "icon": "🌟", + "category": "streak", + "description": "连续记录7天", + "expReward": 50, + "progress": 3, + "target": 7, + "unlocked": false + } + ] + } +} +``` + +### 2.3 增加经验 + +**接口路径**: `POST /growth/add` + +**请求参数**: +| 参数名 | 类型 | 必填 | 说明 | +| :--- | :--- | :--- | :--- | +| source | string | 是 | 来源:task/bill/mood | +| source_id | string | 否 | 关联记录ID | + +**返回示例**: +```json +{ + "code": 0, + "msg": "success", + "data": { + "expGained": 38, + "totalExp": 688, + "levelUp": false, + "currentLevel": 3, + "nextLevelExp": 1500, + "newAchievements": [] + } +} +``` + +--- + +## 三、等级体系 + +### 3.1 等级列表 + +| 等级 | 头衔 | 图标 | 所需累计经验 | +| :--- | :--- | :--- | :--- | +| Lv.1 | 新手冒险家 | 🎒 | 0 | +| Lv.2 | 坚持学徒 | 📝 | 200 | +| Lv.3 | 自律达人 | ⭐ | 600 | +| Lv.4 | 习惯大师 | 💎 | 1500 | +| Lv.5 | 传奇记录者 | 👑 | 3000 | +| Lv.6 | 时光守望者 | 🏆 | 6000 | +| Lv.7 | 超级达人 | 💫 | 10000 | +| Lv.8 | 传说存在 | 🌟 | 15000 | +| Lv.9 | 永恒传奇 | ⭐ | 22000 | +| Lv.10 | 至高王者 | 👑 | 30000 | + +### 3.2 等级经验计算逻辑 + +等级根据用户累计经验值自动计算: + +```go +func (h *growthHandler) getLevelByExp(exp int) int { + level := 1 + for l := 10; l >= 1; l-- { + if config, ok := LevelConfigs[l]; ok && exp >= config.Exp { + level = l + break + } + } + return level +} +``` + +--- + +## 四、经验值获取规则 + +### 4.1 基础经验 + +| 来源 | 基础经验 | +| :--- | :--- | +| 完成任务 (task) | 5 EXP | +| 记账记录 (bill) | 3 EXP | +| 心情记录 (mood) | 5 EXP | + +### 4.2 任务完成经验加成 + +| 今日完成任务数 | 经验计算 | +| :--- | :--- | +| 1-2个 | 5 EXP/个 | +| 3个以上 | 前2个 5 EXP/个,第3个起 10 EXP/个 | +| 5个以上 | 前2个 5 EXP/个,3-4个 10 EXP/个,第5个起 20 EXP/个 | + +**示例**: +- 完成2个任务:2 × 5 = 10 EXP +- 完成3个任务:2 × 5 + 10 = 20 EXP +- 完成5个任务:2 × 5 + 2 × 10 + 15 = 45 EXP + +### 4.3 记账经验加成 + +| 今日记账笔数 | 经验计算 | +| :--- | :--- | +| 1-2笔 | 3 EXP/笔 | +| 3笔以上 | 前2笔 3 EXP/笔,第3笔起 8 EXP/笔 | +| 5笔以上 | 前2笔 3 EXP/笔,3-4笔 8 EXP/笔,第5笔起 13 EXP/笔 | + +**示例**: +- 记录2笔账单:2 × 3 = 6 EXP +- 记录3笔账单:2 × 3 + 8 = 14 EXP +- 记录5笔账单:2 × 3 + 2 × 8 + 13 = 33 EXP + +### 4.4 心情记录经验 + +- 每次记录心情:5 EXP +- **每日仅限1次**:通过 Redis 记录 `mood_count:{userID}:{date}`,每日只能记录一次 + +### 4.5 连续打卡加成 + +连续打卡天数越多,加成越高: + +| 连续天数范围 | 每日加成 | +| :--- | :--- | +| 1-7天 | 2 EXP/天 | +| 8-30天 | 5 EXP/天 | +| 31-90天 | 10 EXP/天 | +| 91天以上 | 15 EXP/天 | + +### 4.6 新用户保护期 + +- 新用户注册后享有 **7天保护期** +- 保护期内享受 **50%额外加成** (`protectBonus = baseExp × 0.5`) +- 保护期内忘记打卡不会完全重置连续天数 +- 如果总记录天数 < 30天,保护期内的连续加成 **翻倍** +- 保护期结束后 `ProtectedDays` 字段递减 + +### 4.7 每日经验上限 + +**每日最高可获得 120 EXP**,超过部分会被截断。 + +--- + +## 五、连续打卡机制 + +### 5.1 打卡判断逻辑 + +```go +isFirstRecordToday := growth.LastRecordDate != today + +if isFirstRecordToday { + yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") + + if growth.LastRecordDate == yesterday { + // 昨天也打卡了,连续天数+1 + growth.StreakDays++ + } else if growth.LastRecordDate == "" || growth.LastRecordDate < yesterday { + // 昨天没打卡(断了) + if growth.StreakDays > 0 { + // 有连续记录但断了,根据之前的连续天数给予部分保留 + if growth.StreakDays <= 7 { + growth.StreakDays = 0 // 7天内断了,直接清零 + } else if growth.StreakDays <= 30 { + growth.StreakDays = 3 // 保留3天 + } else if growth.StreakDays <= 90 { + growth.StreakDays = 7 // 保留7天 + } else { + growth.StreakDays = 15 // 保留15天 + } + } + growth.StreakDays++ + } + + growth.TotalDays++ +} +``` + +### 5.2 断签处理规则 + +| 之前的连续天数 | 断签后保留天数 | +| :--- | :--- | +| ≤7天 | 0天(完全重置) | +| 8-30天 | 3天 | +| 31-90天 | 7天 | +| >90天 | 15天 | + +### 5.3 连续天数重置规则图解 + +``` +用户连续打卡25天后忘记打卡: +┌─────────────────────────────────────┐ +│ 第1-7天: 连续7天 │ +│ ↓ 断了 │ +│ 保留3天 → 再+1天 = 当前4天连续 │ +└─────────────────────────────────────┘ + +用户连续打卡100天后忘记打卡: +┌─────────────────────────────────────┐ +│ 第1-90天: 连续90天 │ +│ ↓ 断了 │ +│ 保留15天 → 再+1天 = 当前16天连续 │ +└─────────────────────────────────────┘ +``` + +--- + +## 六、成就系统 + +### 6.1 成就配置总览 + +| 成就ID | 名称 | 类别 | 描述 | 奖励经验 | 目标类型 | 目标值 | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| first_record | 初次尝试 | streak | 完成第1天记录 | 10 | total_days | 1 | +| streak_3 | 3天坚持 | streak | 连续记录3天 | 20 | streak | 3 | +| streak_7 | 一周打卡 | streak | 连续记录7天 | 50 | streak | 7 | +| streak_30 | 月度坚持 | streak | 连续记录30天 | 200 | streak | 30 | +| streak_100 | 百日筑基 | streak | 连续记录100天 | 500 | streak | 100 | +| streak_365 | 年度传奇 | streak | 连续记录365天 | 2000 | streak | 365 | +| task_1 | 任务新手 | task | 完成第1个任务 | 10 | total_tasks | 1 | +| task_100 | 任务达人 | task | 累计完成100个任务 | 200 | total_tasks | 100 | +| task_500 | 任务大师 | task | 累计完成500个任务 | 800 | total_tasks | 500 | +| perfect_day | 完美一天 | task | 单日完成所有待办 | 30 | perfect_day | 1 | +| perfect_week | 完美一周 | task | 连续7天100%完成 | 200 | perfect_week | 7 | +| mood_first | 心情记录者 | mood | 第1次记录心情 | 10 | total_moods | 1 | +| mood_30 | 月度心情家 | mood | 累计记录30天心情 | 100 | total_moods | 30 | +| mood_100 | 心情收藏家 | mood | 累计记录100天心情 | 300 | total_moods | 100 | +| happy_streak | 阳光达人 | mood | 连续10天记录开心 | 150 | happy_streak | 10 | +| bill_1 | 记账入门 | bill | 第1次记账 | 10 | total_bills | 1 | +| bill_100 | 记账达人 | bill | 累计记账100笔 | 200 | total_bills | 100 | +| bill_500 | 记账大师 | bill | 累计记账500笔 | 500 | total_bills | 500 | + +### 6.2 成就分类 + +#### 📅 坚持类 (streak) +- **first_record**: 初次尝试 - 完成第1天记录 +- **streak_3**: 3天坚持 - 连续记录3天 +- **streak_7**: 一周打卡 - 连续记录7天 +- **streak_30**: 月度坚持 - 连续记录30天 +- **streak_100**: 百日筑基 - 连续记录100天 +- **streak_365**: 年度传奇 - 连续记录365天 + +#### ✅ 任务类 (task) +- **task_1**: 任务新手 - 完成第1个任务 +- **task_100**: 任务达人 - 累计完成100个任务 +- **task_500**: 任务大师 - 累计完成500个任务 +- **perfect_day**: 完美一天 - 单日完成所有待办 +- **perfect_week**: 完美一周 - 连续7天100%完成 + +#### 😊 心情类 (mood) +- **mood_first**: 心情记录者 - 第1次记录心情 +- **mood_30**: 月度心情家 - 累计记录30天心情 +- **mood_100**: 心情收藏家 - 累计记录100天心情 +- **happy_streak**: 阳光达人 - 连续10天记录开心 + +#### 💰 记账类 (bill) +- **bill_1**: 记账入门 - 第1次记账 +- **bill_100**: 记账达人 - 累计记账100笔 +- **bill_500**: 记账大师 - 累计记账500笔 + +### 6.3 成就解锁机制 + +成就在以下时机检查并解锁: +1. **每次增加经验时**:检查相关成就是否达成 +2. **升级时**:额外检查所有成就是否达成 + +解锁成就后会立即发放经验奖励,并记录到 `UserAchievement` 表。 + +--- + +## 七、特权系统 + +### 7.1 等级特权一览 + +| 等级 | 解锁特权 | +| :--- | :--- | +| Lv.1 | 基础功能 | +| Lv.2 | 解锁心情日历 | +| Lv.3 | 解锁心情日历、解锁数据报表 | +| Lv.4 | 解锁心情日历、解锁数据报表、解锁自定义主题 | +| Lv.5 | 解锁心情日历、解锁数据报表、解锁自定义主题、解锁全部功能 | +| Lv.6 | 解锁心情日历、解锁数据报表、解锁自定义主题、解锁全部功能、解锁年度回顾 | +| Lv.7+ | 解锁心情日历、解锁数据报表、解锁自定义主题、解锁全部功能、解锁年度回顾、解锁数据导出 | + +### 7.2 特权获取逻辑 + +```go +func (h *growthHandler) getLevelPrivileges(level int) []string { + privileges := []string{} + + switch level { + case 1: + // Lv.1 无特权 + case 2: + privileges = append(privileges, "解锁心情日历") + case 3: + privileges = append(privileges, "解锁心情日历", "解锁数据报表") + case 4: + privileges = append(privileges, "解锁心情日历", "解锁数据报表", "解锁自定义主题") + case 5: + privileges = append(privileges, "解锁心情日历", "解锁数据报表", "解锁自定义主题", "解锁全部功能") + case 6: + privileges = append(privileges, "解锁心情日历", "解锁数据报表", "解锁自定义主题", "解锁全部功能", "解锁年度回顾") + case 7: + privileges = append(privileges, "解锁心情日历", "解锁数据报表", "解锁自定义主题", "解锁全部功能", "解锁年度回顾", "解锁数据导出") + default: + privileges = append(privileges, "解锁心情日历", "解锁数据报表", "解锁自定义主题", "解锁全部功能", "解锁年度回顾", "解锁数据导出") + } + + return privileges +} +``` + +--- + +## 八、数据统计指标 + +### 8.1 用户成长数据 (UserGrowth) + +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| ID | uint | 主键 | +| UserID | uint | 用户ID | +| Level | int | 当前等级 | +| Experience | int | 当前累计经验 | +| TotalDays | int | 累计记录天数 | +| StreakDays | int | 当前连续打卡天数 | +| MaxStreakDays | int | 历史最高连续天数 | +| LastRecordDate | string | 最后记录日期 (YYYY-MM-DD) | +| ProtectedDays | int | 新用户保护期剩余天数 | +| TotalTasks | int | 累计完成任务数 | +| TotalBills | int | 累计记账笔数 | +| TotalMoods | int | 累计心情记录数 | + +### 8.2 每日进度 (DailyProgress) + +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| ID | uint | 主键 | +| UserID | uint | 用户ID | +| RecordDate | string | 记录日期 (YYYY-MM-DD) | +| TaskCount | int | 今日完成任务数 | +| BillCount | int | 今日记账笔数 | +| MoodRecorded | bool | 今日是否记录心情 | +| DailyExp | int | 今日获得总经验 | +| ExpBreakdown | string | 经验明细 (JSON格式) | + +### 8.3 经验变化日志 (ExpChangeLog) + +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| ID | uint | 主键 | +| UserID | uint | 用户ID | +| ChangeType | string | 变更类型:add/level_up | +| ExpDelta | int | 经验变化量 | +| Source | string | 来源:task/bill/mood/level | +| SourceID | string | 关联记录ID | +| BeforeExp | int | 变化前经验 | +| AfterExp | int | 变化后经验 | +| RecordDate | string | 记录日期 | + +### 8.4 用户成就 (UserAchievement) + +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| ID | uint | 主键 | +| UserID | uint | 用户ID | +| AchievementID | string | 成就ID | +| UnlockDate | string | 解锁日期 | +| ExpRewarded | int | 已奖励经验值 | + +--- + +## 九、防刷机制 + +### 9.1 心情记录限制 + +```go +if source == "mood" { + key := fmt.Sprintf("mood_count:%d:%s", userID, today) + count, _ := global.Redis.Exists(ctx, key).Result() + if count > 0 { + utils.Fail(c, "今日心情已记录") + return + } +} +``` + +- 每日只能记录一次心情 +- 通过 Redis Key `mood_count:{userID}:{date}` 限制 +- Key 过期时间:25小时 + +### 9.2 并发控制 + +```go +lockKey := fmt.Sprintf("lock:growth:%d", userID) +ok, _ := global.Redis.SetNX(ctx, lockKey, "1", 2*time.Second).Result() +if !ok { + utils.Fail(c, "系统繁忙,请稍后重试") + return +} +defer global.Redis.Del(ctx, lockKey) +``` + +- 使用 Redis 分布式锁防止并发操作 +- 锁过期时间:2秒 + +--- + +## 十、经验计算示例 + +### 10.1 普通用户(第1天) + +``` +基础条件:无连续、无保护期 +操作:完成1个任务 + +经验计算: +- 任务基础经验:5 EXP +- 连续加成:0 EXP(无连续) +- 保护期加成:0 EXP(保护期需 TotalDays >= 30 才翻倍) + +今日总计:5 EXP +``` + +### 10.2 新用户(第7天,保护期内) + +``` +基础条件:连续7天,保护期剩余0天 +操作:完成3个任务,记录心情 + +经验计算: +- 任务基础经验:2×5 + 10 = 20 EXP +- 心情经验:5 EXP +- 连续加成(1-7天):2 EXP +- 保护期加成:5 × 50% = 2.5 EXP ≈ 2 EXP +- 保护期连续加成翻倍:2 × 2 = 4 EXP(因为 TotalDays=7 < 30) + +今日总计:20 + 5 + 4 + 2 = 31 EXP +``` + +### 10.3 老用户(连续30天) + +``` +基础条件:连续30天,保护期已过 +操作:完成5个任务,记录3笔账单,记录心情 + +经验计算: +- 任务经验:2×5 + 2×10 + 15 = 45 EXP +- 账单经验:2×3 + 8 = 14 EXP +- 心情经验:5 EXP +- 连续加成(8-30天):5 EXP +- 保护期加成:0 EXP(保护期已过) + +今日总计:45 + 14 + 5 + 5 = 69 EXP +``` + +### 10.4 满级用户(连续100天) + +``` +基础条件:连续100天,保护期已过 +操作:完成5个任务,记录5笔账单,记录心情 + +经验计算: +- 任务经验:45 EXP +- 账单经验:2×3 + 2×8 + 13 = 33 EXP +- 心情经验:5 EXP +- 连续加成(31-90天):10 EXP + +今日总计:45 + 33 + 5 + 10 = 93 EXP +``` + +--- + +## 十一、数据库表结构 + +### 11.1 user_growths 表 + +```sql +CREATE TABLE `user_growths` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `created_at` datetime(3) DEFAULT NULL, + `updated_at` datetime(3) DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `user_id` bigint unsigned NOT NULL COMMENT '用户ID', + `level` int DEFAULT '1' COMMENT '当前等级', + `experience` int DEFAULT '0' COMMENT '当前经验值', + `total_days` int DEFAULT '0' COMMENT '累计记录天数', + `streak_days` int DEFAULT '0' COMMENT '连续记录天数', + `max_streak_days` int DEFAULT '0' COMMENT '最高连续天数', + `last_record_date` varchar(10) DEFAULT '' COMMENT '最后记录日期', + `protected_days` int DEFAULT '7' COMMENT '新用户保护期剩余天数', + `total_tasks` int DEFAULT '0' COMMENT '累计完成任务数', + `total_bills` int DEFAULT '0' COMMENT '累计记账笔数', + `total_moods` int DEFAULT '0' COMMENT '累计心情记录数', + PRIMARY KEY (`id`), + UNIQUE KEY `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +### 11.2 daily_progress 表 + +```sql +CREATE TABLE `daily_progress` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `created_at` datetime(3) DEFAULT NULL, + `updated_at` datetime(3) DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `user_id` bigint unsigned NOT NULL COMMENT '用户ID', + `record_date` varchar(10) NOT NULL COMMENT '记录日期', + `task_count` int DEFAULT '0' COMMENT '今日完成任务数', + `bill_count` int DEFAULT '0' COMMENT '今日记账笔数', + `mood_recorded` tinyint(1) DEFAULT '0' COMMENT '是否记录心情', + `daily_exp` int DEFAULT '0' COMMENT '今日获得经验', + `exp_breakdown` text COMMENT '经验明细JSON', + PRIMARY KEY (`id`), + KEY `idx_user_date` (`user_id`,`record_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +### 11.3 exp_change_logs 表 + +```sql +CREATE TABLE `exp_change_logs` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `created_at` datetime(3) DEFAULT NULL, + `updated_at` datetime(3) DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `user_id` bigint unsigned NOT NULL COMMENT '用户ID', + `change_type` varchar(20) NOT NULL COMMENT '变更类型', + `exp_delta` int NOT NULL DEFAULT '0' COMMENT '经验变化量', + `source` varchar(20) DEFAULT '' COMMENT '来源', + `source_id` varchar(50) DEFAULT '' COMMENT '关联记录ID', + `before_exp` int DEFAULT '0' COMMENT '变化前经验', + `after_exp` int DEFAULT '0' COMMENT '变化后经验', + `record_date` varchar(10) NOT NULL COMMENT '记录日期', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_record_date` (`record_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +### 11.4 user_achievements 表 + +```sql +CREATE TABLE `user_achievements` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `created_at` datetime(3) DEFAULT NULL, + `updated_at` datetime(3) DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `user_id` bigint unsigned NOT NULL COMMENT '用户ID', + `achievement_id` varchar(50) NOT NULL COMMENT '成就ID', + `unlock_date` varchar(10) NOT NULL COMMENT '解锁日期', + `exp_rewarded` int DEFAULT '0' COMMENT '已奖励经验值', + PRIMARY KEY (`id`), + UNIQUE KEY `idx_user_achievement` (`user_id`,`achievement_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +--- + +## 十二、版本历史 + +| 版本 | 更新日期 | 更新内容 | +| :--- | :--- | :--- | +| v1.0 | 2024-01-01 | 初始版本,实现基础等级系统 | +| v1.1 | 2024-02-15 | 新增成就系统,包含18个成就 | +| v1.2 | 2024-03-20 | 新增连续打卡保护机制 | +| v1.3 | 2024-04-25 | 优化经验计算规则,添加任务/账单完成加成 | +| v1.4 | 2024-06-01 | 新增心情记录功能 | +| v1.5 | 2024-08-15 | 优化防刷机制,使用Redis分布式锁 | + +--- + +**文档版本**: v1.5 +**最后更新**: 2024年8月15日 +**适用项目**: simple-memo 后端成长等级系统 +**文档路径**: `/Users/sunct/WeChatProjects/simple-memo/docs/growth_rules.md` diff --git a/docs/growth_rules_user.md b/docs/growth_rules_user.md new file mode 100644 index 0000000..e5d3617 --- /dev/null +++ b/docs/growth_rules_user.md @@ -0,0 +1,238 @@ +# 🌱 成长之路 - 用户指南 + +欢迎来到成长系统!在这里,你的每一次记录都在积累力量,让我们一起看看如何快速成长吧~ + +--- + +## 📊 我的等级 + +### 当前等级:Lv.1 新手冒险家 🎒 + +| 我的经验 | 下一等级所需 | +| :--- | :--- | +| 0 EXP | 200 EXP | + +--- + +## ⬆️ 如何升级 + +### 升级规则 + +``` +累计经验达到对应等级的要求,即可自动升级! +``` + +| 等级 | 头衔 | 所需经验 | 图标 | +| :--- | :--- | :---: | :---: | +| Lv.1 | 新手冒险家 | 0 | 🎒 | +| Lv.2 | 坚持学徒 | 200 | 📝 | +| Lv.3 | 自律达人 | 600 | ⭐ | +| Lv.4 | 习惯大师 | 1,500 | 💎 | +| Lv.5 | 传奇记录者 | 3,000 | 👑 | +| Lv.6 | 时光守望者 | 6,000 | 🏆 | +| Lv.7 | 超级达人 | 10,000 | 💫 | +| Lv.8 | 传说存在 | 15,000 | 🌟 | +| Lv.9 | 永恒传奇 | 22,000 | ⭐ | +| Lv.10 | 至高王者 | 30,000 | 👑 | + +--- + +## ✨ 如何获取经验 + +### 基础获取方式 + +| 每日活动 | 经验奖励 | 每日上限 | +| :--- | :---: | :---: | +| ✅ 完成待办任务 | 5 EXP/个 | 任务做越多,奖励越高 | +| 💰 记录账单 | 3 EXP/笔 | 账单记越多,奖励越高 | +| 😊 记录心情 | 5 EXP | 限1次/天 | + +### 🎯 做任务 - 经验递增 + +| 完成任务数 | 获得经验 | +| :--- | :--- | +| 1个 | 5 EXP | +| 2个 | 10 EXP | +| 3个 | 20 EXP | +| 5个 | 45 EXP | + +> 💡 提示:一天完成的任务越多,每个任务的奖励就越高! + +### 💰 记账 - 经验递增 + +| 记账笔数 | 获得经验 | +| :--- | :--- | +| 1笔 | 3 EXP | +| 2笔 | 6 EXP | +| 3笔 | 14 EXP | +| 5笔 | 33 EXP | + +> 💡 提示:一天记录5笔账单,获得的经验是做1笔的5倍多! + +### 📅 连续打卡加成 + +坚持记录,连续天数越久,每天额外奖励越多! + +| 连续天数 | 每日额外加成 | +| :--- | :---: | +| 1-7天 | +2 EXP/天 | +| 8-30天 | +5 EXP/天 | +| 31-90天 | +10 EXP/天 | +| 91天以上 | +15 EXP/天 | + +**示例**: +- 坚持7天 → 每天多拿2 EXP +- 坚持30天 → 每天多拿5 EXP +- 坚持100天 → 每天多拿15 EXP + +--- + +## 🛡️ 新手保护 + +新用户前7天享受 **保护期** 特权: + +- ✨ 每天额外获得 **50%** 加成 +- 🛡️ 忘记打卡不会完全重置连续天数 +- 💪 保护期内的连续加成会 **翻倍** + +> 💡 即使偶尔忘记打卡,也不会“一夜回到解放前”哦~ + +--- + +## 🔢 每日经验上限 + +**每天最多获得 120 EXP** + +--- + +## 🎖️ 成就系统 + +完成特定目标,解锁成就并获得额外经验奖励! + +### 📅 坚持成就 + +| 成就 | 条件 | 奖励 | +| :--- | :--- | :---: | +| 初次尝试 | 完成第1天记录 | +10 EXP | +| 3天坚持 | 连续打卡3天 | +20 EXP | +| 一周打卡 | 连续打卡7天 | +50 EXP | +| 月度坚持 | 连续打卡30天 | +200 EXP | +| 百日筑基 | 连续打卡100天 | +500 EXP | +| 年度传奇 | 连续打卡365天 | +2000 EXP | + +### ✅ 任务成就 + +| 成就 | 条件 | 奖励 | +| :--- | :--- | :---: | +| 任务新手 | 完成1个任务 | +10 EXP | +| 任务达人 | 累计100个任务 | +200 EXP | +| 任务大师 | 累计500个任务 | +800 EXP | +| 完美一天 | 单日完成所有待办 | +30 EXP | +| 完美一周 | 连续7天100%完成 | +200 EXP | + +### 💰 记账成就 + +| 成就 | 条件 | 奖励 | +| :--- | :--- | :---: | +| 记账入门 | 记账1次 | +10 EXP | +| 记账达人 | 累计100笔 | +200 EXP | +| 记账大师 | 累计500笔 | +500 EXP | + +### 😊 心情成就 + +| 成就 | 条件 | 奖励 | +| :--- | :--- | :---: | +| 心情记录者 | 第1次记录心情 | +10 EXP | +| 月度心情家 | 累计30次心情 | +100 EXP | +| 心情收藏家 | 累计100次心情 | +300 EXP | +| 阳光达人 | 连续10天记录开心 | +150 EXP | + +--- + +## 🎁 等级特权 + +等级越高,解锁的特权越多! + +| 等级 | 解锁特权 | +| :--- | :--- | +| Lv.1 | 基础功能 | +| Lv.2 | 🌈 解锁心情日历 | +| Lv.3 | 🌈 解锁心情日历 📊 解锁数据报表 | +| Lv.4 | 🌈 解锁心情日历 📊 解锁数据报表 🎨 解锁自定义主题 | +| Lv.5 | 🌈 解锁心情日历 📊 解锁数据报表 🎨 解锁自定义主题 🚀 解锁全部功能 | +| Lv.6 | 🌈 解锁心情日历 📊 解锁数据报表 🎨 解锁自定义主题 🚀 解锁全部功能 📅 解锁年度回顾 | +| Lv.7+ | 🌈 解锁心情日历 📊 解锁数据报表 🎨 解锁自定义主题 🚀 解锁全部功能 📅 解锁年度回顾 📤 解锁数据导出 | + +--- + +## 💡 成长小贴士 + +### 🌟 快速升级秘籍 + +1. **每日必做**:每天完成至少3个待办任务 + - 3个任务 = 20 EXP + - 加上连续加成 = 25 EXP/天 + +2. **坚持记账**:每天记录3-5笔账单 + - 5笔账单 = 33 EXP + - 加上连续加成 = 38 EXP/天 + +3. **别忘心情**:每天记录一次心情 + - 心情记录 = 5 EXP + - 轻松简单,积少成多! + +4. **保持连续**:尽量不要断签 + - 连续越久,每天加成越高 + - 断了也没关系,保护期会保留部分天数 + +### 📈 每日收益估算 + +| 每日完成度 | 预计经验 | +| :--- | :---: | +| 只做1个任务 | ~5-7 EXP | +| 3个任务 + 心情 | ~30-40 EXP | +| 5个任务 + 5笔账单 + 心情 | ~80-100 EXP | +| 满额(连续加成拉满) | **150 EXP** | + +> 💰 按每天100 EXP计算: +> - Lv.1 → Lv.2:只需 **2天** +> - Lv.2 → Lv.3:只需 **4天** +> - Lv.3 → Lv.4:只需 **9天** +> - Lv.4 → Lv.5:只需 **15天** + +--- + +## ❓ 常见问题 + +**Q: 忘记打卡会怎样?** +A: 不用担心!如果连续天数不长,会重新从1开始计算。如果连续天数很长,会保留一部分(保留3-15天)。 + +**Q: 每天的经验有上限吗?** +A: 有的,每天最多获得120 EXP。 + +**Q: 成就可以重复解锁吗?** +A: 不行,每个成就只能解锁一次。 + +**Q: 经验会清零吗?** +A: 不会!你的等级和经验会一直保留。 + +**Q: 如何查看自己还差多少升级?** +A: 在「成长之路」页面可以查看当前经验和下一等级所需经验。 + +--- + +## 📱 查看入口 + +在我的页面点击 **「成长之路」** 卡片,可以查看: +- 当前等级和经验进度 +- 今日完成情况 +- 已解锁的成就 +- 享有的特权 + +点击 **「查看成长规则」** 按钮,可以查看详细的规则说明~ + +--- + +**祝你玩得开心,记录美好生活!** 🌟 + +*最后更新:2024年8月* diff --git a/docs/project_plan.md b/docs/project_plan.md new file mode 100644 index 0000000..6c337a0 --- /dev/null +++ b/docs/project_plan.md @@ -0,0 +1,1109 @@ +# 简记 Memo - 项目重构规划文档 + +> 本文档面向前后端开发人员,统一规划「复盘、心情、待办、账单」四大核心模块的业务功能、接口定义与 UI 设计规范。 +> 版本:v2.0 +> 日期:2026-07-28 + +--- + +## 一、项目概述与现状分析 + +### 1.1 产品定位 +「简记 Memo」是一款轻量级个人生活记录小程序,聚焦每日四大维度:**待办事项、账单收支、心情记录、每日复盘**。通过 AI 智能识别降低记录门槛,结合成长激励体系提升用户粘性。 + +### 1.2 现有架构问题 + +| 问题类别 | 具体表现 | 影响 | +|---------|---------|------| +| **数据层** | 复盘模块仅使用本地 `uni.setStorageSync`,未接入后端 | 数据无法跨设备同步,易丢失 | +| **接口层** | 首页需并发调用 3+ 接口获取当日数据 | 加载慢、代码冗余 | +| **UI 层** | 四个核心页面头部、卡片、按钮、弹窗样式均不统一 | 体验割裂,维护成本高 | +| **交互层** | 日历组件在首页/复盘/待办中重复实现 | 代码冗余,行为不一致 | +| **交互层** | 列表滑动操作在待办和账单中实现方式不同 | 用户学习成本高 | + +### 1.3 重构目标 +1. **数据统一**:复盘模块全面接入后端,四模块数据全部云端同步 +2. **接口聚合**:提供首页聚合接口,减少请求次数 +3. **UI 统一**:制定设计规范,四模块页面风格、布局、交互高度一致 +4. **组件复用**:抽取公共组件(日历、列表项、筛选器、模态框、空状态) +5. **操作丝滑**:统一动画曲线、过渡时长、触摸反馈 + +--- + +## 二、四大模块业务功能定义 + +### 2.1 模块总览 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 首页 (Dashboard) │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ 待办 │ │ 账单 │ │ 心情 │ │ 复盘 │ 快捷入口 │ +│ │ 3/5 │ │ +120 │ │ 😊 │ │ 已记录 │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +│ ───────────────────────────────────────────────────────── │ +│ AI 智能输入框:"开会花了50块,下午要写周报,心情不错" │ +│ ───────────────────────────────────────────────────────── │ +│ 今日概览:完成进度环 + 最近待办 + 最近账单 │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌─────────┬─────────┼─────────┬─────────┐ + ▼ ▼ ▼ ▼ ▼ + ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ + │ 待办 │ │ 心情 │ │ 账单 │ │ 复盘 │ │ 我的 │ + │ 列表 │ │ 记录 │ │ 明细 │ │ 编辑 │ │ 中心 │ + └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ +``` + +### 2.2 待办模块(Todo) + +**核心功能**: +- 按日期创建、编辑、删除待办事项 +- 支持优先级(高/中/低)、分类(工作/生活/学习)、时间段设置 +- 支持批量选择与批量操作(完成/删除) +- 按时间范围筛选(今日/本周/本月/自定义) +- 按状态筛选(全部/待办/已完成) +- 日历视图展示任务分布(有无任务、完成状态标记) +- 关键词搜索 + +**新增功能规划**: +- 待办重复规则(每日/每周/每月重复) +- 待办提醒推送(依赖服务端定时任务) + +### 2.3 心情模块(Mood) + +**核心功能**: +- 每日选择心情 emoji(20 种表情) +- 文字记录当日心情与感悟(200 字以内) +- AI 心语小笺:根据心情生成治愈文案并生成分享卡片 +- 心情日历:按月查看心情分布(已有 `moodCalendar` 页面) + +**新增功能规划**: +- 心情周报/月报统计(情绪趋势曲线 + 高频心情标签) +- 心情标签词云 + +### 2.4 账单模块(Bill) + +**核心功能**: +- 记录支出/收入账单(金额、分类、渠道、备注、日期) +- 分类管理(预设 23 个分类 + 自定义分类) +- 月度预算设置与进度追踪 +- 多维度筛选(时间:今日/本周/本月/上月/本年/自定义;类型:全部/支出/收入) +- 分类统计(支出/收入占比、横向滚动卡片) +- 账单导出 + +**新增功能规划**: +- 年度收支趋势图 +- 自定义分类支持图标与颜色完整自定义 + +### 2.5 复盘模块(Review) + +**核心功能**: +- 按日期进行每日复盘 +- 采用 **KPT 复盘法**:Keep(保持)、Problem(问题)、Try(尝试) +- 复盘历史列表查看 +- 复盘内容搜索 + +> **设计决策**:仅保留 KPT 一种复盘方法。理由:KPT 更简洁实用,3 个维度覆盖日常复盘需求,填写门槛低,适合小程序场景。GRAI 法(4 维度)更适合企业级项目复盘,个人日记场景偏重。 + +**重要改造点**: +- 当前复盘数据仅保存在本地 Storage,**已全面接入后端接口** +- 新增复盘历史列表页 +- API 响应兼容嵌套格式 `{kpt: {keep, problem, try}}` 和扁平格式 `{keep, problem, try}` + +--- + +## 三、接口文档 + +### 3.1 接口设计原则 +1. 统一请求方式:`POST` +2. 统一响应格式: +```json +{ + "code": 200, + "message": "success", + "data": { ... } +} +``` +3. 日期格式:`YYYY-MM-DD` +4. 月份格式:`YYYY-MM` + +### 3.2 现有接口保留清单 + +以下接口已稳定运行,本次规划**完全保留**,仅做部分参数优化建议: + +#### 用户模块 +| 接口 | 路径 | 说明 | +|-----|------|------| +| 微信登录 | `POST /user/login` | 保留 | +| 获取用户信息 | `POST /user/info` | 保留 | +| 更新用户信息 | `POST /user/edit` | 保留 | +| 退出登录 | `POST /user/logout` | 保留 | +| 获取用户配置 | `POST /user/config/info` | 保留 | +| 编辑用户配置 | `POST /user/config/edit` | 保留 | +| 获取头像列表 | `POST /user/avatar/list` | 保留 | + +#### 待办模块 +| 接口 | 路径 | 说明 | +|-----|------|------| +| 添加待办 | `POST /todo/add` | 保留 | +| 获取待办列表 | `POST /todo/list` | 保留,建议支持 `start_date` + `end_date` 范围查询 | +| 删除待办 | `POST /todo/delete` | 保留 | +| 完成/取消完成 | `POST /todo/done` | 保留 | +| 编辑待办 | `POST /todo/edit` | 保留 | + +#### 账单模块 +| 接口 | 路径 | 说明 | +|-----|------|------| +| 添加账单 | `POST /bill/add` | 保留 | +| 编辑账单 | `POST /bill/edit` | 保留 | +| 获取账单列表 | `POST /bill/list` | 保留,建议支持 `start_time` + `end_time` | +| 删除账单 | `POST /bill/delete` | 保留 | +| 获取月度统计 | `POST /bill/stat/month` | 保留 | +| 获取分类统计 | `POST /bill/stat/category` | 保留 | +| 导出账单 | `POST /bill/export` | 保留 | + +#### 预算模块 +| 接口 | 路径 | 说明 | +|-----|------|------| +| 获取月度预算 | `POST /budget/get` | 保留 | +| 设置月度预算 | `POST /budget/set` | 保留 | +| 删除月度预算 | `POST /budget/delete` | 保留 | + +#### 分类管理模块 +| 接口 | 路径 | 说明 | +|-----|------|------| +| 获取自定义分类 | `POST /category/list` | 保留 | +| 添加自定义分类 | `POST /category/add` | 保留 | +| 删除自定义分类 | `POST /category/delete` | 保留 | + +#### 心情模块 +| 接口 | 路径 | 说明 | +|-----|------|------| +| 获取某天心情 | `POST /mood/get` | 保留 | +| 保存心情 | `POST /mood/save` | 保留 | +| 获取心情列表 | `POST /mood/list` | 保留 | +| 删除心情 | `POST /mood/delete` | 保留 | +| 获取 AI 心情结果 | `POST /ai/mood/result` | 保留 | +| 重新生成 AI 心情 | `POST /ai/mood/regenerate` | 保留 | + +#### 成长系统模块 +| 接口 | 路径 | 说明 | +|-----|------|------| +| 获取成长信息 | `POST /growth/info` | 保留 | +| 获取成就列表 | `POST /growth/achievements` | 保留 | +| 获取成长规则 | `POST /growth/rules` | 保留 | + +#### AI 模块 +| 接口 | 路径 | 说明 | +|-----|------|------| +| AI 智能分类 | `POST /ai/classify` | 保留 | + +#### 邮箱/安全模块 +| 接口 | 路径 | 说明 | +|-----|------|------| +| 获取图形验证码 | `POST /captcha/digit` | 保留 | +| 发送邮箱验证码 | `POST /user/email/code` | 保留 | +| 设置/修改邮箱 | `POST /user/email/set` | 保留 | +| 邮箱登录 | `POST /user/email/login` | 保留 | +| 注册 | `POST /user/register` | 保留 | +| 重置密码 | `POST /user/reset-password` | 保留 | +| 修改密码 | `POST /user/change-password` | 保留 | +| 检查邮箱存在 | `POST /user/email/exists` | 保留 | +| 绑定微信 | `POST /user/bind-wechat` | 保留 | +| 检查微信绑定 | `POST /user/check-wechat` | 保留 | + +--- + +### 3.3 复盘模块接口(已全面接入后端) + +复盘模块已从本地 `uni.setStorageSync` 迁移至后端接口。前端同时兼容嵌套格式和扁平格式的响应数据。 + +#### 3.3.1 保存复盘 +```http +POST /review/save +``` +**请求参数**: +| 字段 | 类型 | 必填 | 说明 | +|-----|------|------|------| +| date | string | 是 | 日期 YYYY-MM-DD | +| method | string | 是 | 复盘方法:固定为 `kpt` | +| kpt | object | 是 | KPT 数据 | +| kpt.keep | string | 否 | 保持 | +| kpt.problem | string | 否 | 问题 | +| kpt.try | string | 否 | 尝试 | + +> 注:GRAI 法已移除,method 固定传 `kpt`。后端保留 `grai` 字段兼容历史数据。 + +**响应数据**: +```json +{ + "id": "review_123", + "date": "2026-07-28", + "method": "kpt", + "kpt": { + "keep": "今天按时完成了所有待办", + "problem": "下午注意力不集中", + "try": "明天尝试番茄工作法" + }, + "created_at": "2026-07-28T22:00:00Z", + "updated_at": "2026-07-28T22:00:00Z" +} +``` + +> **兼容说明**:前端同时支持后端返回嵌套格式 `{kpt: {keep, problem, try}}` 和扁平格式 `{keep, problem, try}`,确保接口对接顺畅。 + +#### 3.3.2 获取单日复盘 +```http +POST /review/get +``` +**请求参数**: +| 字段 | 类型 | 必填 | 说明 | +|-----|------|------|------| +| date | string | 是 | 日期 YYYY-MM-DD | + +**响应数据**:同保存复盘响应,无数据时返回 `null` + +#### 3.3.3 获取复盘列表 +```http +POST /review/list +``` +**请求参数**: +| 字段 | 类型 | 必填 | 说明 | +|-----|------|------|------| +| startDate | string | 否 | 开始日期 YYYY-MM-DD | +| endDate | string | 否 | 结束日期 YYYY-MM-DD | +| method | string | 否 | 筛选方法:`kpt`(历史可能含 `grai` 数据) | +| keyword | string | 否 | 搜索关键词(搜索 keep/problem/try/goal 等字段) | +| page | number | 否 | 页码,默认 1 | +| pageSize | number | 否 | 每页条数,默认 20 | + +**响应数据**: +```json +{ + "list": [ + { + "id": "review_123", + "date": "2026-07-28", + "method": "kpt", + "summary": "今天按时完成了所有待办...", + "created_at": "2026-07-28T22:00:00Z" + } + ], + "total": 100, + "page": 1, + "pageSize": 20 +} +``` + +#### 3.3.4 删除复盘 +```http +POST /review/delete +``` +**请求参数**: +| 字段 | 类型 | 必填 | 说明 | +|-----|------|------|------| +| id | string | 是 | 复盘 ID | + +--- + +### 3.4 新增接口 + +#### 3.4.1 首页聚合数据接口(推荐优先实现) + +**目的**:替代首页并发请求 `getTodoList` + `getBillList` + `getMoodByDate`,一接口返回当日全部概况。 + +```http +POST /dashboard/daily +``` + +**请求参数**: +| 字段 | 类型 | 必填 | 说明 | +|-----|------|------|------| +| date | string | 是 | 日期 YYYY-MM-DD,不传默认今天 | + +**响应数据**: +```json +{ + "date": "2026-07-28", + "todo": { + "total": 5, + "done": 3, + "undone": 2, + "list": [ + { "id": "1", "title": "写周报", "done": 0, "priority": 1, "category": "work" } + ] + }, + "bill": { + "expend": 128.5, + "income": 0, + "balance": -128.5, + "list": [ + { "id": "1", "type": 1, "money": 28.5, "cate": "food", "note": "午餐" } + ] + }, + "mood": { + "recorded": true, + "emoji": "😊", + "content": "今天心情不错,工作顺利", + "ai_generated": true, + "ai_text": "阳光正好,微风不燥..." + }, + "review": { + "recorded": true, + "method": "kpt" + }, + "growth": { + "todayExp": 45, + "maxDailyExp": 120, + "streakDays": 7 + } +} +``` + +#### 3.4.2 待办统计接口 + +```http +POST /todo/stat +``` + +**请求参数**: +| 字段 | 类型 | 必填 | 说明 | +|-----|------|------|------| +| start_date | string | 是 | 开始日期 | +| end_date | string | 是 | 结束日期 | + +**响应数据**: +```json +{ + "total": 50, + "done": 42, + "undone": 8, + "completion_rate": 84.0, + "by_category": { + "work": { "total": 20, "done": 18 }, + "life": { "total": 20, "done": 16 }, + "study": { "total": 10, "done": 8 } + }, + "by_priority": { + "1": { "total": 10, "done": 9 }, + "2": { "total": 25, "done": 21 }, + "3": { "total": 15, "done": 12 } + } +} +``` + +#### 3.4.3 心情统计接口 + +```http +POST /mood/stat +``` + +**请求参数**: +| 字段 | 类型 | 必填 | 说明 | +|-----|------|------|------| +| month | string | 是 | 月份 YYYY-MM | + +**响应数据**: +```json +{ + "month": "2026-07", + "total_days": 28, + "recorded_days": 25, + "mood_distribution": { + "😊": 10, + "😌": 8, + "😔": 4, + "😤": 2, + "😴": 1 + }, + "weekly_trend": [ + { "week": "W1", "avg_score": 4.2 }, + { "week": "W2", "avg_score": 3.8 }, + { "week": "W3", "avg_score": 4.5 }, + { "week": "W4", "avg_score": 4.0 } + ], + "top_phrases": ["开心", "平静", "工作顺利"] +} +``` + +#### 3.4.4 账单聚合统计接口(整合现有接口) + +**目的**:将 `getMonthStat` + `getCateStat` 整合为一个更灵活的统计接口。 + +```http +POST /bill/stat/aggregate +``` + +**请求参数**: +| 字段 | 类型 | 必填 | 说明 | +|-----|------|------|------| +| start_date | string | 是 | 开始日期 | +| end_date | string | 是 | 结束日期 | +| type | number | 否 | 类型筛选 1-支出 2-收入,不传则全部 | + +**响应数据**: +```json +{ + "total_expend": 3250.5, + "total_income": 8000.0, + "balance": 4749.5, + "budget": { + "amount": 5000, + "used": 3250.5, + "remaining": 1749.5, + "progress": 65.0 + }, + "by_category": [ + { + "cate": "food", + "label": "餐饮", + "icon": "🍚", + "color": "#ff6b6b", + "expend": 1200, + "income": 0, + "expend_rate": 36.9, + "income_rate": 0 + } + ], + "by_channel": [ + { "channel": "wechat", "label": "微信", "amount": 2000 } + ], + "daily_trend": [ + { "date": "2026-07-01", "expend": 100, "income": 0 }, + { "date": "2026-07-02", "expend": 50, "income": 8000 } + ] +} +``` + +--- + +### 3.5 接口整合与废弃建议 + +| 现有接口 | 建议 | 说明 | +|---------|------|------| +| `POST /bill/stat/month` | **保留但标记为兼容** | 建议前端逐步迁移到 `/bill/stat/aggregate` | +| `POST /bill/stat/category` | **保留但标记为兼容** | 同上 | +| `POST /review/save` | **修复接入** | 后端需确保接口可用,前端需替换本地存储逻辑 | +| `POST /review/get` | **修复接入** | 同上 | +| `POST /review/list` | **修复接入** | 同上,前端需新增复盘列表页 | +| `POST /review/delete` | **修复接入** | 同上 | + +--- + +## 四、统一 UI 设计规范 + +### 4.1 设计原则 + +1. **温暖自然**:沿用现有暖陶/抹茶/暮蓝等主题色系,保持低饱和度、手作感 +2. **统一间距**:建立 4rpx 基栅格,所有间距为 4 的倍数 +3. **统一圆角**:卡片 20rpx,按钮 14rpx,标签 12rpx,头像/图标 16rpx +4. **统一阴影**:仅卡片使用阴影 `0 8rpx 24rpx ${shadowLight}`,避免过度层级 +5. **操作反馈**:所有可点击元素添加 `:active` 态,缩放 0.96-0.98,过渡 0.2s + +### 4.2 页面布局骨架(四模块统一) + +所有核心模块页面采用统一的 **三段式布局**: + +``` +┌─────────────────────────────────────┐ +│ Page Header (固定高度 ~120rpx) │ +│ ┌──────┬──────────────┬────────┐ │ +│ │ 返回 │ 标题+日期 │ 操作Btn │ │ +│ └──────┴──────────────┴────────┘ │ +├─────────────────────────────────────┤ +│ Filter Bar (可选,固定或吸顶) │ +│ [全部][今日][本周][本月] ... │ +├─────────────────────────────────────┤ +│ │ +│ Main Content (scroll-view) │ +│ ┌─────────────────────────────┐ │ +│ │ Stat Cards 统计卡片 │ │ +│ └─────────────────────────────┘ │ +│ ┌─────────────────────────────┐ │ +│ │ Content List / Form │ │ +│ └─────────────────────────────┘ │ +│ │ +├─────────────────────────────────────┤ +│ Footer Action (固定底部) │ +│ ┌─────────────────────────────┐ │ +│ │ [ 保存 / 添加 / 提交 ] │ │ +│ └─────────────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +### 4.3 色彩规范(基于现有主题系统) + +沿用 `utils/theme.js` 中的 `THEME_PRESETS`,确保所有页面 100% 使用 `themeConfig` 绑定,禁止硬编码颜色。 + +| 用途 | 主题变量 | 默认值(暖陶) | +|-----|---------|--------------| +| 页面背景 | `themeConfig.bgColor` | `#FAF6F0` | +| 卡片背景 | `themeConfig.cardBgColor` | `#FFFCF8` | +| 主文本 | `themeConfig.textPrimary` | `#3D3630` | +| 次要文本 | `themeConfig.textSecondary` | `#7A7066` | +| 禁用文本 | `themeConfig.textDisabled` | `#B0A89E` | +| 主题色 | `themeConfig.primaryColor` | `#C68E3F` | +| 辅助色 | `themeConfig.secondaryColor` | `#4A9E7F` | +| 成功/收入 | `themeConfig.successColor` | `#5B9E6E` | +| 危险/支出 | `themeConfig.dangerColor` | `#C75B4A` | +| 警告 | `themeConfig.warningColor` | `#D4A24A` | +| 边框 | `themeConfig.borderColor` | `#E8E2DA` | +| 轻边框 | `themeConfig.borderLight` | `#F2EDE6` | +| 渐变按钮 | `themeConfig.gradientSoft` | `linear-gradient(135deg, #C68E3F, #D4AA60)` | +| 阴影轻 | `themeConfig.shadowLight` | `rgba(61,54,48,0.04)` | + +### 4.4 字体规范 + +| 元素 | 字号 | 字重 | 行高 | +|-----|------|------|------| +| 页面大标题 | 36rpx | 700 | 1.2 | +| 页面副标题/日期 | 24rpx | 500 | 1.4 | +| 卡片标题 | 30rpx | 700 | 1.3 | +| 正文内容 | 28rpx | 400 | 1.6 | +| 标签/辅助文字 | 22rpx | 500 | 1.4 | +| 金额数字 | 34rpx | 700 | 1.0 | +| 按钮文字 | 32rpx | 600 | 1.0 | + +### 4.5 公共组件规范 + +#### 4.5.1 页面头部(PageHeader) + +```vue + + + + +``` + +**样式规范**: +- 高度:自适应,内边距 `24rpx 36rpx` +- 顶部安全区:`padding-top: calc(24rpx + env(safe-area-inset-top))` +- 标题字号:36rpx,700 +- 副标题字号:24rpx,500 +- 右侧按钮:68rpx × 68rpx,圆角 20rpx,背景 `cardBgColor` +- 按钮点击态:`transform: scale(0.93); transition: all 0.25s ease` + +#### 4.5.2 统一卡片(AppCard) + +```vue + + + +``` + +**样式规范**: +- 背景:`themeConfig.cardBgColor` +- 圆角:20rpx +- 内边距:24rpx(可根据内容调整 20-32rpx) +- 阴影:`0 8rpx 24rpx ${themeConfig.shadowLight}` +- 边框:2rpx solid transparent(仅作为占位,实际靠阴影区分层级) + +#### 4.5.3 统一按钮 + +**主按钮(底部固定)**: +- 宽度:100%(减去左右边距) +- 高度:108rpx +- 圆角:100rpx(全圆角胶囊) +- 背景:`themeConfig.gradientVibrant` +- 文字:32rpx,#fff,600 +- 阴影:`0 12rpx 36rpx ${themeConfig.shadowColor}` +- 禁用态:`opacity: 0.5; pointer-events: none` + +**次要按钮(模态框内)**: +- 高度:88rpx-96rpx +- 圆角:14rpx +- 取消按钮:背景 `rgba(0,0,0,0.05)`,文字 `textSecondary` +- 确认按钮:背景 `gradientSoft`,文字 #fff + +**胶囊筛选标签**: +- 内边距:10rpx 24rpx +- 圆角:22rpx +- 默认态:背景 `borderColor`,文字 `textSecondary` +- 激活态:背景 `primaryColor`,文字 #fff +- 过渡:`all 0.25s ease` + +#### 4.5.4 统一模态框(BottomSheet / CenterModal) + +**底部弹出(用于新增/编辑)**: +- 宽度:100% +- 圆角:顶部 24rpx +- 背景:`cardBgColor` +- 动画:`slideUp 0.3s ease` +- 最大高度:85vh +- 底部安全区:`padding-bottom: calc(24rpx + env(safe-area-inset-bottom))` + +**居中弹窗(用于确认/设置)**: +- 宽度:600rpx +- 圆角:20rpx +- 背景:`cardBgColor` +- 动画:`popUp 0.25s ease` + +#### 4.5.5 统一日历组件(InlineCalendar) + +当前首页、复盘页各自实现了一套日历。**必须抽取为统一组件**。 + +**组件能力**: +- 月份切换(上一月/下一月) +- 日期选择回调 +- 标记日期(传入有数据的日期数组) +- 今日高亮 +- 选中态高亮(背景 `gradientSoft`,文字 #fff) + +**样式规范**: +- 背景:`cardBgColor` +- 圆角:20rpx +- 内边距:24rpx +- 星期标题:20rpx,600,`textSecondary` +- 日期格子:72rpx × 72rpx,圆角 10rpx +- 非当月日期:`opacity: 0.25` +- 数据标记点:8rpx 圆点,`primaryColor` + +#### 4.5.6 统一列表项(SwipeableItem) + +当前待办和账单的滑动删除/编辑逻辑各自实现,需统一。 + +**组件能力**: +- 左滑/右滑露出操作按钮 +- 支持多个操作按钮(编辑、删除、完成) +- 点击主区域收起滑动菜单 +- 同时只允许一项展开 + +**样式规范**: +- 滑动按钮宽度:80rpx/个 +- 按钮高度:100% +- 编辑按钮:`primaryColor` +- 删除按钮:`dangerColor` +- 完成按钮:`successColor` +- 文字:24rpx,#fff +- 滑动过渡:`transform 0.3s ease` + +#### 4.5.7 统一空状态(EmptyState) + +当前已存在 `components/EmptyState.vue`,**所有列表页面强制使用**。 + +**规范**: +- 图标:48rpx emoji 或 FaIcon +- 标题:26rpx,600,`textSecondary` +- 描述:22rpx,`textDisabled` +- 操作按钮(可选):主按钮样式 mini 版 + +--- + +## 五、各模块页面详细设计 + +### 5.1 首页(pages/index/index) + +**定位**:今日 Dashboard,聚合展示 + AI 快速录入。 + +**布局调整**: +1. **顶部问候区**:保留现有头像 + 问候语 + 日期显示,统一样式 +2. **AI 智能记录卡片**: + - 优化为更明显的录入入口 + - 快捷标签横向滚动(替代当前换行) + - 结果提示条动画优化 +3. **快捷入口四宫格**: + - 统一为 AppCard 样式 + - 图标背景色使用各模块主题色 + 15% 透明度 + - 待办显示 `待完成/总数`,账单显示 `今日收支`,心情显示 `今日emoji或未记录`,复盘显示 `已完成/未记录` +4. **今日概览卡片**: + - 保留完成进度环 + - 增加「复盘状态」行:今日是否已复盘 + - 最近待办列表最多展示 3 条(当前 2 条) + - 增加「最近账单」3 条预览(新增) + +**数据加载优化**: +- 优先调用新增接口 `POST /dashboard/daily` +- 失败时降级为现有并发请求 + +--- + +### 5.2 待办模块(pages/todo/todo) + +**定位**:任务管理中心,强调高效规划与批量操作。 + +**布局重构**: + +``` +┌─────────────────────────────────────┐ +│ Header: 待办清单 / 高效规划每一天 │ +│ [批量模式Btn] │ +├─────────────────────────────────────┤ +│ SearchBar: 🔍 搜索待办 │ +├─────────────────────────────────────┤ +│ FilterRow1: [今日][本周][本月][自定义]│ +│ FilterRow2: [全部][待办][已完成] │ +├─────────────────────────────────────┤ +│ InlineCalendar(时间筛非今日时显示) │ +├─────────────────────────────────────┤ +│ ListHeader: ● 任务列表 12项 │ +├─────────────────────────────────────┤ +│ ┌─────────────────────────────┐ │ +│ │ ☐ 🔴 写周报 14:00 │ │ +│ │ 工作 · 今天 │ │ +│ └─────────────────────────────┘ │ +│ ┌─────────────────────────────┐ │ +│ │ ☑ 🟡 买咖啡豆 │ │ +│ │ 生活 · 今天 [已完成] │ │ +│ └─────────────────────────────┘ │ +├─────────────────────────────────────┤ +│ EmptyState(无数据时) │ +├─────────────────────────────────────┤ +│ [+] 悬浮添加按钮 │ +└─────────────────────────────────────┘ +``` + +**样式统一**: +- 页面背景:`bgColor` +- 所有卡片使用 AppCard 规范 +- 任务列表项使用 SwipeableItem 组件 +- 优先级标识统一为圆形徽章(🔴🟡🟢 或 FaIcon),尺寸 40rpx +- 批量操作底部栏:固定底部,背景 `cardBgColor`,阴影 `shadowMedium` + +**交互优化**: +- 任务完成动画:点击后 checkbox 缩放弹跳 `cubic-bezier(0.34, 1.56, 0.64, 1)` +- 新增/编辑弹窗统一为 BottomSheet,字段分区:标题 > 日期/优先级 > 时间段 > 分类 > 备注 + +--- + +### 5.3 心情模块(pages/mood/mood) + +**定位**:情绪记录与治愈空间,强调温暖与仪式感。 + +**布局重构**: + +``` +┌─────────────────────────────────────┐ +│ Header: 记录心情 / 7月28日 │ +│ [心情日历Btn] │ +├─────────────────────────────────────┤ +│ Hero区域: │ +│ ┌─────────────────────────────┐ │ +│ │ 今天心情如何? │ │ +│ │ 选择最贴合此刻感受的... │ │ +│ └─────────────────────────────┘ │ +├─────────────────────────────────────┤ +│ MoodSelector(5列网格) │ +│ 😊 😌 😔 😤 😴 │ +│ 😰 😄 🤔 😢 😎 │ +│ ... │ +├─────────────────────────────────────┤ +│ SelectedBanner(选中后展示) │ +│ ┌─────────────────────────────┐ │ +│ │ 😊 开心 │ │ +│ │ 今天是开心的一天 │ │ +│ └─────────────────────────────┘ │ +├─────────────────────────────────────┤ +│ InputCard: 想对自己说 │ +│ ┌─────────────────────────────┐ │ +│ │ 记录此刻的心情与感悟... │ │ +│ │ ─────────── 0/200 │ │ +│ └─────────────────────────────┘ │ +├─────────────────────────────────────┤ +│ QuickPhrases: 快捷短语流式布局 │ +│ [今天很棒!] [工作很累] [加油呀] ... │ +├─────────────────────────────────────┤ +│ [ 发送心情 ✨ ] │ +└─────────────────────────────────────┘ +``` + +**样式统一**: +- 心情选项卡片:110rpx × 110rpx,圆形,背景 `cardBgColor` +- 选中态:缩放 1.15,背景 `primaryColor + 12%`,边框 `primaryColor` +- 过渡动画:`all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)` +- 文字输入区:AppCard 样式,内边距 28rpx +- 进度条:10rpx 高度,圆角 100rpx,背景 `borderLight`,填充 `gradientSoft` + +**交互优化**: +- 心情选中后,banner 区域淡入上移 +- 保存成功后触发 AI 生成,弹窗使用居中 Modal,展示加载/成功/失败状态 +- AI 结果卡片支持「换一换」和「保存图片」 + +--- + +### 5.4 账单模块(pages/bill/bill) + +**定位**:收支明细与财务概览,强调数据清晰与预算控制。 + +**布局重构**: + +``` +┌─────────────────────────────────────┐ +│ Header: 记账 / 记录每一笔收支 │ +│ [预算设置Btn] │ +├─────────────────────────────────────┤ +│ SearchBar: 🔍 搜索备注/分类 │ +├─────────────────────────────────────┤ +│ FilterRow1: [全部][支出][收入] │ +│ FilterRow2: [今日][本周][本月][上月]..│ +├─────────────────────────────────────┤ +│ BudgetCard(本月/上月预算进度,可选) │ +│ 本月预算 ¥5000 [========>--] 65% │ +├─────────────────────────────────────┤ +│ StatGrid(4宫格统计卡片) │ +│ ┌──────┐┌──────┐┌──────┐┌──────┐ │ +│ │总支出 ││总收入 ││周期结余││累计结余│ │ +│ │¥3250 ││¥8000 ││+¥4749││+¥4749│ │ +│ └──────┘└──────┘└──────┘└──────┘ │ +├─────────────────────────────────────┤ +│ CateStat(分类统计) │ +│ [餐饮¥1200] [交通¥300] [购物¥800]... │ +├─────────────────────────────────────┤ +│ BillList(账单列表) │ +│ ┌─────────────────────────────┐ │ +│ │ 🍚 餐饮 午餐 -¥28.5 │ │ +│ │ 微信 · 07/28 │ │ +│ └─────────────────────────────┘ │ +├─────────────────────────────────────┤ +│ [+] 悬浮添加按钮 │ +└─────────────────────────────────────┘ +``` + +**样式统一**: +- 统计 4 宫格:每个格子为 AppCard,内部图标 48rpx,金额 26rpx 700,标签 20rpx +- 分类统计:≤2 个时纵向满宽卡片,>2 个时横向滚动卡片(宽度 200rpx) +- 分类图标:56rpx × 56rpx,圆角 16rpx,白色图标文字 +- 账单列表项:高度 140rpx,使用 SwipeableItem,左滑显示编辑+删除 +- 金额颜色:支出 `dangerColor`,收入 `successColor` + +**交互优化**: +- 分类统计卡片点击后筛选对应分类,列表实时过滤 +- 记账弹窗(BottomSheet)分区:类型切换(支出/收入大按钮)> 金额输入(大字号)> 快捷金额 > 分类网格 > 渠道选择 > 备注 > 日期 > 底部按钮 +- 金额输入框获得焦点时自动弹出数字键盘 + +--- + +### 5.5 复盘模块(pages/review/review + 新增 reviewList) + +**定位**:每日反思与成长记录,强调结构化思考。 + +**重大改造**: +1. 现有 `review.vue` 改为**复盘编辑页** +2. 新增 `pages/review/reviewList.vue` 为**复盘历史列表页** +3. 所有数据从本地 Storage 迁移到后端接口 +4. **仅保留 KPT 复盘法**,移除 GRAI 方法切换 + +#### 5.5.1 复盘编辑页(review.vue) + +``` +┌─────────────────────────────────────┐ +│ Header: 今日复盘 / 7月28日 │ +│ [日历Btn] [历史Btn] │ +├─────────────────────────────────────┤ +│ IntroCard: 💡 KPT复盘法 │ +│ 通过记录做得好的、遇到的问题... │ +├─────────────────────────────────────┤ +│ Card 1: Keep(保持) │ +│ ┌─────────────────────────────┐ │ +│ │ 💡 做得好的地方 │ │ +│ │ ─────────────────────────── │ │ +│ │ 今天按时完成了所有待办... │ │ +│ │ ─────────── 45/500 │ │ +│ └─────────────────────────────┘ │ +│ Card 2: Problem(问题) │ +│ ┌─────────────────────────────┐ │ +│ │ ⚠️ 遇到的挑战 │ │ +│ │ ─────────────────────────── │ │ +│ │ 下午注意力不集中... │ │ +│ │ ─────────── 12/500 │ │ +│ └─────────────────────────────┘ │ +│ Card 3: Try(尝试) │ +│ ┌─────────────────────────────┐ │ +│ │ 🚀 改进方向 │ │ +│ │ ─────────────────────────── │ │ +│ │ 明天尝试番茄工作法... │ │ +│ │ ─────────── 28/500 │ │ +│ └─────────────────────────────┘ │ +├─────────────────────────────────────┤ +│ [ 保存复盘 ✨ ] │ +└─────────────────────────────────────┘ +``` + +**样式统一**: +- 介绍卡片:AppCard,左侧图标 56rpx,圆角 16rpx +- 输入卡片:AppCard,内边距 26rpx + - 卡片头部:图标 wrap 60rpx + 标题 30rpx 700 + 副标题 24rpx + - 输入框:min-height 160rpx,28rpx,行高 1.8 + - 底部进度条:8rpx 高度 + 字数统计 + +**交互优化**: +- 日期切换使用 InlineCalendar 组件 +- 离开页面时自动保存草稿到本地 Storage + +#### 5.5.2 复盘历史列表页(新增 reviewList.vue) + +**路由**:`pages/review/reviewList` + +``` +┌─────────────────────────────────────┐ +│ Header: 复盘历史 / 回顾与成长 │ +├─────────────────────────────────────┤ +│ Filter: 🔍搜索 │ +├─────────────────────────────────────┤ +│ ┌─────────────────────────────┐ │ +│ │ 07月28日 周一 │ │ +│ │ 保持:今天按时完成了所有待办... │ │ +│ └─────────────────────────────┘ │ +│ ┌─────────────────────────────┐ │ +│ │ 07月27日 周日 │ │ +│ │ 保持:专注度很高... │ │ +│ └─────────────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +- 列表项使用 AppCard,点击后进入复盘编辑页查看详情 +- 支持按日期范围筛选 + +--- + +### 5.6 我的心情日历(pages/moodCalendar/moodCalendar) + +**定位**:月度心情可视化回顾。 + +**布局优化**: +- 顶部月份切换器统一样式(与 InlineCalendar 一致) +- 日历格子内展示当日心情 emoji(28rpx) +- 底部增加「本月心情统计」卡片: + - 心情分布横向条形图 + - 高频词汇标签 + - 平均心情评分 + +--- + +## 六、公共组件开发清单 + +| 组件名 | 路径 | 用途 | 优先级 | +|-------|------|------|--------| +| `PageHeader` | `components/PageHeader.vue` | 统一页面头部 | 高 | +| `AppCard` | `components/AppCard.vue` | 统一卡片容器 | 高 | +| `InlineCalendar` | `components/InlineCalendar.vue` | 内联日历(选择/展示) | 高 | +| `SwipeableItem` | `components/SwipeableItem.vue` | 滑动列表项 | 高 | +| `CapsuleFilter` | `components/CapsuleFilter.vue` | 胶囊筛选标签组 | 高 | +| `BottomSheet` | `components/BottomSheet.vue` | 底部弹出模态框 | 高 | +| `CenterModal` | `components/CenterModal.vue` | 居中模态框 | 高 | +| `StatCard` | `components/StatCard.vue` | 统计数字卡片 | 中 | +| `ProgressBar` | `components/ProgressBar.vue` | 进度条(预算/经验等) | 中 | +| `EmptyState` | `components/EmptyState.vue` | 空状态(已存在,需推广到所有页面) | 高 | + +--- + +## 七、数据结构定义 + +### 7.1 待办项(TodoItem) +```typescript +interface TodoItem { + id: string; + title: string; + date: string; // YYYY-MM-DD + done: number; // 0-未完成 1-已完成 + priority: number; // 1-高 2-中 3-低 + category: string; // work / life / study + start_time?: string; // HH:MM + end_time?: string; // HH:MM + remark?: string; + repeat_type?: number; // 0-不重复 1-每日 2-每周 3-每月 + created_at: string; + updated_at: string; +} +``` + +### 7.2 账单项(BillItem) +```typescript +interface BillItem { + id: string; + type: number; // 1-支出 2-收入 + money: string; // 金额字符串,保留两位小数 + cate: string; // 分类 value + channel?: string; // 支付渠道 + note?: string; + date: string; // YYYY-MM-DD + created_at: string; +} +``` + +### 7.3 心情项(MoodItem) +```typescript +interface MoodItem { + id: string; + date: string; // YYYY-MM-DD + emoji: string; // emoji 字符 + content?: string; // 文字内容 + ai_text?: string; // AI 生成文案 + ai_image_url?: string; + generate_count?: number; + created_at: string; +} +``` + +### 7.4 复盘项(ReviewItem) +```typescript +interface ReviewItem { + id: string; + date: string; // YYYY-MM-DD + method: 'kpt'; // 固定为 kpt(历史数据可能含 'grai') + kpt?: { + keep: string; + problem: string; + try: string; + }; + created_at: string; + updated_at: string; +} +``` + +--- + +## 八、开发顺序建议 + +### 第一阶段:基础设施 ✅ 已完成 +1. ✅ 开发公共组件:`AppCard`、`PageHeader`、`CapsuleFilter`、`BottomSheet`、`CenterModal` +2. ✅ 开发 `InlineCalendar`、`SwipeableItem`、`ProgressBar`、`SkeletonScreen` 组件 +3. ✅ 开发 `themeMixin`、`growthModalMixin`、`moodMixin` 混入 +4. ✅ 实现请求缓存(`apiCache.js`)和请求去重机制 + +### 第二阶段:接口对接 ✅ 已完成 +1. ✅ 前端复盘模块全面接入后端 API(`saveReview`、`getReviewByDate`、`getReviewList`、`deleteReview`) +2. ✅ 新增聚合接口 `getDashboardDaily`(首页 3 合 1) +3. ✅ 新增统计接口 `getTodoStat`、`getMoodStat`、`getBillAggregateStat` +4. ✅ 移除复盘模块本地 Storage 逻辑(保留草稿自动保存) +5. ✅ 修复字体加载 ERR_CACHE_MISS(移除 CSS @font-face 本地路径) + +### 第三阶段:页面重构 ✅ 已完成 +1. ✅ 重构 `pages/review/review.vue`,接入后端 + 统一 UI + 仅保留 KPT 法 +2. ✅ 新增 `pages/review/reviewList.vue` 复盘历史列表 +3. ✅ 重构 `pages/todo/todo.vue`,接入公共组件 +4. ✅ 重构 `pages/mood/mood.vue`,接入公共组件 +5. ✅ 重构 `pages/bill/bill.vue`,接入公共组件 +6. ✅ 优化 `pages/index/index.vue`,接入聚合接口 + +### 第四阶段:体验优化 ✅ 已完成 +1. ✅ 统一所有页面过渡动画(pageFadeIn、cardEnter、theme-transition) +2. ✅ 优化列表加载骨架屏(SkeletonScreen 组件,支持 list/card/stats/dashboard/form 布局) +3. ✅ 空状态全覆盖(EmptyState 组件) +4. ✅ 请求缓存与去重(apiCache.js + request.js cache 选项) + +--- + +## 九、注意事项 + +1. **复盘数据迁移**:复盘由本地改为云端,老用户本地数据需考虑提示「历史数据将保留在本地,新复盘将同步到云端」,或在版本更新时提供一次性上传引导。 +2. **接口兼容性**:`POST /todo/list` 现有参数为 `date`,建议后端同时支持 `start_date` + `end_date` 以适配日历范围查询,保持向后兼容。 +3. **主题一致性**:所有重构页面必须 100% 使用 `themeConfig` 变量,禁止任何硬编码颜色值。 +4. **安全区适配**:所有固定底部操作栏必须添加 `env(safe-area-inset-bottom)` 适配。 +5. **性能优化**:列表页使用虚拟滚动或分页加载,避免一次性渲染大量数据。 +6. **字体加载**:微信小程序中字体仅通过 JS `uni.loadFontFace()` + base64 加载,CSS `@font-face` 本地路径会导致 ERR_CACHE_MISS,已移除。 +7. **复盘方法简化**:仅保留 KPT 复盘法,GRAI 法已移除。历史数据可能包含 GRAI 记录,列表页兼容展示。 + +--- + +*文档结束。如有疑问请在开发群中沟通确认。* diff --git a/docs/新UI.md b/docs/新UI.md new file mode 100644 index 0000000..51a6dc2 --- /dev/null +++ b/docs/新UI.md @@ -0,0 +1,160 @@ +# 「简记 · 事与钱」小程序 UI 设计规范 +## 清新唯美配色方案 + 全局视觉规范 +整体风格:**清透柔和、低饱和高级、留白充足、温柔治愈**,适配深浅双主题,兼顾极简与质感。 + +--- + +# 一、全局清新唯美主配色 +## 1. 浅色模式(主视觉·清新唯美) +| 用途 | 色值 | 视觉说明 | +|------|------|----------| +| 主品牌色 | `#A3D8D3` | 薄荷青,温柔干净,代表简洁、治愈、高效 | +| 辅助色1 | `#F8C8DC` | 淡樱花粉,点缀温馨,不艳俗 | +| 辅助色2 | `#B5D8FE` | 浅天青蓝,提升清爽感 | +| 收入色 | `#87D0A0` | 柔和薄荷绿,不刺眼 | +| 支出色 | `#F2A3A0` | 淡豆沙红,温和警示 | +| 优先级高 | `#F69999` | 浅红提醒 | +| 优先级中 | `#F5D090` | 浅黄温和提示 | +| 优先级低 | `#A0D9E0` | 浅蓝默认 | +| 背景底色 | `#FAFDFF` | 极浅冷白,护眼不惨白 | +| 卡片底色 | `#FFFFFF` | 纯白卡片,轻微阴影 | +| 分割线 | `#E8EEF2` | 极浅灰,弱化边界 | +| 主文字 | `#3A4A54` | 深灰蓝,柔和不黑 | +| 次级文字 | `#83959F` | 中性灰 | +| 提示文字 | `#B5C2C9` | 淡灰 | + +## 2. 深色模式(护眼·低饱和唯美) +| 用途 | 色值 | +|------|------| +| 主品牌色 | `#82BFB8` | +| 收入色 | `#70B887` | +| 支出色 | `#D98885` | +| 背景底色 | `#1C2528` | +| 卡片底色 | `#2A353A` | +| 分割线 | `#3A474F` | +| 主文字 | `#EDF3F5` | +| 次级文字 | `#9FB1B9` | +| 提示文字 | `#6B7B83` | + +--- + +# 二、全局视觉规范 +## 1. 字体 +- 标题:`font-weight: 600`,大小 `32rpx ~ 36rpx` +- 正文:`font-weight: 400`,大小 `28rpx` +- 备注/次要:`24rpx` +- 金额数字:`font-weight: 500`,清晰易读 + +## 2. 间距与圆角 +- 页面边距:`32rpx` +- 卡片内边距:`32rpx` +- 卡片圆角:`16rpx`(统一柔和大圆角) +- 按钮圆角:`12rpx` +- 列表项高度:`96rpx ~ 112rpx` + +## 3. 阴影与质感 +- 卡片阴影:`0 4rpx 20rpx rgba(163,216,211,0.08)` +- 悬浮按钮:轻微上浮阴影,柔和不重 +- 无描边、少边框,靠阴影与留白区分层级 + +--- + +# 三、四 Tab 页面 UI 布局设计 +## Page 1:今日(聚合页 · 核心首页) +- 顶部日期栏:浅薄荷青小字,左右箭头淡灰,切换开关用 `#A3D8D3` +- 今日待办卡片: + - 进度条主色 `#A3D8D3`,背景浅灰 + - 列表项:优先级小圆点 + 标题 + 勾选框 + - 空态:淡灰图标 + `#83959F` 文字 +- 今日记账卡片: + - 金额数字突出,支出淡红、收入柔和绿 + - 列表横向紧凑,分类图标小而精致 +- 底部双悬浮按钮: + - 左记账:`#B5D8FE` 浅蓝 + - 右加任务:`#A3D8D3` 薄荷青 + - 均为圆形、轻微阴影、极简图标 + +## Page 2:待办(任务管理页) +- 顶部搜索框:浅灰背景 `#F3F7F9`,圆角大 +- 筛选标签:选中态 `#A3D8D3` 背景+白字,未选中淡灰边框 +- 任务列表: + - 每条卡片式,优先级色块前置 + - 标题粗体,备注折叠淡灰色 + - 日期、重复标记居右小文字 +- 批量选择:勾选框用主色,背景轻微遮罩 +- 右下角悬浮添加按钮:主色圆形 + 白色 + 号 + +## Page 3:账单(收支管理页) +- 顶部切换按钮:支出/收入/全部,选中主色填充 +- 统计条:大数字突出,结余用主色强调 +- 分类横向滚动:小图标 + 分类名 + 金额,当前分类高亮主色 +- 账单列表: + - 分类图标统一圆形底色 + - 金额右对齐,支出红、收入绿 + - 备注折叠,日期居右下 +- 记账弹窗: + - 数字键盘干净简洁 + - 分类宫格布局,图标柔和配色 + - 保存按钮主色填充 + +## Page 4:我的(个人中心) +- 月度统计卡片: + - 环形统计图主色 `#A3D8D3` + - 任务完成率、收支数据清晰排布 +- 功能列表: + - 图标统一线性风格 + - 每项右侧箭头淡灰 + - 开关控件主色 +- 底部版本、关于:浅灰色小字,极简排版 + +--- + +# 四、交互动效(清新不花哨) +- 完成任务:轻微缩放 + 主色闪烁 +- 滑动删除/完成:平滑位移,不卡顿 +- 弹窗出现:淡入 + 轻微上浮 +- 切换主题:渐变过渡 +- 数据加载:极简转圈 `#A3D8D3` +- 点击反馈:轻微按压变暗 + +--- + +# 五、可直接复制使用的 WXSS 变量 +```css +/* 浅色模式 */ +page { + --main: #A3D8D3; + --income: #87D0A0; + --expend: #F2A3A0; + --bg: #FAFDFF; + --card: #FFFFFF; + --text: #3A4A54; + --text-secondary: #83959F; + --line: #E8EEF2; +} + +/* 深色模式 */ +@media (prefers-color-scheme: dark) { + page { + --main: #82BFB8; + --income: #70B887; + --expend: #D98885; + --bg: #1C2528; + --card: #2A353A; + --text: #EDF3F5; + --text-secondary: #9FB1B9; + --line: #3A474F; + } +} +``` + +--- + +如果你需要,我可以**下一步直接一次性输出完整可开发内容**: +1. 小程序项目目录结构(pages + components + utils + app.js) +2. 4 个页面完整 `WXML + WXSS + JS` 骨架代码 +3. 云数据库创建 JSON 脚本 +4. 日期格式化、金额计算、统计、增删改查工具函数 +5. 全局 app.wxss 统一样式 + +需要我直接把**可马上开发的完整技术原型**全部给你吗? \ No newline at end of file diff --git a/global/global.go b/global/global.go new file mode 100644 index 0000000..028a289 --- /dev/null +++ b/global/global.go @@ -0,0 +1,25 @@ +// 全局变量包:存放项目所有全局单例 +package global + +import ( + "math/rand" + "time" + + "github.com/redis/go-redis/v9" + "github.com/spf13/viper" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// 全局DB、Redis、配置、日志 +var ( + DB *gorm.DB // MySQL客户端 + Redis *redis.Client // Redis客户端 + VP *viper.Viper // 配置文件 + Logger *zap.Logger // 日志 + Rand *rand.Rand // 随机数生成器 +) + +func init() { + Rand = rand.New(rand.NewSource(time.Now().UnixNano())) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4a6bcbb --- /dev/null +++ b/go.mod @@ -0,0 +1,65 @@ +module simple-memo + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v4 v4.5.0 + github.com/importcjj/sensitive v0.0.0-20200106142752-42d1c505be7b + github.com/mojocn/base64Captcha v1.3.1 + github.com/redis/go-redis/v9 v9.7.0 // 升级到最新稳定版 + github.com/spf13/viper v1.17.0 + go.uber.org/zap v1.25.0 + golang.org/x/crypto v0.13.0 + gorm.io/driver/mysql v1.5.2 + gorm.io/gorm v1.25.4 +) + +require github.com/sony/sonyflake v0.0.0-20250518065636-2343cac6764e + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/sagikazarmark/locafero v0.3.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.10.0 // indirect + github.com/spf13/cast v1.5.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/image v0.0.0-20190802002840-cff245a6509b // indirect + golang.org/x/net v0.15.0 // indirect + golang.org/x/sys v0.12.0 // indirect + golang.org/x/text v0.13.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6c24bd3 --- /dev/null +++ b/go.sum @@ -0,0 +1,594 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= +github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/importcjj/sensitive v0.0.0-20200106142752-42d1c505be7b h1:9hudrgWUhyfR4FRMOfL9KB1uYw48DUdHkkgr9ODOw7Y= +github.com/importcjj/sensitive v0.0.0-20200106142752-42d1c505be7b/go.mod h1:zLVdX6Ed2SvCbEamKmve16U0E03UkdJo4ls1TBfmc8Q= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mojocn/base64Captcha v1.3.1 h1:2Wbkt8Oc8qjmNJ5GyOfSo4tgVQPsbKMftqASnq8GlT0= +github.com/mojocn/base64Captcha v1.3.1/go.mod h1:wAQCKEc5bDujxKRmbT6/vTnTt5CjStQ8bRfPWUuz/iY= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ= +github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sony/sonyflake v0.0.0-20250518065636-2343cac6764e h1:uvPpqiJaV+aEP9mHwLnpp8i0II9MwAPIZw8YUN2LfXM= +github.com/sony/sonyflake v0.0.0-20250518065636-2343cac6764e/go.mod h1:LORtCywH/cq10ZbyfhKrHYgAUGH7mOBa76enV9txy/Y= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY= +github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= +github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= +github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= +github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= +go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190501045829-6d32002ffd75/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs= +gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8= +gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/gorm v1.25.4 h1:iyNd8fNAe8W9dvtlgeRI5zSVZPsq3OpcTu37cYcpCmw= +gorm.io/gorm v1.25.4/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/middleware/encryption.go b/middleware/encryption.go new file mode 100644 index 0000000..9f00aee --- /dev/null +++ b/middleware/encryption.go @@ -0,0 +1,79 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "simple-memo/utils" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "simple-memo/global" +) + +func Encryption() gin.HandlerFunc { + return func(c *gin.Context) { + // 只处理POST + if c.Request.Method == "POST" { + body, err := ioutil.ReadAll(c.Request.Body) + if err != nil { + global.Logger.Error("读取请求体失败", + append(utils.LogContextFields(c), zap.Error(err))..., + ) + utils.Fail(c, "读取请求体失败") + c.Abort() + return + } + if len(body) == 0 { + utils.Fail(c, "请求体为空") + c.Abort() + return + } + + // 解析 {data:"加密串"} + var req map[string]string + if err = json.Unmarshal(body, &req); err != nil { + global.Logger.Error("解析请求体失败", + append(utils.LogContextFields(c), zap.Error(err))..., + ) + utils.Fail(c, "参数格式错误") + c.Abort() + return + } + encryptedData, ok := req["data"] + if !ok { + utils.Fail(c, "参数格式错误") + c.Abort() + return + } + + baseFields := utils.LogContextFields(c) + + // ====================== 日志:打印【加密串】 ====================== + global.Logger.Info("【请求加密数据】", + append(baseFields, + zap.String("encrypt_data", encryptedData), + )..., + ) + + // 解密 + decrypted, err := utils.AESDecrypt(encryptedData) + if err != nil { + utils.Fail(c, "解密失败") + c.Abort() + return + } + + // ====================== 日志:打印【解密后的明文】 ====================== + global.Logger.Info("【请求解密明文】", + append(baseFields, + zap.String("decrypt_data", string(decrypted)), + )..., + ) + + // 重新赋值给Body + c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(decrypted)) + } + c.Next() + } +} diff --git a/middleware/jwt.go b/middleware/jwt.go new file mode 100644 index 0000000..4e68612 --- /dev/null +++ b/middleware/jwt.go @@ -0,0 +1,81 @@ +// JWT鉴权:验证token并把userID存入上下文 +package middleware + +import ( + "context" + "net/http" + "simple-memo/global" + "simple-memo/utils" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func JWTAuth() gin.HandlerFunc { + return func(c *gin.Context) { + // 从header取token + token := c.GetHeader("token") + if token == "" { + // 检查是否为网页端页面路由(/web/*) + isWebPage := len(c.Request.URL.Path) >= 5 && c.Request.URL.Path[:5] == "/web/" + if isWebPage { + // 网页端页面:重定向到登录页 + c.Redirect(http.StatusFound, "/login") + } else { + // API接口:返回JSON错误 + utils.Fail(c, "请先登录") + } + c.Abort() + return + } + + // 检查 token 是否在黑名单中 + ctx := context.Background() + exists, err := global.Redis.Exists(ctx, "token_blacklist:"+token).Result() + if err != nil { + requestID, _ := c.Get("request_id") + clientIP, _ := c.Get("client_ip") + route, _ := c.Get("route") + global.Logger.Warn("检查token黑名单失败", + zap.String("request_id", requestID.(string)), + zap.String("route", route.(string)), + zap.String("client_ip", clientIP.(string)), + zap.Error(err), + ) + } else if exists == 1 { + // 检查是否为网页端页面路由 + isWebPage := len(c.Request.URL.Path) >= 5 && c.Request.URL.Path[:5] == "/web/" + if isWebPage { + c.Redirect(http.StatusFound, "/login") + } else { + utils.Fail(c, "登录已过期", 1000) + } + c.Abort() + return + } + + // 解析 + claims, err := utils.ParseToken(token) + if err != nil { + // 检查是否为网页端页面路由 + isWebPage := len(c.Request.URL.Path) >= 5 && c.Request.URL.Path[:5] == "/web/" + if isWebPage { + c.Redirect(http.StatusFound, "/login") + } else { + utils.Fail(c, "登录已过期", 1000) + } + c.Abort() + return + } + + // 把userID和token存入上下文 + c.Set("userID", claims.UserID) + c.Set("token", token) + + // 同时写入标准 context,供整条链路使用 + ctx = context.WithValue(c.Request.Context(), utils.UserIDKey, claims.UserID) + c.Request = c.Request.WithContext(ctx) + + c.Next() + } +} diff --git a/middleware/limiter.go b/middleware/limiter.go new file mode 100644 index 0000000..9079ab4 --- /dev/null +++ b/middleware/limiter.go @@ -0,0 +1,34 @@ +// IP限流:防刷、高并发保护 +package middleware + +import ( + "simple-memo/global" + "simple-memo/utils" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +func Limiter() gin.HandlerFunc { + return func(c *gin.Context) { + key := "limiter:" + c.ClientIP() + ctx := c.Request.Context() + + // 10秒内请求次数+1 + global.Redis.Incr(ctx, key) + global.Redis.Expire(ctx, key, 10*time.Second) + + // 转成数字再比较 + countStr := global.Redis.Get(ctx, key).Val() + count, _ := strconv.Atoi(countStr) + + // 10秒最多 100 次(正常开发完全够用) + if count > 100 { + utils.Fail(c, "请求过于频繁") + c.Abort() + return + } + c.Next() + } +} diff --git a/middleware/request_log.go b/middleware/request_log.go new file mode 100644 index 0000000..0c15c99 --- /dev/null +++ b/middleware/request_log.go @@ -0,0 +1,88 @@ +// 请求日志中间件:记录请求ID、路由、客户端IP等关键信息 +package middleware + +import ( + "context" + "fmt" + "simple-memo/global" + "simple-memo/utils" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/sony/sonyflake" + "go.uber.org/zap" +) + +// requestIDSF 用于生成全局唯一的请求ID +var requestIDSF *sonyflake.Sonyflake + +func init() { + requestIDSF = sonyflake.NewSonyflake(sonyflake.Settings{}) +} + +// genRequestID 基于 sonyflake 生成请求ID(base36 紧凑字符串) +func genRequestID() string { + if requestIDSF == nil { + return fmt.Sprintf("%x", time.Now().UnixNano()) + } + id, err := requestIDSF.NextID() + if err != nil { + return fmt.Sprintf("%x", time.Now().UnixNano()) + } + // base36 编码:紧凑且只含 [0-9a-z] + return strconv.FormatUint(id, 36) +} + +// RequestLog 请求日志中间件 +func RequestLog() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + + // 优先从请求头获取 request-id,否则自动生成 + requestID := c.GetHeader("X-Request-ID") + if requestID == "" { + requestID = genRequestID() + } + + // 客户端IP + clientIP := c.ClientIP() + + // 路由路径 + route := c.Request.URL.Path + + // 请求方法 + method := c.Request.Method + + // 写入标准 context,供整条链路使用 + ctx := context.WithValue(c.Request.Context(), utils.RequestIdKey, requestID) + ctx = context.WithValue(ctx, utils.RequestRouteKey, route) + ctx = context.WithValue(ctx, utils.ClientIPKey, clientIP) + c.Request = c.Request.WithContext(ctx) + + // 同时记录到 gin context,兼容老代码 + c.Set("request_id", requestID) + c.Set("client_ip", clientIP) + c.Set("route", route) + + // 打印请求进入日志 + global.Logger.Info("【请求进入】", + zap.String("request_id", requestID), + zap.String("route", route), + zap.String("method", method), + zap.String("client_ip", clientIP), + zap.String("user_agent", c.Request.UserAgent()), + ) + + c.Next() + + // 请求结束后打印耗时日志 + duration := time.Since(start) + global.Logger.Info("【请求结束】", + zap.String("request_id", requestID), + zap.String("route", route), + zap.Int("status", c.Writer.Status()), + zap.Duration("duration", duration), + ) + } +} diff --git a/models/bill.go b/models/bill.go new file mode 100644 index 0000000..659d575 --- /dev/null +++ b/models/bill.go @@ -0,0 +1,18 @@ +// 账单表:sm_bill +package models + +import "gorm.io/gorm" + +type Bill struct { + gorm.Model + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"column:user_id;type:bigint unsigned;default:0;index;comment:用户ID" json:"user_id" db:"user_id"` + Type int `gorm:"column:type;type:tinyint(1);not null;default:0;comment:类型 1支出 2收入 3转账" json:"type" db:"type"` + Money float64 `gorm:"column:money;type:decimal(10,2);not null;default:0.00;comment:金额" json:"money" db:"money"` + Cate string `gorm:"column:cate;type:varchar(20);default:'';comment:分类名称" json:"cate" db:"cate"` + Note string `gorm:"column:note;type:varchar(100);default:'';comment:备注" json:"note" db:"note"` + Channel string `gorm:"column:channel;type:varchar(20);default:'';comment:渠道" json:"channel" db:"channel"` + Date string `gorm:"column:date;type:varchar(10);not null;index;comment:日期 2025-05-01" json:"date" db:"date"` + FromAccount string `gorm:"column:from_account;type:varchar(20);default:'';comment:转出账户" json:"from_account" db:"from_account"` + ToAccount string `gorm:"column:to_account;type:varchar(20);default:'';comment:转入账户" json:"to_account" db:"to_account"` +} diff --git a/models/budget.go b/models/budget.go new file mode 100644 index 0000000..645d5f4 --- /dev/null +++ b/models/budget.go @@ -0,0 +1,14 @@ +// 预算表:sm_budget +package models + +import "gorm.io/gorm" + +// Budget 月度预算表 +// 表名:sm_budget +type Budget struct { + gorm.Model // 包含 ID, CreatedAt, UpdatedAt, DeletedAt + + UserID uint `gorm:"column:user_id;type:bigint unsigned;default:0;index:idx_user_month;comment:用户ID" json:"user_id" db:"user_id"` + Month string `gorm:"column:month;type:varchar(10);not null;index:idx_user_month;comment:月份 2025-05" json:"month" db:"month"` + Amount float64 `gorm:"column:amount;type:decimal(10,2);not null;default:0.00;comment:月度预算金额" json:"amount" db:"amount"` +} diff --git a/models/category.go b/models/category.go new file mode 100644 index 0000000..5a879c7 --- /dev/null +++ b/models/category.go @@ -0,0 +1,13 @@ +// 分类表:sm_category +package models + +import "gorm.io/gorm" + +type Category struct { + gorm.Model + UserID uint `gorm:"column:user_id;type:bigint unsigned;default:0;index:idx_user_type;comment:用户ID 0=系统内置" json:"user_id" db:"user_id"` + Name string `gorm:"column:name;type:varchar(20);not null;comment:分类名称" json:"name" db:"name"` + Type int `gorm:"column:type;type:tinyint(1);not null;index:idx_user_type;comment:类型 1支出 2收入" json:"type" db:"type"` + Icon string `gorm:"column:icon;type:varchar(50);default:'';comment:分类图标" json:"icon" db:"icon"` + Color string `gorm:"column:color;type:varchar(20);default:'#666666';comment:分类颜色" json:"color" db:"color"` +} diff --git a/models/daily_progress.go b/models/daily_progress.go new file mode 100644 index 0000000..9a43f6e --- /dev/null +++ b/models/daily_progress.go @@ -0,0 +1,19 @@ +// 每日进度表:sm_daily_progress +package models + +import "gorm.io/gorm" + +type DailyProgress struct { + gorm.Model + UserID uint `gorm:"column:user_id;type:bigint unsigned;not null;uniqueIndex:idx_user_date;comment:用户ID" json:"user_id" db:"user_id"` + RecordDate string `gorm:"column:record_date;type:varchar(10);not null;uniqueIndex:idx_user_date;comment:日期 yyyy-MM-dd" json:"record_date" db:"record_date"` + TaskCount int `gorm:"column:task_count;type:int;default:0;comment:完成任务数" json:"task_count" db:"task_count"` + TotalTasks int `gorm:"column:total_tasks;type:int;default:0;comment:当日待办总数" json:"total_tasks" db:"total_tasks"` + BillCount int `gorm:"column:bill_count;type:int;default:0;comment:记账笔数" json:"bill_count" db:"bill_count"` + MoodRecorded bool `gorm:"column:mood_recorded;type:tinyint(1);default:0;comment:是否记录心情" json:"mood_recorded" db:"mood_recorded"` + MoodType string `gorm:"column:mood_type;type:varchar(20);default:'';comment:心情类型 happy/normal/sad" json:"mood_type" db:"mood_type"` + ReviewRecorded bool `gorm:"column:review_recorded;type:tinyint(1);default:0;comment:是否复盘" json:"review_recorded" db:"review_recorded"` + PerfectDay bool `gorm:"column:perfect_day;type:tinyint(1);default:0;comment:是否完美一天(100%完成待办)" json:"perfect_day" db:"perfect_day"` + DailyExp int `gorm:"column:daily_exp;type:int;default:0;comment:今日获得经验" json:"daily_exp" db:"daily_exp"` + ExpBreakdown string `gorm:"column:exp_breakdown;type:varchar(200);default:'';comment:经验构成JSON" json:"exp_breakdown" db:"exp_breakdown"` +} diff --git a/models/exp_change_log.go b/models/exp_change_log.go new file mode 100644 index 0000000..1d5700e --- /dev/null +++ b/models/exp_change_log.go @@ -0,0 +1,16 @@ +// 经验变化日志表:sm_exp_change_log +package models + +import "gorm.io/gorm" + +type ExpChangeLog struct { + gorm.Model + UserID uint `gorm:"column:user_id;type:bigint unsigned;not null;index;comment:用户ID" json:"user_id" db:"user_id"` + ChangeType string `gorm:"column:change_type;type:varchar(20);comment:变化类型 add/level_up/achievement" json:"change_type" db:"change_type"` + ExpDelta int `gorm:"column:exp_delta;type:int;comment:经验变化量" json:"exp_delta" db:"exp_delta"` + Source string `gorm:"column:source;type:varchar(20);comment:来源 task/bill/mood/streak/achievement" json:"source" db:"source"` + SourceID string `gorm:"column:source_id;type:varchar(50);comment:关联记录ID" json:"source_id" db:"source_id"` + BeforeExp int `gorm:"column:before_exp;type:int;comment:变化前经验" json:"before_exp" db:"before_exp"` + AfterExp int `gorm:"column:after_exp;type:int;comment:变化后经验" json:"after_exp" db:"after_exp"` + RecordDate string `gorm:"column:record_date;type:varchar(10);comment:记录日期" json:"record_date" db:"record_date"` +} diff --git a/models/mood.go b/models/mood.go new file mode 100644 index 0000000..19cb7d5 --- /dev/null +++ b/models/mood.go @@ -0,0 +1,19 @@ +// 心情表:sm_mood +package models + +import "gorm.io/gorm" + +type Mood struct { + gorm.Model + UserID uint `gorm:"column:user_id;type:bigint unsigned;default:0;index;comment:用户ID" json:"user_id" db:"user_id"` + Date string `gorm:"column:date;type:varchar(10);uniqueIndex:idx_user_date;comment:日期 yyyy-MM-dd" json:"date" db:"date"` + Emoji string `gorm:"column:emoji;type:varchar(10);default:'';comment:心情表情" json:"emoji" db:"emoji"` + Content string `gorm:"column:content;type:varchar(500);default:'';comment:心情内容" json:"content" db:"content"` + + // AI生成内容(使用 gorm:"-" 避免写入,只用于查询) + AIGen MoodAIGeneration `gorm:"-" json:"ai_gen,omitempty"` +} + +func (Mood) TableName() string { + return "sm_mood" +} diff --git a/models/mood_ai.go b/models/mood_ai.go new file mode 100644 index 0000000..d577ed0 --- /dev/null +++ b/models/mood_ai.go @@ -0,0 +1,23 @@ +// 心情AI生成记录表:sm_mood_ai_generations +package models + +import "gorm.io/gorm" + +// MoodAIGeneration 心情AI生成记录 +type MoodAIGeneration struct { + gorm.Model + MoodID uint `gorm:"column:mood_id;type:bigint unsigned;index;comment:关联的心情ID" json:"mood_id"` + UserID uint `gorm:"column:user_id;type:bigint unsigned;index;comment:用户ID" json:"user_id"` + Emoji string `gorm:"column:emoji;type:varchar(50);comment:心情表情" json:"emoji"` + Content string `gorm:"column:content;type:varchar(500);comment:心情内容" json:"content"` + Date string `gorm:"column:date;type:varchar(10);comment:日期" json:"date"` + AIText string `gorm:"column:ai_text;type:text;comment:AI生成的文本" json:"ai_text"` + AIImageURL string `gorm:"column:ai_image_url;type:varchar(500);comment:AI生成的图片URL" json:"ai_image_url"` + GenerateCount int `gorm:"column:generate_count;type:int;default:1;comment:生成次数" json:"generate_count"` + Status int `gorm:"column:status;type:tinyint;default:0;comment:状态 0生成中 1成功 2失败" json:"status"` + ErrorMsg string `gorm:"column:error_msg;type:varchar(500);comment:错误信息" json:"error_msg"` +} + +func (MoodAIGeneration) TableName() string { + return "sm_mood_ai_generations" +} diff --git a/models/review.go b/models/review.go new file mode 100644 index 0000000..6aa2a67 --- /dev/null +++ b/models/review.go @@ -0,0 +1,19 @@ +// 复盘表:sm_review +package models + +import "gorm.io/gorm" + +// Review 复盘记录(KPT 法) +type Review struct { + gorm.Model + UserID uint `gorm:"column:user_id;type:bigint unsigned;default:0;index;comment:用户ID" json:"user_id" db:"user_id"` + Date string `gorm:"column:date;type:varchar(10);uniqueIndex:idx_user_date;comment:日期 yyyy-MM-dd" json:"date" db:"date"` + // KPT 法 + Keep string `gorm:"column:keep;type:text;comment:保持" json:"keep" db:"keep"` + Problem string `gorm:"column:problem;type:text;comment:问题" json:"problem" db:"problem"` + Try string `gorm:"column:try;type:text;comment:尝试" json:"try" db:"try"` +} + +func (Review) TableName() string { + return "sm_review" +} diff --git a/models/todo.go b/models/todo.go new file mode 100644 index 0000000..5589f40 --- /dev/null +++ b/models/todo.go @@ -0,0 +1,22 @@ +// 待办表:sm_todo +package models + +import "gorm.io/gorm" + +type Todo struct { + gorm.Model + UserID uint `gorm:"column:user_id;type:bigint unsigned;default:0;index;comment:用户ID" json:"user_id" db:"user_id"` + Title string `gorm:"column:title;type:varchar(50);default:'';comment:任务标题" json:"title" db:"title"` + Remark string `gorm:"column:remark;type:varchar(255);default:'';comment:任务备注" json:"remark" db:"remark"` + Category string `gorm:"column:category;type:varchar(20);default:'';comment:分类 work/life/study" json:"category" db:"category"` + Priority int `gorm:"column:priority;type:tinyint(1);default:2;comment:优先级 1高 2中 3低" json:"priority" db:"priority"` + RepeatType string `gorm:"column:repeat_type;type:varchar(20);default:'none';comment:重复类型 none/day/week/month" json:"repeat_type" db:"repeat_type"` + Done int `gorm:"column:done;type:tinyint(1);default:0;comment:是否完成 0未完成 1已完成" json:"done" db:"done"` + Date string `gorm:"column:date;type:varchar(10);index;comment:任务日期 yyyy-MM-dd" json:"date" db:"date"` + StartTime string `gorm:"column:start_time;type:varchar(10);comment:任务开始时间" json:"start_time" db:"start_time"` + EndTime string `gorm:"column:end_time;type:varchar(10);comment:任务结束时间" json:"end_time" db:"end_time"` + IsOverdue int `gorm:"column:is_overdue;type:tinyint(1);default:0;comment:是否逾期 0否 1是" json:"is_overdue" db:"is_overdue"` + AutoPostpone int `gorm:"column:auto_postpone;type:tinyint(1);default:0;comment:自动顺延开关 0关 1开" json:"auto_postpone" db:"auto_postpone"` + Subtasks string `gorm:"column:subtasks;type:text;comment:子任务 JSON" json:"subtasks" db:"subtasks"` + Sort int `gorm:"column:sort;type:int;default:0;comment:排序" json:"sort" db:"sort"` +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..546002e --- /dev/null +++ b/models/user.go @@ -0,0 +1,16 @@ +// 用户表:sm_user +package models + +import "gorm.io/gorm" + +type User struct { + gorm.Model + Openid string `gorm:"column:openid;type:varchar(100);default:'';index;comment:微信openid" json:"openid" db:"openid"` + Unionid string `gorm:"column:unionid;type:varchar(100);default:'';index;comment:微信unionid(跨平台统一标识)" json:"unionid" db:"unionid"` + Nickname string `gorm:"column:nickname;type:varchar(30);default:'';comment:用户昵称" json:"nickname" db:"nickname"` + Avatar string `gorm:"column:avatar;type:varchar(255);default:'';comment:用户头像" json:"avatar" db:"avatar"` + Signature string `gorm:"column:signature;type:varchar(255);default:'';comment:个性签名" json:"signature" db:"signature"` + Email string `gorm:"column:email;type:varchar(100);default:'';index;comment:用户邮箱" json:"email" db:"email"` + Password string `gorm:"column:password;type:varchar(255);default:'';comment:邮箱登录密码" json:"-" db:"password"` + UserCode string `gorm:"column:user_code;type:varchar(20);default:'';index;comment:用户码(展示用)" json:"user_code" db:"user_code"` +} diff --git a/models/userConfig.go b/models/userConfig.go new file mode 100644 index 0000000..f3b6293 --- /dev/null +++ b/models/userConfig.go @@ -0,0 +1,10 @@ +// 用户配置表:sm_user_config +package models + +import "gorm.io/gorm" + +type UserConfig struct { + gorm.Model + UserID uint `gorm:"column:user_id;type:bigint unsigned;default:0;index;comment:用户ID" json:"user_id" db:"user_id"` + Theme string `gorm:"column:theme;type:varchar(20);default:'';comment:主题" json:"theme" db:"theme"` +} diff --git a/models/user_achievement.go b/models/user_achievement.go new file mode 100644 index 0000000..d719b75 --- /dev/null +++ b/models/user_achievement.go @@ -0,0 +1,12 @@ +// 用户成就表:sm_user_achievement +package models + +import "gorm.io/gorm" + +type UserAchievement struct { + gorm.Model + UserID uint `gorm:"column:user_id;type:bigint unsigned;not null;index;comment:用户ID" json:"user_id" db:"user_id"` + AchievementID string `gorm:"column:achievement_id;type:varchar(50);not null;comment:成就ID" json:"achievement_id" db:"achievement_id"` + UnlockDate string `gorm:"column:unlock_date;type:varchar(10);default:'';comment:解锁日期" json:"unlock_date" db:"unlock_date"` + ExpRewarded int `gorm:"column:exp_rewarded;type:int;default:0;comment:已发放的经验奖励" json:"exp_rewarded" db:"exp_rewarded"` +} diff --git a/models/user_growth.go b/models/user_growth.go new file mode 100644 index 0000000..dd66ac8 --- /dev/null +++ b/models/user_growth.go @@ -0,0 +1,21 @@ +// 用户成长表:sm_user_growth +package models + +import "gorm.io/gorm" + +type UserGrowth struct { + gorm.Model + UserID uint `gorm:"column:user_id;type:bigint unsigned;not null;uniqueIndex;comment:用户ID" json:"user_id" db:"user_id"` + Level int `gorm:"column:level;type:int;default:1;comment:等级" json:"level" db:"level"` + Experience int `gorm:"column:experience;type:int;default:0;comment:当前经验值" json:"experience" db:"experience"` + TotalDays int `gorm:"column:total_days;type:int;default:0;comment:累计记录天数" json:"total_days" db:"total_days"` + StreakDays int `gorm:"column:streak_days;type:int;default:0;comment:连续记录天数" json:"streak_days" db:"streak_days"` + MaxStreakDays int `gorm:"column:max_streak_days;type:int;default:0;comment:最高连续天数" json:"max_streak_days" db:"max_streak_days"` + HappyStreakDays int `gorm:"column:happy_streak_days;type:int;default:0;comment:连续开心天数" json:"happy_streak_days" db:"happy_streak_days"` + LastRecordDate string `gorm:"column:last_record_date;type:varchar(10);default:'';comment:最后记录日期" json:"last_record_date" db:"last_record_date"` + ProtectedDays int `gorm:"column:protected_days;type:int;default:7;comment:新用户保护期剩余天数" json:"protected_days" db:"protected_days"` + TotalTasks int `gorm:"column:total_tasks;type:int;default:0;comment:累计完成任务数" json:"total_tasks" db:"total_tasks"` + TotalBills int `gorm:"column:total_bills;type:int;default:0;comment:累计记账笔数" json:"total_bills" db:"total_bills"` + TotalMoods int `gorm:"column:total_moods;type:int;default:0;comment:累计心情记录数" json:"total_moods" db:"total_moods"` + TotalReview int `gorm:"column:total_review;type:int;default:0;comment:累计复盘数" json:"total_review" db:"total_review"` +} diff --git a/routes/api_app.go b/routes/api_app.go new file mode 100644 index 0000000..de1d407 --- /dev/null +++ b/routes/api_app.go @@ -0,0 +1,107 @@ +// routes/api_app.go +// 小程序端API路由 +package routes + +import ( + "simple-memo/api/app" + "simple-memo/middleware" + + "github.com/gin-gonic/gin" +) + +// registerAPIAppRoutes 注册小程序端API路由 +func registerAPIAppRoutes(r *gin.Engine) { + api := r.Group("/api") + + // 小程序接口(需要加密) + appGroup := api.Group("/app") + appGroup.Use(middleware.Limiter()) // 限流 + appGroup.Use(middleware.Encryption()) // 加解密 + { + // 登录 + appGroup.POST("/user/login", app.NewUserHandler().Login) + + // 邮箱登录相关(无需登录) + appGroup.POST("/user/email/login", app.NewUserHandler().EmailLogin) // 邮箱密码登录 + appGroup.POST("/user/register", app.NewUserHandler().EmailRegister) // 注册账号 + appGroup.POST("/user/email/code", app.NewUserHandler().SendEmailCode) // 发送验证码(type=register/login/reset_password) + appGroup.POST("/user/email/exists", app.NewUserHandler().CheckEmailExists) // 检查邮箱是否已注册 + appGroup.POST("/user/reset-password", app.NewUserHandler().ResetPassword) // 重置密码 + + appGroup.POST("/captcha/digit", app.NewCaptchaHandler().GenerateDigitCaptcha) // 生成数字验证码 + appGroup.POST("/captcha/digit/verify", app.NewCaptchaHandler().VerifyDigitCaptcha) // 验证数字验证码 + + // 需要登录鉴权 + auth := appGroup.Group("/") + auth.Use(middleware.JWTAuth()) + { + // ===================== 用户 ===================== + auth.POST("/user/logout", app.NewUserHandler().Logout) // 退出登录 + auth.POST("/user/info", app.NewUserHandler().UserInfo) // 获取用户信息(预留) + auth.POST("/user/edit", app.NewUserHandler().EditUser) // 修改用户信息 + auth.POST("/user/email/set", app.NewUserHandler().SetEmail) // 设置邮箱(绑定邮箱) + auth.POST("/user/change-password", app.NewUserHandler().ChangePassword) // 修改密码(已登录) + auth.POST("/user/bind-wechat", app.NewUserHandler().BindWechatMini) // 绑定微信(小程序端,wx.login code) + + auth.POST("/user/config/info", app.NewUserConfigHandler().UserConfigInfo) // 获取用户配置 + auth.POST("/user/config/edit", app.NewUserConfigHandler().EditUserConfig) // 修改用户配置 + + // ===================== 成长等级 ===================== + auth.POST("/growth/info", app.NewGrowthHandler().GetGrowthInfo) + auth.POST("/growth/rules", app.NewGrowthHandler().GetGrowthRules) + auth.POST("/growth/achievements", app.NewGrowthHandler().GetAchievements) + auth.POST("/growth/add_exp", app.NewGrowthHandler().AddExp) + + // ===================== 待办 ===================== + auth.POST("/todo/add", app.NewTodoHandler().AddTodo) + auth.POST("/todo/list", app.NewTodoHandler().TodoList) + auth.POST("/todo/delete", app.NewTodoHandler().DeleteTodo) + auth.POST("/todo/done", app.NewTodoHandler().DoneTodo) + auth.POST("/todo/edit", app.NewTodoHandler().EditTodo) + auth.POST("/todo/stat", app.NewTodoHandler().TodoStat) // 待办统计(新增) + + // ===================== 账单 ===================== + auth.POST("/bill/add", app.NewBillHandler().AddBill) + auth.POST("/bill/edit", app.NewBillHandler().EditBill) + auth.POST("/bill/list", app.NewBillHandler().BillList) + auth.POST("/bill/delete", app.NewBillHandler().DeleteBill) + auth.POST("/bill/stat/month", app.NewBillHandler().MonthStat) // 月度统计(mine.vue使用) + auth.POST("/bill/stat/category", app.NewBillHandler().CateStat) // 分类统计 + auth.POST("/bill/stat/aggregate", app.NewBillHandler().AggregateStat) // 聚合统计(新增) + auth.POST("/bill/export", app.NewBillHandler().ExportBill) + + // ===================== 预算 ===================== + auth.POST("/budget/get", app.NewBudgetHandler().GetBudget) // 获取预算(mine.vue使用) + auth.POST("/budget/set", app.NewBudgetHandler().SetBudget) // 设置预算(mine.vue使用) + auth.POST("/budget/delete", app.NewBudgetHandler().DeleteBudget) + + // ===================== 分类 ===================== + auth.POST("/category/list", app.NewCategoryHandler().CategoryList) // 自定义分类(mine.vue使用) + auth.POST("/category/add", app.NewCategoryHandler().AddCategory) + auth.POST("/category/delete", app.NewCategoryHandler().DeleteCategory) + + // ===================== 心情 ===================== + auth.POST("/mood/get", app.NewMoodHandler().GetMoodByDate) + auth.POST("/mood/save", app.NewMoodHandler().SaveMood) + auth.POST("/mood/list", app.NewMoodHandler().GetMoodList) + auth.POST("/mood/delete", app.NewMoodHandler().DeleteMood) + auth.POST("/mood/stat", app.NewMoodHandler().MoodStat) // 心情统计(新增) + + // ===================== 复盘 ===================== + auth.POST("/review/save", app.NewReviewHandler().SaveReview) // 保存复盘(新增) + auth.POST("/review/get", app.NewReviewHandler().GetReview) // 获取单日复盘(新增) + auth.POST("/review/list", app.NewReviewHandler().ReviewList) // 获取复盘列表(新增) + auth.POST("/review/delete", app.NewReviewHandler().DeleteReview) // 删除复盘(新增) + + // ===================== 首页聚合 ===================== + auth.POST("/dashboard/daily", app.NewDashboardHandler().DailyDashboard) // 首页聚合数据(新增) + + // ===================== AI 助手 ===================== + auth.POST("/ai/mood/analysis", app.NewAIHandler().MoodAnalysis) // 心情分析 + auth.POST("/ai/mood/weekly", app.NewAIHandler().MoodWeeklySummary) // 心情周报 + auth.POST("/ai/mood/result", app.NewAIHandler().GetMoodAIResult) // 获取心情AI生成结果 + auth.POST("/ai/mood/regenerate", app.NewAIHandler().RegenerateMoodAI) // 重新生成心情AI内容 + auth.POST("/ai/classify", app.NewAIHandler().Classify) // AI 智能分类 + } + } +} diff --git a/routes/api_web.go b/routes/api_web.go new file mode 100644 index 0000000..bbe9da0 --- /dev/null +++ b/routes/api_web.go @@ -0,0 +1,84 @@ +// routes/api_web.go +// Web端API路由 +package routes + +import ( + "simple-memo/api/app" + "simple-memo/middleware" + + "github.com/gin-gonic/gin" +) + +// registerAPIWebRoutes 注册Web端API路由 +func registerAPIWebRoutes(r *gin.Engine) { + api := r.Group("/api") + + // 网页端接口(不需要加密,直接接收JSON) + web := api.Group("/web") + web.Use(middleware.Limiter()) // 限流(不加加密中间件) + { + // 网页端登录相关(无需登录) + web.POST("/login", app.NewUserHandler().EmailLogin) // 邮箱登录 + web.POST("/register", app.NewUserHandler().EmailRegister) // 注册 + web.POST("/send-code", app.NewUserHandler().SendEmailCode) // 发送邮箱验证码 + web.POST("/reset-password", app.NewUserHandler().ResetPassword) // 重置密码 + + // 验证码接口(无需登录) + web.POST("/captcha/digit", app.NewCaptchaHandler().GenerateDigitCaptcha) // 生成数字验证码 + web.POST("/captcha/digit/verify", app.NewCaptchaHandler().VerifyDigitCaptcha) // 验证数字验证码 + + // 成长规则(无需登录,公开页面使用) + web.GET("/growth/rules", app.NewGrowthHandler().GetGrowthRules) + + // 常量接口 + web.POST("/constants/all", app.NewConstantsHandler().GetAllConstants) + web.POST("/constants/bill-categories", app.NewConstantsHandler().GetBillCategories) + web.POST("/constants/todo-categories", app.NewConstantsHandler().GetTodoCategories) + web.POST("/constants/mood-list", app.NewConstantsHandler().GetMoodList) + web.POST("/constants/pay-channels", app.NewConstantsHandler().GetPayChannels) + + // 需要登录鉴权的网页端接口 + webAuth := web.Group("/") + webAuth.Use(middleware.JWTAuth()) + { + // 用户信息(GET获取,POST修改) + webAuth.GET("/user/info", app.NewUserHandler().UserInfo) + webAuth.POST("/user/edit", app.NewUserHandler().EditUser) + webAuth.POST("/user/logout", app.NewUserHandler().Logout) + webAuth.POST("/user/change-password", app.NewUserHandler().ChangePassword) // 修改密码(已登录) + webAuth.POST("/user/bind-wechat", app.NewUserHandler().BindWechat) // 绑定微信(已登录) + + // 日历聚合数据 + webAuth.POST("/calendar/data", app.NewCalendarHandler().GetCalendarData) + + // 待办 + webAuth.POST("/todo/add", app.NewTodoHandler().AddTodo) + webAuth.POST("/todo/list", app.NewTodoHandler().TodoList) + webAuth.POST("/todo/delete", app.NewTodoHandler().DeleteTodo) + webAuth.POST("/todo/done", app.NewTodoHandler().DoneTodo) + webAuth.POST("/todo/edit", app.NewTodoHandler().EditTodo) + + // 账单 + webAuth.POST("/bill/add", app.NewBillHandler().AddBill) + webAuth.POST("/bill/list", app.NewBillHandler().BillList) + webAuth.POST("/bill/edit", app.NewBillHandler().EditBill) + webAuth.POST("/bill/delete", app.NewBillHandler().DeleteBill) + + // 心情 + webAuth.POST("/mood/get", app.NewMoodHandler().GetMoodByDate) + webAuth.POST("/mood/save", app.NewMoodHandler().SaveMood) + webAuth.POST("/mood/list", app.NewMoodHandler().GetMoodList) + webAuth.POST("/mood/delete", app.NewMoodHandler().DeleteMood) + + // 复盘 + webAuth.POST("/review/save", app.NewReviewHandler().SaveReview) + webAuth.POST("/review/get", app.NewReviewHandler().GetReview) + webAuth.POST("/review/list", app.NewReviewHandler().ReviewList) + webAuth.POST("/review/delete", app.NewReviewHandler().DeleteReview) + + // 成长(GET获取) + webAuth.GET("/growth/info", app.NewGrowthHandler().GetGrowthInfo) + webAuth.GET("/growth/achievements", app.NewGrowthHandler().GetAchievements) + } + } +} diff --git a/routes/pages.go b/routes/pages.go new file mode 100644 index 0000000..d6acc5b --- /dev/null +++ b/routes/pages.go @@ -0,0 +1,28 @@ +// routes/pages.go +// HTML页面路由 +package routes + +import ( + "path/filepath" + + "github.com/gin-gonic/gin" +) + +// registerPageRoutes 注册HTML页面路由 +func registerPageRoutes(r *gin.Engine) { + staticPath := getStaticPath() + htmlPath := filepath.Join(staticPath, "html") + + // 公开页面(无需登录)- 普通头部 + r.StaticFile("/", filepath.Join(htmlPath, "index.html")) + r.StaticFile("/login", filepath.Join(htmlPath, "login.html")) + r.StaticFile("/about", filepath.Join(htmlPath, "about.html")) + r.StaticFile("/agreement", filepath.Join(htmlPath, "agreement.html")) + r.StaticFile("/privacy", filepath.Join(htmlPath, "privacy.html")) + r.StaticFile("/growth-rules", filepath.Join(htmlPath, "growth-rules.html")) + + // Web端页面路由(需登录)- 工作台头部 + r.StaticFile("/web/calendar", filepath.Join(htmlPath, "calendar.html")) + r.StaticFile("/web/profile", filepath.Join(htmlPath, "profile.html")) + r.StaticFile("/web/growth", filepath.Join(htmlPath, "growth.html")) +} diff --git a/routes/routes.go b/routes/routes.go new file mode 100644 index 0000000..9a6f53f --- /dev/null +++ b/routes/routes.go @@ -0,0 +1,28 @@ +// routes/routes.go +// 路由注册器 - 统一管理所有路由 +package routes + +import ( + "path/filepath" + + "github.com/gin-gonic/gin" +) + +// RegisterRoutes 注册所有路由 +func RegisterRoutes(r *gin.Engine) { + // 注册静态资源路由 + registerStaticRoutes(r) + + // 注册页面路由 + registerPageRoutes(r) + + // 注册API路由 + registerAPIAppRoutes(r) + registerAPIWebRoutes(r) + + // 404页面处理 - 所有未匹配的路由都返回404页面 + r.NoRoute(func(c *gin.Context) { + staticPath := getStaticPath() + c.File(filepath.Join(staticPath, "html", "404.html")) + }) +} diff --git a/routes/static.go b/routes/static.go new file mode 100644 index 0000000..3ea450c --- /dev/null +++ b/routes/static.go @@ -0,0 +1,24 @@ +// routes/static.go +// 静态资源路由 +package routes + +import ( + "os" + "path/filepath" + + "github.com/gin-gonic/gin" +) + +// getStaticPath 获取静态资源目录路径 +func getStaticPath() string { + exeDir, _ := os.Getwd() + return filepath.Join(exeDir, "..", "static") +} + +// registerStaticRoutes 注册静态资源路由 +func registerStaticRoutes(r *gin.Engine) { + staticPath := getStaticPath() + + // 静态资源目录 + r.Static("/static", staticPath) +} diff --git a/static/avatar/corgi.jpg b/static/avatar/corgi.jpg new file mode 100644 index 0000000..01f3dd9 Binary files /dev/null and b/static/avatar/corgi.jpg differ diff --git a/static/avatar/default.png b/static/avatar/default.png new file mode 100644 index 0000000..0ebe859 Binary files /dev/null and b/static/avatar/default.png differ diff --git a/static/avatar/dragon.jpg b/static/avatar/dragon.jpg new file mode 100644 index 0000000..b223365 Binary files /dev/null and b/static/avatar/dragon.jpg differ diff --git a/static/avatar/ginger_cat.jpg b/static/avatar/ginger_cat.jpg new file mode 100644 index 0000000..d9e3e6e Binary files /dev/null and b/static/avatar/ginger_cat.jpg differ diff --git a/static/avatar/hamster.jpg b/static/avatar/hamster.jpg new file mode 100644 index 0000000..88d2536 Binary files /dev/null and b/static/avatar/hamster.jpg differ diff --git a/static/avatar/lamb.jpg b/static/avatar/lamb.jpg new file mode 100644 index 0000000..b522acf Binary files /dev/null and b/static/avatar/lamb.jpg differ diff --git a/static/avatar/piglet.jpg b/static/avatar/piglet.jpg new file mode 100644 index 0000000..d1b2eef Binary files /dev/null and b/static/avatar/piglet.jpg differ diff --git a/static/avatar/pony.jpg b/static/avatar/pony.jpg new file mode 100644 index 0000000..de639c6 Binary files /dev/null and b/static/avatar/pony.jpg differ diff --git a/static/avatar/ragdoll.jpg b/static/avatar/ragdoll.jpg new file mode 100644 index 0000000..bafa8ce Binary files /dev/null and b/static/avatar/ragdoll.jpg differ diff --git a/static/css/common.css b/static/css/common.css new file mode 100644 index 0000000..f44e80d --- /dev/null +++ b/static/css/common.css @@ -0,0 +1,1241 @@ +/* =================================== + 简记 · 事与钱 - 公共样式 + 清新美观 · 响应式设计 + =================================== */ + +/* CSS Variables */ +:root { + /* 主色调 - 清新蓝色渐变 */ + --primary-color: #4F9EDE; + --primary-light: #67B8E3; + --primary-dark: #3A8BC9; + + /* 渐变色 */ + --gradient-primary: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); + --gradient-success: linear-gradient(135deg, #10b981 0%, #059669 100%); + --gradient-warning: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + --gradient-danger: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + --gradient-info: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%); + + /* 中性色 */ + --white: #ffffff; + --gray-50: #f7fafc; + --gray-100: #edf2f7; + --gray-200: #e2e8f0; + --gray-300: #cbd5e0; + --gray-400: #a0aec0; + --gray-500: #718096; + --gray-600: #4a5568; + --gray-700: #2d3748; + --gray-800: #1a202c; + --gray-900: #171923; + + /* 文字颜色 */ + --text-primary: #2d3748; + --text-secondary: #718096; + --text-muted: #a0aec0; + + /* 阴影 */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-md: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + --shadow-xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + + /* 圆角 */ + --radius-sm: 0.375rem; + --radius: 0.5rem; + --radius-md: 0.75rem; + --radius-lg: 1rem; + --radius-xl: 1.5rem; + --radius-full: 9999px; + + /* 间距 */ + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing: 1rem; + --spacing-md: 1.5rem; + --spacing-lg: 2rem; + --spacing-xl: 3rem; + --spacing-2xl: 4rem; + + /* 过渡 */ + --transition-fast: all 0.15s ease-in-out; + --transition: all 0.3s ease-in-out; + --transition-slow: all 0.5s ease-in-out; +} + +/* Reset & Base */ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + line-height: 1.6; + color: var(--text-primary); + background: var(--gray-50); + min-height: 100vh; +} + +a { + text-decoration: none; + color: inherit; + transition: var(--transition-fast); +} + +ul, ol { + list-style: none; +} + +img { + max-width: 100%; + height: auto; + display: block; +} + +/* Container */ +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 var(--spacing-lg); +} + +.container-sm { + max-width: 800px; +} + +.container-lg { + max-width: 1400px; +} + +/* =================================== + Header & Navigation + =================================== */ + +.header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: var(--shadow-sm); + transition: var(--transition); +} + +.header-inner { + display: flex; + align-items: center; + justify-content: space-between; + height: 72px; +} + +.header-actions { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.login-btn, +.profile-btn { + display: flex; + align-items: center; + gap: 6px; + padding: var(--spacing-sm) var(--spacing-md); + background: var(--gradient-primary); + color: var(--white); + border-radius: var(--radius-full); + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; + transition: var(--transition); +} + +.login-btn:hover, +.profile-btn:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.login-icon, +.profile-icon { + font-size: 1rem; +} + +.logo { + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-size: 1.25rem; + font-weight: 700; + color: var(--primary-color); +} + +.logo-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + background: var(--gradient-primary); + display: flex; + align-items: center; + justify-content: center; + color: var(--white); + font-size: 1.25rem; + overflow: hidden; +} + +.logo-icon img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.nav { + display: flex; + align-items: center; + gap: var(--spacing-xl); +} + +.nav-link { + position: relative; + font-weight: 500; + color: var(--text-secondary); + padding: var(--spacing-sm) 0; +} + +.nav-link:hover, +.nav-link.active { + color: var(--primary-color); +} + +.nav-link::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 0; + height: 2px; + background: var(--gradient-primary); + transition: var(--transition); +} + +.nav-link:hover::after, +.nav-link.active::after { + width: 100%; +} + +.mobile-menu-btn { + display: none; + padding: var(--spacing-sm); + background: none; + border: none; + cursor: pointer; +} + +.mobile-menu-btn span { + display: block; + width: 24px; + height: 2px; + background: var(--text-primary); + margin: 6px 0; + transition: var(--transition); +} + +/* =================================== + Hero Section + =================================== */ + +.hero { + padding: 140px 0 80px; + background: var(--gradient-primary); + position: relative; + overflow: hidden; +} + +.hero::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); +} + +.hero-content { + position: relative; + z-index: 1; + text-align: center; + color: var(--white); + max-width: 800px; + margin: 0 auto; +} + +.hero-title { + font-size: 3.5rem; + font-weight: 800; + line-height: 1.2; + margin-bottom: var(--spacing-lg); + text-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.hero-subtitle { + font-size: 1.25rem; + line-height: 1.8; + opacity: 0.95; + margin-bottom: var(--spacing-xl); +} + +.hero-buttons { + display: flex; + gap: var(--spacing-md); + justify-content: center; + flex-wrap: wrap; +} + +/* =================================== + Buttons + =================================== */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: 14px 32px; + font-size: 1rem; + font-weight: 600; + border-radius: var(--radius-full); + border: none; + cursor: pointer; + transition: var(--transition); +} + +.btn:hover { + transform: translateY(-2px); +} + +.btn-primary { + background: var(--white); + color: var(--primary-color); + box-shadow: var(--shadow-md); +} + +.btn-primary:hover { + box-shadow: var(--shadow-lg); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.2); + color: var(--white); + border: 2px solid var(--white); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.3); +} + +.btn-gradient { + background: var(--gradient-primary); + color: var(--white); + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); +} + +.btn-gradient:hover { + box-shadow: 0 8px 25px rgba(102, 126, 234, 0.5); + transform: translateY(-2px); +} + +.btn-sm { + padding: 10px 20px; + font-size: 0.875rem; +} + +.btn-lg { + padding: 18px 40px; + font-size: 1.125rem; +} + +/* =================================== + Cards + =================================== */ + +.card { + background: var(--white); + border-radius: var(--radius-xl); + padding: var(--spacing-xl); + box-shadow: var(--shadow); + transition: var(--transition); +} + +.card:hover { + transform: translateY(-5px); + box-shadow: var(--shadow-lg); +} + +.card-header { + margin-bottom: var(--spacing-lg); +} + +.card-title { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: var(--spacing-sm); +} + +.card-subtitle { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.card-body { + color: var(--text-secondary); + line-height: 1.7; +} + +/* Feature Card */ +.feature-card { + text-align: center; + padding: var(--spacing-xl); +} + +.feature-icon { + width: 80px; + height: 80px; + margin: 0 auto var(--spacing-lg); + border-radius: var(--radius-xl); + background: var(--gradient-primary); + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5rem; + box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3); +} + +.feature-card h3 { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: var(--spacing-sm); +} + +.feature-card p { + color: var(--text-secondary); + line-height: 1.6; +} + +/* Level Card */ +.level-card { + text-align: center; + padding: var(--spacing-xl); + position: relative; + overflow: hidden; +} + +.level-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--gradient-primary); +} + +.level-icon { + width: 100px; + height: 100px; + margin: 0 auto var(--spacing-lg); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 3rem; + box-shadow: var(--shadow); +} + +.level-card h3 { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: var(--spacing-sm); +} + +.level-exp { + font-size: 0.875rem; + color: var(--text-secondary); + margin-bottom: var(--spacing-md); +} + +.level-badge { + display: inline-block; + padding: 6px 16px; + background: var(--gray-100); + color: var(--primary-color); + border-radius: var(--radius-full); + font-size: 0.875rem; + font-weight: 600; +} + +/* Achievement Card */ +.achievement-card { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-lg); + background: var(--gray-50); + border-radius: var(--radius-lg); + transition: var(--transition); +} + +.achievement-card:hover { + background: var(--gray-100); + transform: scale(1.02); +} + +.achievement-icon { + width: 60px; + height: 60px; + border-radius: var(--radius-lg); + background: var(--gradient-primary); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.75rem; + flex-shrink: 0; +} + +.achievement-info { + flex: 1; +} + +.achievement-info h4 { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.achievement-info p { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.achievement-reward { + font-size: 0.75rem; + color: var(--primary-color); + font-weight: 600; + background: rgba(102, 126, 234, 0.1); + padding: 4px 10px; + border-radius: var(--radius-full); +} + +/* =================================== + Sections + =================================== */ + +.section { + padding: var(--spacing-2xl) 0; +} + +.section-header { + text-align: center; + margin-bottom: var(--spacing-2xl); +} + +.section-title { + font-size: 2.5rem; + font-weight: 800; + color: var(--text-primary); + margin-bottom: var(--spacing-md); +} + +.section-subtitle { + font-size: 1.125rem; + color: var(--text-secondary); + max-width: 600px; + margin: 0 auto; +} + +.section-light { + background: var(--white); +} + +.section-gray { + background: var(--gray-50); +} + +.section-gradient { + background: var(--gradient-primary); + color: var(--white); +} + +.section-gradient .section-title { + color: var(--white); +} + +.section-gradient .section-subtitle { + color: rgba(255, 255, 255, 0.9); +} + +/* =================================== + Grid Layouts + =================================== */ + +.grid { + display: grid; + gap: var(--spacing-lg); +} + +.grid-2 { + grid-template-columns: repeat(2, 1fr); +} + +.grid-3 { + grid-template-columns: repeat(3, 1fr); +} + +.grid-4 { + grid-template-columns: repeat(4, 1fr); +} + +.grid-auto { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +/* =================================== + Stats + =================================== */ + +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--spacing-lg); +} + +.stat-item { + text-align: center; + padding: var(--spacing-lg); +} + +.stat-value { + font-size: 3rem; + font-weight: 800; + color: var(--primary-color); + line-height: 1; + margin-bottom: var(--spacing-sm); +} + +.stat-label { + font-size: 1rem; + color: var(--text-secondary); +} + +/* =================================== + Progress Bar + =================================== */ + +.progress { + width: 100%; + height: 12px; + background: var(--gray-200); + border-radius: var(--radius-full); + overflow: hidden; +} + +.progress-bar { + height: 100%; + background: var(--gradient-primary); + border-radius: var(--radius-full); + transition: width 0.5s ease-out; +} + +.progress-label { + display: flex; + justify-content: space-between; + margin-bottom: var(--spacing-sm); + font-size: 0.875rem; + color: var(--text-secondary); +} + +/* =================================== + Footer + =================================== */ + +.footer { + background: var(--gray-800); + color: var(--white); + padding: var(--spacing-2xl) 0 var(--spacing-lg); +} + +.footer-grid { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: var(--spacing-2xl); + margin-bottom: var(--spacing-2xl); +} + +.footer-brand h3 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: var(--spacing-md); +} + +.footer-brand p { + color: var(--gray-400); + line-height: 1.7; + margin-bottom: var(--spacing-lg); +} + +.footer-title { + font-size: 1rem; + font-weight: 700; + margin-bottom: var(--spacing-lg); + color: var(--white); +} + +.footer-links li { + margin-bottom: var(--spacing-sm); +} + +.footer-links a { + color: var(--gray-400); + transition: var(--transition-fast); +} + +.footer-links a:hover { + color: var(--white); +} + +.footer-bottom { + padding-top: var(--spacing-lg); + border-top: 1px solid var(--gray-700); + text-align: center; + color: var(--gray-500); + font-size: 0.875rem; +} + +/* =================================== + Page Footer (Simple) + =================================== */ + +.page-footer { + position: fixed; + bottom: 20px; + left: 0; + right: 0; + text-align: center; + font-size: 0.75rem; + color: var(--text-muted); + z-index: 100; + padding: 0 20px; + pointer-events: none; +} + +.page-footer a { + color: var(--primary-color); + text-decoration: none; + pointer-events: auto; +} + +.page-footer a:hover { + text-decoration: underline; +} + +.footer-divider { + margin: 0 8px; + color: var(--gray-400); +} + +/* =================================== + Tabs + =================================== */ + +.tabs { + display: flex; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-xl); + border-bottom: 2px solid var(--gray-200); +} + +.tab { + padding: var(--spacing-md) var(--spacing-lg); + font-weight: 600; + color: var(--text-secondary); + background: none; + border: none; + cursor: pointer; + position: relative; + transition: var(--transition); +} + +.tab:hover { + color: var(--primary-color); +} + +.tab.active { + color: var(--primary-color); +} + +.tab.active::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + right: 0; + height: 2px; + background: var(--gradient-primary); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* =================================== + Badges + =================================== */ + +.badge { + display: inline-flex; + align-items: center; + padding: 4px 12px; + font-size: 0.75rem; + font-weight: 600; + border-radius: var(--radius-full); +} + +.badge-primary { + background: rgba(102, 126, 234, 0.1); + color: var(--primary-color); +} + +.badge-success { + background: rgba(72, 187, 120, 0.1); + color: #48bb78; +} + +.badge-warning { + background: rgba(237, 137, 54, 0.1); + color: #ed8936; +} + +.badge-danger { + background: rgba(252, 129, 129, 0.1); + color: #fc8181; +} + +/* =================================== + Empty State + =================================== */ + +.empty-state { + text-align: center; + padding: var(--spacing-2xl); +} + +.empty-state-icon { + font-size: 4rem; + margin-bottom: var(--spacing-lg); + opacity: 0.5; +} + +.empty-state h3 { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: var(--spacing-sm); +} + +.empty-state p { + color: var(--text-secondary); +} + +/* =================================== + Utilities + =================================== */ + +.text-center { text-align: center; } +.text-left { text-align: left; } +.text-right { text-align: right; } + +.text-primary { color: var(--primary-color); } +.text-secondary { color: var(--text-secondary); } +.text-muted { color: var(--text-muted); } +.text-white { color: var(--white); } + +.font-bold { font-weight: 700; } +.font-semibold { font-weight: 600; } +.font-medium { font-weight: 500; } + +.mt-sm { margin-top: var(--spacing-sm); } +.mt { margin-top: var(--spacing); } +.mt-lg { margin-top: var(--spacing-lg); } +.mt-xl { margin-top: var(--spacing-xl); } + +.mb-sm { margin-bottom: var(--spacing-sm); } +.mb { margin-bottom: var(--spacing); } +.mb-lg { margin-bottom: var(--spacing-lg); } +.mb-xl { margin-bottom: var(--spacing-xl); } + +.flex { display: flex; } +.flex-center { display: flex; align-items: center; justify-content: center; } +.flex-between { display: flex; align-items: center; justify-content: space-between; } +.flex-wrap { flex-wrap: wrap; } +.gap-sm { gap: var(--spacing-sm); } +.gap { gap: var(--spacing); } +.gap-lg { gap: var(--spacing-lg); } + +.hidden { display: none; } +.visible { visibility: visible; } +.invisible { visibility: hidden; } + +.opacity-50 { opacity: 0.5; } +.opacity-75 { opacity: 0.75; } + +/* =================================== + Responsive Design + =================================== */ + +@media (max-width: 1024px) { + .grid-4 { + grid-template-columns: repeat(2, 1fr); + } + + .footer-grid { + grid-template-columns: repeat(2, 1fr); + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + html { + font-size: 14px; + } + + .container { + padding: 0 var(--spacing); + } + + .header-inner { + height: 64px; + } + + .nav { + display: none; + position: absolute; + top: 64px; + left: 0; + right: 0; + background: var(--white); + flex-direction: column; + padding: var(--spacing-lg); + box-shadow: var(--shadow-md); + gap: var(--spacing-md); + } + + .nav.active { + display: flex; + } + + .mobile-menu-btn { + display: block; + } + + .mobile-menu-btn.active span:nth-child(1) { + transform: rotate(45deg) translate(6px, 6px); + } + + .mobile-menu-btn.active span:nth-child(2) { + opacity: 0; + } + + .mobile-menu-btn.active span:nth-child(3) { + transform: rotate(-45deg) translate(6px, -6px); + } + + .hero { + padding: 120px 0 60px; + } + + .hero-title { + font-size: 2.5rem; + } + + .hero-subtitle { + font-size: 1rem; + } + + .section { + padding: var(--spacing-xl) 0; + } + + .section-title { + font-size: 2rem; + } + + .grid-2, + .grid-3, + .grid-4 { + grid-template-columns: 1fr; + } + + .footer-grid { + grid-template-columns: 1fr; + text-align: center; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .stat-value { + font-size: 2rem; + } + + .tabs { + flex-wrap: wrap; + } + + .tab { + flex: 1; + text-align: center; + padding: var(--spacing-sm) var(--spacing); + } +} + +@media (max-width: 480px) { + .hero-buttons { + flex-direction: column; + align-items: center; + } + + .btn { + width: 100%; + max-width: 280px; + } + + .card { + padding: var(--spacing-lg); + } + + .stats-grid { + grid-template-columns: 1fr; + } +} + +/* =================================== + Animations + =================================== */ + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes float { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.animate-fadeIn { + animation: fadeIn 0.6s ease-out; +} + +.animate-float { + animation: float 3s ease-in-out infinite; +} + +.animate-pulse { + animation: pulse 2s ease-in-out infinite; +} + +/* Scroll Animation */ +.fade-in-up { + opacity: 0; + transform: translateY(30px); + transition: opacity 0.6s ease-out, transform 0.6s ease-out; +} + +.fade-in-up.visible { + opacity: 1; + transform: translateY(0); +} + +/* Safe Area */ +.safe-area-bottom { + height: env(safe-area-inset-bottom); +} +0 +/* Mini Program Link */ +.minipro-link { + position: relative; + cursor: pointer; +} + +.minipro-link:hover { + color: var(--primary-color); +} + +.minipro-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background: linear-gradient(135deg, #07C160 0%, #06AD56 100%); + color: white; + font-size: 0.7rem; + font-weight: 600; + border-radius: var(--radius-full); + margin-left: 4px; + vertical-align: middle; +} + +/* Mini Program Modal */ +.minipro-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: none; + align-items: center; + justify-content: center; + z-index: 9999; + padding: var(--spacing-lg); +} + +.minipro-modal-overlay.active { + display: flex; +} + +.minipro-modal { + background: var(--white); + border-radius: var(--radius-xl); + padding: var(--spacing-xl); + max-width: 400px; + width: 100%; + box-shadow: var(--shadow-xl); + animation: slideUp 0.3s ease-out; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.minipro-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacing-lg); +} + +.minipro-modal-title { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); +} + +.minipro-modal-close { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: var(--gray-100); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + color: var(--text-secondary); + transition: var(--transition); +} + +.minipro-modal-close:hover { + background: var(--gray-200); +} + +.minipro-modal-body { + text-align: center; + margin-bottom: var(--spacing-xl); +} + +.minipro-modal-icon { + width: 80px; + height: 80px; + margin: 0 auto var(--spacing-lg); + border-radius: var(--radius-xl); + background: var(--gradient-primary); + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5rem; +} + +.minipro-modal-body h3 { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: var(--spacing-sm); +} + +.minipro-modal-body p { + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: var(--spacing-lg); +} + +.minipro-modal-footer { + display: flex; + gap: var(--spacing-md); +} + +.minipro-modal-footer .btn { + flex: 1; +} diff --git a/static/css/form.css b/static/css/form.css new file mode 100644 index 0000000..4c10f37 --- /dev/null +++ b/static/css/form.css @@ -0,0 +1,301 @@ +/* =================================== + 简记memo - 表单公共样式 + =================================== */ + +/* 表单容器 */ +.form-container { + width: 100%; + max-width: 400px; + background: #fff; + border-radius: 24px; + padding: 40px; + box-shadow: 0 4px 24px rgba(99, 102, 241, 0.08); +} + +/* =================================== + 表单标签页 + =================================== */ +.form-tabs { + display: flex; + background: #f1f5f9; + border-radius: 14px; + padding: 5px; + margin-bottom: 32px; +} + +.form-tab { + flex: 1; + padding: 12px; + font-weight: 600; + color: #64748b; + background: none; + border: none; + border-radius: 10px; + cursor: pointer; + transition: all 0.2s; + font-size: 0.9rem; + text-align: center; +} + +.form-tab:hover { + color: #6366f1; +} + +.form-tab.active { + background: #fff; + color: #6366f1; + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.12); +} + +/* 表单组 */ +.form-group { + margin-bottom: 20px; +} + +.form-label { + display: block; + font-weight: 600; + color: #1e293b; + margin-bottom: 8px; + font-size: 0.875rem; + line-height: 1.4; +} + +/* 输入框 */ +.form-input { + width: 100%; + padding: 14px 16px; + border: 2px solid #e2e8f0; + border-radius: 12px; + font-size: 1rem; + transition: all 0.2s; + background: #fff; + box-sizing: border-box; + margin: 0; + -webkit-autofill: none; + autocomplete: off; +} + +.form-input:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.08); +} + +.form-input::placeholder { + color: #94a3b8; +} + +/* 文本域 */ +.form-textarea { + width: 100%; + padding: 14px 16px; + border: 2px solid #e2e8f0; + border-radius: 12px; + font-size: 1rem; + transition: all 0.2s; + background: #fff; + box-sizing: border-box; + margin: 0; + resize: vertical; + min-height: 80px; + font-family: inherit; + -webkit-autofill: none; + autocomplete: off; +} + +.form-textarea:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.08); +} + +.form-textarea::placeholder { + color: #94a3b8; +} + +/* 选择框 */ +.form-select { + width: 100%; + padding: 14px 16px; + border: 2px solid #e2e8f0; + border-radius: 12px; + font-size: 1rem; + transition: all 0.2s; + background: #fff; + box-sizing: border-box; + cursor: pointer; + margin: 0; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 16px center; +} + +.form-select:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.08); +} + +/* 表单行 */ +.form-row { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 12px; + align-items: flex-end; +} + +/* 主要按钮 */ +.btn-primary { + width: 100%; + padding: 15px; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + color: #fff; + border: none; + border-radius: 12px; + font-weight: 600; + font-size: 1rem; + cursor: pointer; + transition: all 0.2s; + text-align: center; + box-sizing: border-box; + line-height: 1.4; + letter-spacing: 0.5px; +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(99, 102, 241, 0.25); +} + +.btn-primary:active { + transform: translateY(0); +} + +/* 发送验证码按钮 */ +.btn-code { + padding: 14px 16px; + border: 2px solid #6366f1; + border-radius: 12px; + background: #fff; + color: #6366f1; + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s; + height: 50px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + margin: 0; + text-align: center; + white-space: nowrap; +} + +.btn-code:hover { + background: rgba(99, 102, 241, 0.04); +} + +.btn-code:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 次要链接 */ +.forgot-link { + display: block; + font-size: 0.875rem; + color: #64748b; + text-align: right; + cursor: pointer; + transition: color 0.2s; + margin-top: -4px; +} + +.forgot-link:hover { + color: #6366f1; +} + +/* 返回链接 */ +.back-link { + display: inline-flex; + align-items: center; + gap: 4px; + color: #64748b; + font-size: 0.875rem; + cursor: pointer; + transition: color 0.2s; + margin-bottom: 16px; +} + +.back-link:hover { + color: #6366f1; +} + +/* 错误提示 */ +.error-message { + background: rgba(239, 68, 68, 0.06); + color: #ef4444; + padding: 12px 16px; + border-radius: 10px; + font-size: 0.875rem; + margin-bottom: 16px; + display: none; + line-height: 1.4; +} + +.error-message.show { + display: block; +} + +/* 成功提示 */ +.success-message { + background: rgba(16, 185, 129, 0.06); + color: #10b981; + padding: 12px 16px; + border-radius: 10px; + font-size: 0.875rem; + margin-bottom: 16px; + display: none; + line-height: 1.4; +} + +.success-message.show { + display: block; +} + +/* 底部提示 */ +.footer-tip { + text-align: center; + font-size: 0.875rem; + color: #94a3b8; + margin-top: 24px; + line-height: 1.4; +} + +.footer-tip a { + color: #6366f1; + text-decoration: none; + font-weight: 500; +} + +.footer-tip a:hover { + text-decoration: underline; +} + +/* 响应式 */ +@media (max-width: 768px) { + .form-container { + padding: 24px; + } + + .form-row { + grid-template-columns: 1fr; + } + + .btn-code { + width: 100%; + } +} \ No newline at end of file diff --git a/static/css/fullcalendar.min.css b/static/css/fullcalendar.min.css new file mode 100644 index 0000000..06deedf --- /dev/null +++ b/static/css/fullcalendar.min.css @@ -0,0 +1,153 @@ +/* FullCalendar Base Styles */ +.fc { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +.fc-direction-ltr .fc-daygrid-event.fc-event-start { + margin-left: 2px; +} + +.fc-direction-ltr .fc-daygrid-event.fc-event-end { + margin-right: 2px; +} + +.fc-daygrid-day-top { + display: flex; + flex-direction: row-reverse; + justify-content: space-between; +} + +.fc-daygrid-day-number { + font-size: 14px; + font-weight: 500; + padding: 4px 8px; +} + +.fc-theme-standard td, +.fc-theme-standard th { + border: 1px solid #e5e7eb; +} + +.fc-theme-standard thead { + background: #f9fafb; +} + +.fc-col-header-cell-cushion { + padding: 8px 4px; + font-weight: 600; + color: #666; + text-transform: uppercase; + font-size: 12px; +} + +.fc-daygrid-day { + background: #fff; + transition: all 0.2s; +} + +.fc-daygrid-day:hover { + background: #f3f4f6; +} + +.fc-daygrid-day-frame { + min-height: 100px; +} + +.fc-toolbar-title { + font-size: 18px; + font-weight: 600; + color: #1a1a1a; +} + +.fc-button { + background: transparent !important; + border: 1px solid #e5e7eb !important; + color: #666 !important; + border-radius: 8px !important; + padding: 8px 16px !important; + margin: 0 4px !important; + font-weight: 500 !important; + transition: all 0.2s !important; + cursor: pointer; +} + +.fc-button:hover { + background: #667eea !important; + border-color: #667eea !important; + color: #fff !important; +} + +.fc-button-active { + background: #667eea !important; + border-color: #667eea !important; + color: #fff !important; +} + +.fc-button-primary:not(:disabled).fc-button-active, +.fc-button-primary:not(:disabled):active { + background: #667eea !important; + border-color: #667eea !important; +} + +.fc-today-button { + background: #667eea !important; + border-color: #667eea !important; + color: #fff !important; +} + +.fc-day-today { + background: rgba(102, 126, 234, 0.1) !important; +} + +.fc-day-today .fc-daygrid-day-frame { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; + border-radius: 8px; +} + +.fc-day-today .fc-daygrid-day-number { + color: #fff; + font-weight: 700; +} + +.fc-event { + border-radius: 4px; + padding: 2px 6px; + font-size: 12px; + margin: 2px; +} + +.fc-event-title { + font-weight: 500; +} + +.fc-more-link { + font-size: 12px; + color: #667eea; + cursor: pointer; +} + +.fc-more-link:hover { + text-decoration: underline; +} + +.fc-highlight { + background: rgba(102, 126, 234, 0.2); +} + +.fc-popover { + background: #fff; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: 1px solid #e5e7eb; +} + +.fc-popover-header { + padding: 8px 12px; + border-bottom: 1px solid #e5e7eb; + font-weight: 600; +} + +.fc-popover-body { + padding: 8px; +} diff --git a/static/css/global.css b/static/css/global.css new file mode 100644 index 0000000..9cd13ea --- /dev/null +++ b/static/css/global.css @@ -0,0 +1,344 @@ +/* =================================== + 简记memo - 全局样式变量 + =================================== */ + +:root { + --primary-color: #4F9EDE; + --primary-light: #67B8E3; + --primary-dark: #3A8BC9; + --secondary-color: #6366f1; + --accent-color: #34d399; + --warning-color: #fbbf24; + --danger-color: #ef4444; + --success-color: #10b981; + + --text-primary: #1e293b; + --text-secondary: #64748b; + --text-muted: #94a3b8; + + --bg-primary: #ffffff; + --bg-secondary: #f8fafc; + --bg-tertiary: #f1f5f9; + + --border-color: #e2e8f0; + --border-light: #f1f5f9; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08); + + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + + --font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + --gradient-primary: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); + --gradient-secondary: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); +} + +/* =================================== + 基础样式重置 + =================================== */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + font-family: var(--font-family); + font-size: 14px; + line-height: 1.6; + color: var(--text-primary); + background-color: var(--bg-secondary); + min-height: 100vh; +} + +a { + color: var(--primary-color); + text-decoration: none; + transition: color 0.15s ease; +} + +a:hover { + color: var(--primary-dark); + text-decoration: underline; +} + +button { + font-family: inherit; + cursor: pointer; + border: none; + outline: none; + transition: all 0.15s ease; +} + +input, textarea, select { + font-family: inherit; + font-size: inherit; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 10px 12px; + background: var(--bg-primary); + color: var(--text-primary); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +input:focus, textarea:focus, select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(79, 158, 222, 0.08); +} + +/* =================================== + 按钮样式 + =================================== */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 16px; + border-radius: var(--radius-md); + font-weight: 500; + font-size: 14px; + transition: all 0.15s ease; +} + +.btn-primary { + background: var(--gradient-primary); + color: #fff; + box-shadow: var(--shadow-sm); +} + +.btn-primary:hover { + box-shadow: var(--shadow-md); + transform: translateY(-0.5px); +} + +.btn-secondary { + background: var(--bg-primary); + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + background: var(--bg-tertiary); + border-color: var(--primary-color); + color: var(--primary-color); +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +/* =================================== + 卡片样式 + =================================== */ + +.card { + background: var(--bg-primary); + border-radius: var(--radius-lg); + border: 1px solid var(--border-color); + box-shadow: var(--shadow-sm); + padding: 20px; +} + +.card-header { + padding-bottom: 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--border-light); + display: flex; + align-items: center; + justify-content: space-between; +} + +.card-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +/* =================================== + 表单样式 + =================================== */ + +.form-group { + margin-bottom: 16px; +} + +.form-label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.form-input { + width: 100%; +} + +.form-textarea { + width: 100%; + min-height: 80px; + resize: vertical; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +/* =================================== + 布局样式 + =================================== */ + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; +} + +.flex { + display: flex; +} + +.flex-col { + flex-direction: column; +} + +.items-center { + align-items: center; +} + +.justify-between { + justify-content: space-between; +} + +.gap-4 { + gap: 16px; +} + +.gap-6 { + gap: 24px; +} + +/* =================================== + 文字样式 + =================================== */ + +.text-sm { + font-size: 12px; +} + +.text-md { + font-size: 14px; +} + +.text-lg { + font-size: 16px; +} + +.text-xl { + font-size: 20px; +} + +.font-medium { + font-weight: 500; +} + +.font-semibold { + font-weight: 600; +} + +.text-center { + text-align: center; +} + +.text-muted { + color: var(--text-muted); +} + +/* =================================== + 状态提示 + =================================== */ + +.alert { + padding: 12px 16px; + border-radius: var(--radius-md); + margin-bottom: 16px; + font-size: 13px; + display: flex; + align-items: flex-start; + gap: 8px; +} + +.alert-success { + background: rgba(16, 185, 129, 0.08); + color: var(--success-color); + border: 1px solid rgba(16, 185, 129, 0.2); +} + +.alert-error { + background: rgba(239, 68, 68, 0.08); + color: var(--danger-color); + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.alert-warning { + background: rgba(251, 191, 36, 0.08); + color: #d97706; + border: 1px solid rgba(251, 191, 36, 0.2); +} + +/* =================================== + 响应式 + =================================== */ + +@media (max-width: 768px) { + .container { + padding: 0 16px; + } + + .form-row { + grid-template-columns: 1fr; + } + + .btn { + padding: 12px 16px; + font-size: 14px; + } + + .card { + padding: 16px; + } +} + +@media (max-width: 480px) { + body { + font-size: 13px; + } + + .container { + padding: 0 12px; + } + + .btn { + padding: 10px 14px; + font-size: 13px; + } + + .card { + padding: 12px; + } +} \ No newline at end of file diff --git a/static/css/login.css b/static/css/login.css new file mode 100644 index 0000000..6399f4b --- /dev/null +++ b/static/css/login.css @@ -0,0 +1,174 @@ +/* =================================== + 简记memo - 登录页样式 + =================================== */ + +.login-page { + display: flex; + min-height: calc(100vh - 70px); +} + +/* 左侧品牌区 */ +.login-left { + flex: 1; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px; + color: #fff; +} + +.login-logo { + width: 120px; + height: 120px; + border-radius: 28px; + background: rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 32px; + overflow: hidden; +} + +.login-logo img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.login-brand { + font-size: 2.5rem; + font-weight: 800; + margin-bottom: 12px; + letter-spacing: 3px; +} + +.login-slogan { + font-size: 1rem; + opacity: 0.9; + text-align: center; + line-height: 1.8; + max-width: 300px; +} + +.login-features { + margin-top: 48px; + display: flex; + gap: 32px; +} + +.feature-item { + text-align: center; +} + +.feature-icon { + font-size: 2rem; + margin-bottom: 8px; +} + +.feature-text { + font-size: 0.875rem; + opacity: 0.85; +} + +/* 右侧表单区 */ +.login-right { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: #f8fafc; + padding: 40px; +} + +/* 响应式 */ +@media (max-width: 768px) { + .login-page { + flex-direction: column; + } + + .login-left { + padding: 40px 24px; + } + + .login-logo { + width: 80px; + height: 80px; + } + + .login-brand { + font-size: 1.75rem; + } + + .login-features { + margin-top: 24px; + gap: 20px; + } + + .login-right { + padding: 24px; + } +} + +/* 页面底部版权信息 */ +.page-footer { + padding: 24px; + text-align: center; + font-size: 0.75rem; + color: #94a3b8; + background: #fff; + border-top: 1px solid #e2e8f0; +} + +.page-footer a { + color: #6366f1; + text-decoration: none; +} + +.page-footer a:hover { + text-decoration: underline; +} + +.footer-divider { + margin: 0 8px; + color: #cbd5e1; +} + +/* 协议勾选框样式 */ +.checkbox-group { + padding: 0; + margin-bottom: 16px; +} + +.checkbox-label { + display: flex; + align-items: flex-start; + gap: 6px; + cursor: pointer; + font-size: 13px; + color: #64748b; + flex-wrap: wrap; +} + +.form-checkbox { + width: 14px; + height: 14px; + cursor: pointer; + margin-top: 3px; + flex-shrink: 0; +} + +.checkbox-text { + line-height: 1.6; +} + +.link-text { + color: #4F9EDE; + text-decoration: none; + line-height: 1.6; +} + +.link-text:hover { + text-decoration: underline; +} \ No newline at end of file diff --git a/static/html/404.html b/static/html/404.html new file mode 100644 index 0000000..e381b22 --- /dev/null +++ b/static/html/404.html @@ -0,0 +1,238 @@ + + + + + + 404 - 页面走丢了 + + + + + +
📝
+
+
🎯
+
💡
+ +
+
📝
+
404
+

哎呀,页面走丢了!

+

+ 这个页面可能被风吹走了,或者被外星人带走了...
+ 别担心,我们可以带你回到安全的地方! +

+ +
+ 💡 冷知识:404 错误代码源于 1990 年的 CERN,当时办公室在 4 楼 404 房间! +
+
+ + + + diff --git a/static/html/about.html b/static/html/about.html new file mode 100644 index 0000000..171b38b --- /dev/null +++ b/static/html/about.html @@ -0,0 +1,148 @@ + + + + + + 关于我们 - 简记memo + + + + +
+ +
+ + +
+
+ +
+ + +

简记memo

+

集待办任务、账单记录、心情日记于一体的个人事务管理工具

+
+ + +
+

+ + 关于我们 +

+
+

+ 简记memo是一款致力于帮助用户更好地管理个人事务的工具。我们相信,良好的记录习惯能够让生活更加有序,让每一天都变得更有意义。 +

+

+ 我们的愿景是打造一个简洁、优雅、实用的记录平台,让用户能够轻松记录待办任务、心情变化和收支情况,同时通过成长体系激励用户养成良好的习惯。 +

+

+ 团队成员来自不同领域,我们怀着对产品的热爱和对用户体验的追求,不断优化和完善产品,希望能够为用户带来更好的使用体验。 +

+
+
+ + +
+

+ + 联系我们 +

+
+
+
+ + + +
+
+
邮箱
+
support@jianji-memo.com
+
+
+
+
+ + + +
+
+
微信公众号
+
简记memo
+
+
+
+
+ + +
+

+ + 团队成员 +

+
+
+
+ 👨‍💻 +
+
产品经理
+
负责产品规划和设计
+
+
+
+ 👩‍💻 +
+
前端开发
+
负责界面开发
+
+
+
+ 👨‍🔧 +
+
后端开发
+
负责服务端开发
+
+
+
+ 🎨 +
+
UI设计师
+
负责界面设计
+
+
+
+
+
+ + +
+
+ + + + + + + + \ No newline at end of file diff --git a/static/html/agreement.html b/static/html/agreement.html new file mode 100644 index 0000000..d2cdff0 --- /dev/null +++ b/static/html/agreement.html @@ -0,0 +1,106 @@ + + + + + + 用户协议 - 简记memo + + + + +
+ +
+ + +
+
+
+

简记memo用户协议

+

生效日期:2025年04月24日

+ +
+
1. 协议主体与范围
+

1.1 本协议是您(以下简称"用户")与简记memo服务(以下简称"本服务")个人运营者(以下简称"运营者")之间关于使用本服务所订立的契约,运营者为个人独立开发者,无所属企业/公司主体。

+

1.2 您通过网站访问、使用本服务的行为,即视为您已阅读、理解并同意本协议全部条款及《隐私政策》;若您不同意本协议或《隐私政策》,应立即停止使用本服务。

+

1.3 本协议适用于您使用本服务的所有行为,包括但不限于账号注册登录、数据录入、存储、编辑等核心功能。

+
+ +
+
2. 服务内容与范围
+

2.1 本服务为用户提供免费的备忘录、待办事项、账单记录服务,核心功能包括文字内容录入、服务器存储、编辑、删除、数据同步等,所有功能仅基于用户身份识别提供。

+

2.2 运营者有权根据技术发展、用户需求、法律法规调整等因素,不定期优化、调整或暂停部分/全部服务,重大调整将通过服务内公告提前7日告知用户。

+

2.3 本服务为免费服务,运营者不承诺提供商业级别的服务稳定性,仅保障基础功能的正常可用。

+
+ +
+
3. 用户权利与义务
+

3.1 权利:您有权按照本协议约定使用本服务核心功能;有权查看、编辑、删除自身存储在服务器的所有数据;有权向运营者反馈功能问题或优化建议;在遵守本协议的前提下,运营者保障您正常使用本服务的基础权益。

+

3.2 义务:您保证使用本服务的行为符合《中华人民共和国网络安全法》《互联网信息服务管理办法》等法律法规及公序良俗;不得利用本服务存储、传播违法、违规、侵权、色情、暴力等内容;应对自身录入的内容及使用行为承担全部法律责任;不得对本服务进行反向工程、篡改、破解、爬虫抓取等损害服务正常运行的操作。

+

3.3 您理解并同意,用户身份信息仅用于身份识别,运营者不会通过该信息获取您的其他账号信息。

+
+ +
+
4. 服务的暂停与终止
+

4.1 运营者有权在以下情形暂停/终止服务:(1)您违反本协议约定且未在通知期限内纠正;(2)法律法规、相关平台规则要求暂停/终止;(3)不可抗力(如服务器故障、网络中断);(4)运营者自主停止运营(提前30日在服务内公告)。

+

4.2 服务暂停期间,运营者将尽力恢复服务,但不承诺恢复时限;服务终止后,您存储在服务器的所有数据将保留7日,逾期将永久删除,运营者不承担数据找回、备份或赔偿责任。

+

4.3 您可主动申请终止服务(联系运营者删除账号及所有数据),申请生效后,运营者将在15个工作日内完成数据清理。

+
+ +
+
5. 免责声明
+

5.1 本服务仅提供基础数据存储与管理功能,运营者不对您存储内容的完整性、准确性、安全性做绝对担保;因网络故障、服务器维护、平台规则调整、您的设备问题等非运营方可控因素导致的数据丢失、功能异常,运营者不承担赔偿责任。

+

5.2 本服务为个人开发的免费服务,运营者无义务提供7×24小时技术支持,仅根据实际情况在合理期限内解答用户咨询(通过预留邮箱/公众号)。

+

5.3 因您违反法律法规或本协议约定,导致第三方索赔、行政机关处罚的,由您自行承担全部责任,运营者不承担连带责任。

+
+ +
+
6. 协议的变更
+

6.1 运营者可根据法律法规调整、业务优化需要修改本协议,修改后的协议将在服务内公示,公示满7日后自动生效。

+

6.2 若您不同意修改后的协议,应立即停止使用本服务并可申请删除账号数据;继续使用本服务的,视为您同意修改后的协议。

+
+ +
+
7. 法律适用与争议解决
+

7.1 本协议适用中华人民共和国法律(不含港澳台地区法律)。

+

7.2 因本协议产生的争议,双方应友好协商解决;协商不成的,任何一方可向运营者所在地有管辖权的人民法院提起诉讼。

+
+ +
+
8. 联系与其他
+

8.1 若您对本协议有任何疑问,可通过以下方式联系运营者:联系邮箱:memo@miaoall.cn;微信公众号:孙三苗(sunsanmiao)。

+

8.2 本协议的条款如被有权机关认定为无效或不可执行,不影响其余条款的效力。

+

8.3 本协议与《隐私政策》共同构成您与运营者之间的完整约定,两者冲突时,以《隐私政策》中关于个人信息保护的内容为准。

+
+
+
+
+ + +
+
+ + + + + + + + \ No newline at end of file diff --git a/static/html/calendar.html b/static/html/calendar.html new file mode 100644 index 0000000..14ec2a8 --- /dev/null +++ b/static/html/calendar.html @@ -0,0 +1,2151 @@ + + + + + + 日历 - 简记memo + + + + +
+ +
+ + +
+
+ +
+ +
+
+

日程日历

+

2024年5月

+
+
+ + + +
+
+ + +
+
+
+
+
+
+
+
+
+ + +
+ + +
+

图例

+
+
+
+ 待办 +
+
+
+ 心情 +
+
+
+ 账单 +
+
+
+
+ + +
+ +
+
+
+

今日

+

5月14日 周四

+
+
+
😊
+
心情
+
+
+
+
+
0
+
待办任务
+
+
+
0
+
今日账单
+
+
+
0
+
获得经验
+
+
+ +
+
+ 今日进度 + 0 / 120 EXP +
+
+
+
+
+
+ + +
+ +
+ + + +
+ + +
+
+

待办任务

+ +
+
+
+ + + + + + +
+
+
+
+ + +
+
+ + + + + +
+
+
+ 新增待办 + +
+
+
+ + +
+ +
+ + +
+ +
+ +
+ + - + +
+
+ +
+ +
+ + + + +
+
+ +
+ +
+ + + +
+
+ +
+ + +
+
+ +
+ + +
+
+
+ 记录心情 + +
+
+
+
+ + + +
+ +
+ 0/200 +
+
+ +
+
+ +
+ +
+
+ +
+ + +
+
+
+ 记一笔 + +
+
+
+
+ + +
+
+ +
+ +
+ ¥ 0.00 +
+ +
+ + + + + + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + +
+ #午餐 + #打车 + #地铁 + #工资 + #购物 + #娱乐 +
+
+ +
+ + +
+
+ +
+ + + + + + + + + + + + \ No newline at end of file diff --git a/static/html/growth-rules.html b/static/html/growth-rules.html new file mode 100644 index 0000000..59522f9 --- /dev/null +++ b/static/html/growth-rules.html @@ -0,0 +1,429 @@ + + + + + + 成长规则 - 简记memo + + + + +
+
+ +
+
+
+
+ 📊 +
+

成长规则

+

了解如何获取经验,提升等级,解锁成就与特权

+
+ +
+
+
+ 📈 +
+
+

等级体系

+

从Lv.1到Lv.10,等级越高,称号越拉风

+
+
+

+ 成长等级从Lv.1到Lv.10,通过累计经验值提升等级。所有核心功能完全免费开放,不受等级限制!等级特权只是额外的荣誉展示和增值服务。 +

+ +
+
+
{{ level.icon }}
+
+
+ Lv.{{ level.level }} +
+ {{ level.title }} +
+
需要 {{ level.exp.toLocaleString() }} EXP
+
+ + {{ privilege }} + +
+
+
+
+ +
+
+
+ +
+
+

经验获取

+

完成任务、记账、记录心情都能获得经验

+
+
+ +
+
+
+
+ {{ rule.icon }} +
+
+
{{ rule.name }}
+
{{ rule.desc }}
+
+
+
+
{{ rule.exp }}
+
+

{{ rule.detail }}

+
+
+ +
+
+ + + + 每日经验上限:120 EXP +
+
+
+ +
+
+
+ 🔥 +
+
+

连续打卡加成

+

连续记录天数越多,加成越高

+
+
+ +
+
+
+ {{ rule.bonus }} EXP/天 +
+
{{ rule.range }}
+
+
+ +
+

+ 💡 温馨提示:断签后连续天数不会完全清零,而是根据断签前的连续天数保留一部分:1-7天清零,8-30天保留3天,31-90天保留7天,91天以上保留15天。 +

+
+
+ +
+
+
+ 🛡️ +
+
+

新用户保护期

+

帮助新用户快速成长

+
+
+ +
+
+
50%
+
保护期内额外经验加成
+
+
+

+ 新用户前7天享受保护期加成!保护期内每次获取经验都会额外获得50%的经验加成,帮助你快速成长。 +

+

+ 保护期结束后,连续天数中断时,只会减少部分天数,不会完全重置。 +

+
+
+
+ +
+
+
+ 🎖️ +
+
+

成就系统

+

完成特定目标可解锁成就,获得额外经验奖励

+
+
+ +
+ +
+ +
+
+
+ {{ rarity.name }} +
+
+ +
+
+
+
+ {{ achievement.icon || '🏆' }} +
+
+ {{ achievement.rarityName || achievement.rarity }} +
+
+

{{ achievement.name }}

+

{{ achievement.description || achievement.desc || '' }}

+
+ +{{ achievement.expReward || achievement.exp || 0 }} EXP + 目标: {{ achievement.target }} +
+
+
+
+ +
+
+
+ 💡 +
+
+

快速升级秘籍

+

掌握这些技巧,升级更快

+
+
+ +
+
+
+ {{ index + 1 }} +
+

{{ tip }}

+
+
+
+ +
+
+
+ +
+
+

常见问题

+

解答你的疑惑

+
+
+ +
+
+
+
+ Q +
+ {{ item.Q }} + + + +
+
+

{{ item.A }}

+
+
+
+
+
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/static/html/growth.html b/static/html/growth.html new file mode 100644 index 0000000..a0ec740 --- /dev/null +++ b/static/html/growth.html @@ -0,0 +1,257 @@ + + + + + + 成长中心 - 简记memo + + + + +
+ +
+ + +
+
+ +
+ +
+ +
+
+
Lv.1
+
+
+

成长等级

+

0 / 100 经验

+ +
+
+
+

还需 100 经验升级

+
+ + +
+

数据统计

+
+
+
0
+
累计经验
+
+
+
0
+
获得成就
+
+
+
0
+
连续记录
+
+
+
0
+
完成任务
+
+
+
+ + +
+

等级列表

+
+ +
+
+
+ + +
+
+

我的成就

+
+ + 0 / 8 +
+
+ +
+ +
+
+
+
+
+ + +
+
+ + + + + + + + + + diff --git a/static/html/index.html b/static/html/index.html new file mode 100644 index 0000000..550294b --- /dev/null +++ b/static/html/index.html @@ -0,0 +1,437 @@ + + + + + + 简记memo - 让记录更简单 + + + + + +
+ +
+ + +
+
+
+
+
+ + 全新版本上线 +
+

简记memo

+

集待办任务、账单记录、心情日记于一体的个人事务管理工具。简洁优雅的界面设计,让记录成为一种享受。

+ +
+
+
+
+
+ 今日待办 + 5月13日 +
+
+
+
+ +
+ 完成项目文档 +
+
+
+
+ 参加会议 + 重要 +
+
+
+
+ 整理文件 +
+
+
+
+
+
+
+
+ + +
+
+
+ 功能特性 +

为什么选择简记memo

+

简洁优雅的界面设计,强大实用的功能,帮助您更好地管理生活

+
+ +
+
+
+ 📝 +
+

待办管理

+

轻松创建和管理待办任务,设置优先级和截止日期,让您的日程井井有条。

+
+ +
+
+ 😊 +
+

心情日记

+

记录每一天的心情变化,用文字和表情记录生活的点点滴滴。

+
+ +
+
+ 💰 +
+

账单记录

+

便捷记录收支情况,分类统计一目了然,让理财变得简单。

+
+ +
+
+ 🏆 +
+

成长体系

+

完成任务获得经验值,提升等级解锁成就,让记录更有成就感。

+
+
+
+
+ + +
+
+
+ 使用流程 +

简单三步开始记录

+
+ +
+
+
+ 1️⃣ +
+

注册账号

+

使用邮箱快速注册,开启您的记录之旅

+
+
+
+ 2️⃣ +
+

创建任务

+

添加待办事项、记录心情或账单

+
+
+
+ 3️⃣ +
+

查看统计

+

查看完成进度、等级成长和成就

+
+
+
+
+ + +
+
+

开始您的记录之旅

+

加入简记memo,让每一天都值得记录

+ + 立即体验 + +
+
+ + +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/static/html/login.html b/static/html/login.html new file mode 100644 index 0000000..86504b4 --- /dev/null +++ b/static/html/login.html @@ -0,0 +1,1661 @@ + + + + + + 登录 - 简记memo + + + + +
+ +
+ + + + + +
+
+ + + + + + + + + diff --git a/static/html/privacy.html b/static/html/privacy.html new file mode 100644 index 0000000..14ecd18 --- /dev/null +++ b/static/html/privacy.html @@ -0,0 +1,123 @@ + + + + + + 隐私政策 - 简记memo + + + + +
+ +
+ + +
+
+
+

简记memo隐私政策

+

生效日期:2025年04月24日

+ +
+
1. 总则
+

1.1 为保护用户隐私,规范本服务的个人信息处理行为,本服务运营者(个人开发者,以下简称"运营者")依据《中华人民共和国个人信息保护法》《中华人民共和国网络安全法》等法律法规,制定本隐私政策。

+

1.2 本隐私政策适用于您使用本服务过程中涉及的个人信息处理活动,您使用本服务即视为同意本政策;若您不同意本政策,请勿使用本服务。

+

1.3 本服务由个人独立开发及运营,无所属企业/公司主体,所有信息处理行为均由运营者个人负责。

+
+ +
+
2. 个人信息的收集
+

2.1 登录相关信息:您注册账号时提供的邮箱地址、昵称等信息,用于识别您的用户身份。

+

2.2 业务数据:您在本服务内录入的备忘录、待办事项、账单内容、编辑记录、时间戳等数据,将存储于运营者的服务器中,仅用于为您提供数据同步、查看、编辑等核心功能。

+

2.3 设备与日志信息:为保障服务正常运行,可能被动获取基础设备信息(如设备型号、系统版本)、操作日志(如页面访问记录、功能使用记录),该类信息仅用于故障排查、功能优化。

+

2.4 运营者不会主动收集您的姓名、身份证号、手机号、地理位置等其他个人敏感信息。

+
+ +
+
3. 个人信息的使用
+

3.1 用户身份信息仅用于识别您的用户身份,保障您能正常访问、管理自己存储在服务器的业务数据。

+

3.2 您的业务数据仅用于为您本人提供查看、编辑、删除、同步等核心功能,运营者不会利用该数据进行商业推广、数据分析、第三方共享等其他用途。

+

3.3 设备与日志信息仅用于定位和解决服务运行故障、优化产品体验,不会用于识别个人身份或向第三方披露。

+
+ +
+
4. 个人信息的存储与保护
+

4.1 存储方式:用户身份信息及您的业务数据存储于运营者的服务器中;设备与日志信息仅在故障排查期间临时存储,排查完成后即时删除。

+

4.2 存储期限:用户身份信息与业务数据将持续存储,直至您主动申请删除账号;设备与日志信息存储期限不超过7日。

+

4.3 安全措施:运营者通过服务器访问权限控制、数据加密传输等技术手段保障数据安全,但因网络攻击、服务器故障等非运营方可控因素导致的数据泄露,运营者不承担责任。

+

4.4 您可通过服务内的删除功能清理自身业务数据,如需彻底删除账号及所有数据,可联系运营者协助处理。

+
+ +
+
5. 个人信息的共享、转让与公开披露
+

5.1 运营者不会将您的身份信息、业务数据、设备信息等任何信息共享、转让给第三方,除非获得您的明确授权,或法律法规、司法机关/行政机关的强制性要求。

+

5.2 未经您同意,运营者不会公开披露您的任何信息;因法律法规要求披露时,将在允许范围内尽可能提前通知您。

+
+ +
+
6. 您的权利
+

6.1 您有权随时查看、编辑、删除自己存储在本服务内的业务数据;

+

6.2 您有权联系运营者申请删除账号及所有关联数据;

+

6.3 您有权向运营者查询自身个人信息的处理情况,运营者将在合理期限内予以答复;

+

6.4 若您发现本服务存在违规处理您信息的行为,有权向运营者反馈或向相关平台投诉。

+
+ +
+
7. 未成年人保护
+

7.1 本服务面向所有年龄段用户,若未成年人使用本服务,应在监护人指导下进行,监护人应承担相应监护责任。

+

7.2 运营者不会主动收集未成年人的任何额外个人信息,若监护人发现未成年人的信息被不当处理,可联系运营者协助处理。

+
+ +
+
8. 隐私政策的变更
+

8.1 运营者可根据法律法规调整或业务优化,修改本隐私政策,修改后的政策将在服务内公示,公示满7日后生效。

+

8.2 若您不同意修改后的政策,应停止使用本服务并可申请删除账号数据;继续使用的,视为同意修改后的政策。

+
+ +
+
9. 联系我们
+

9.1 若您对本隐私政策有任何疑问、意见或投诉,可通过以下方式联系运营者:

+

联系邮箱:memo@miaoall.cn

+

微信公众号:孙三苗(sunsanmiao)

+

9.2 运营者将在15个工作日内回复您的咨询或处理请求。

+
+ +
+
10. 其他
+

10.1 本隐私政策的解释权归运营者个人所有;

+

10.2 若本政策与法律法规冲突,以法律法规为准;

+

10.3 您使用本服务的行为,同时受相关平台的隐私政策及运营规范约束。

+
+
+
+
+ + +
+
+ + + + + + + + \ No newline at end of file diff --git a/static/html/profile.html b/static/html/profile.html new file mode 100644 index 0000000..e78d37b --- /dev/null +++ b/static/html/profile.html @@ -0,0 +1,509 @@ + + + + + + 个人中心 - 简记memo + + + + +
+ +
+ +
+
+ +
+ +
+ +
+
+ U +
更换
+
+

用户

+

未绑定邮箱

+

还没有个性签名

+ +
+ + +
+

快捷入口

+
+ + +
+
+ + +
+
+
+ + 关于我们 +
+ +
+
+
+ + 隐私政策 +
+ +
+
+
+ + +
+ +
+
+
0
+
总待办
+
+
+
0
+
已完成
+
+
+
1
+
当前等级
+
+
+
0
+
经验值
+
+
+ + +
+

成长进度

+
+
+ Lv.1 +
+
+
+ 当前等级 + 0 / 100 经验 +
+
+
+
+

还需 100 经验升级

+
+
+
+ + +
+
+

我的成就

+
+ +
+
+
+ +
+
+
+
+
+
+ + +
+
+ + + + + + + + + + + + + + + + diff --git a/static/images/logo.png b/static/images/logo.png new file mode 100644 index 0000000..ef0341a Binary files /dev/null and b/static/images/logo.png differ diff --git a/static/js/auth.js b/static/js/auth.js new file mode 100644 index 0000000..d722d54 --- /dev/null +++ b/static/js/auth.js @@ -0,0 +1,172 @@ +// static/js/auth.js +// 统一认证管理:Token过期检查、错误处理、自动登出 +(function() { + 'use strict'; + + // ==================== 防重复标记 ==================== + var _isHandlingExpired = false; // 正在处理过期(防重复) + var _hasNotified = false; // 本次会话是否已提示过 + + var Auth = { + DEFAULT_EXPIRE_MS: 7200 * 1000, // 2小时 + + isTokenExpired: function() { + var token = localStorage.getItem('user_token'); + if (!token) return true; + + var expiresAt = localStorage.getItem('user_token_expires'); + if (!expiresAt) { + var tokenAge = Date.now() - (parseInt(localStorage.getItem('user_token_created')) || Date.now()); + return tokenAge > this.DEFAULT_EXPIRE_MS; + } + return Date.now() > parseInt(expiresAt); + }, + + isLoggedIn: function() { + return !this.isTokenExpired() && !!localStorage.getItem('user_token'); + }, + + /** + * 清除登录状态(仅清数据,不跳转) + */ + logout: function() { + // 清除所有用户相关的 localStorage key + localStorage.removeItem('user_token'); + localStorage.removeItem('user_token_expires'); + localStorage.removeItem('user_token_created'); + localStorage.removeItem('user_nickname'); + localStorage.removeItem('user_avatar'); + // user_info 由 UserStore.clear() 处理 + + if (window.UserStore) { + UserStore.clear(); + } + + // 立即更新头部 DOM,把用户菜单替换为登录按钮 + this._updateHeaderUI(); + }, + + /** + * 更新头部用户菜单 UI + */ + _updateHeaderUI: function() { + // PC端 + var pcContainer = document.getElementById('userMenuContainer'); + if (pcContainer && window.renderUserMenu) { + pcContainer.innerHTML = renderUserMenu(false, null); + } + // 移动端 + var mobileContainer = document.getElementById('mobileUserMenu'); + if (mobileContainer && window.renderMobileUserMenu) { + mobileContainer.innerHTML = renderMobileUserMenu(false, null); + } + }, + + /** + * 处理登录过期(防重复调用) + */ + handleTokenExpired: function(reason) { + // 防重复:如果已经在处理中,直接返回 + if (_isHandlingExpired) return; + _isHandlingExpired = true; + + console.warn('Token已过期:', reason); + + // 1. 先清除数据 + this.logout(); + + // 2. 只提示一次 + if (!_hasNotified) { + _hasNotified = true; + if (window.$toast) { + $toast.warning('登录已过期,请重新登录'); + } + } + + // 3. 立即跳转(不再用 setTimeout,避免期间其他请求重复触发) + // 如果当前已经在登录页,不跳转 + if (window.location.pathname !== '/login') { + window.location.href = '/login'; + } + }, + + /** + * 初始化:页面加载时检查 token + */ + init: function() { + if (this.isTokenExpired()) { + var token = localStorage.getItem('user_token'); + if (token) { + // 有 token 但过期了 → 处理 + this.handleTokenExpired('本地检测到token已过期'); + } else { + // 没有 token → 仅更新 UI(不提示、不跳转) + this._updateHeaderUI(); + } + } else { + // token 有效,记录创建时间 + if (!localStorage.getItem('user_token_created')) { + localStorage.setItem('user_token_created', String(Date.now())); + } + } + }, + + getAuthHeaders: function() { + return { 'token': localStorage.getItem('user_token') }; + } + }; + + window.Auth = Auth; + + // ==================== 全局 Fetch 拦截 ==================== + var originalFetch = window.fetch; + + window.fetch = function(url, options) { + options = options || {}; + options.headers = options.headers || {}; + + var isInternalAPI = typeof url === 'string' && url.indexOf('/api/') !== -1 && url.indexOf('://') === -1; + + // 自动注入 token + if (isInternalAPI) { + var token = localStorage.getItem('user_token'); + if (token && !options.headers['token']) { + options.headers['token'] = token; + } + } + + return originalFetch(url, options) + .then(function(response) { + // 非 API 或正在处理过期 → 直接返回 + if (!isInternalAPI || _isHandlingExpired) { + return response; + } + + // 克隆响应,尝试解析 JSON 检查认证错误 + var clone = response.clone(); + clone.json().then(function(data) { + if (data.code === 1000 || data.code === 401) { + Auth.handleTokenExpired(data.msg || '登录已过期'); + } + }).catch(function() { + // JSON 解析失败(如非 JSON 响应),忽略 + }); + + return response; + }); + }; + + // ==================== 初始化 ==================== + // 确保 DOM 加载完成后再初始化(避免 _updateHeaderUI 找不到元素) + function doInit() { + Auth.init(); + } + + if (document.readyState === 'complete' || document.readyState === 'interactive') { + // DOM 已就绪,但需要等下一帧确保其他脚本也执行完 + setTimeout(doInit, 0); + } else { + document.addEventListener('DOMContentLoaded', doInit); + } + +})(); diff --git a/static/js/common-components.js b/static/js/common-components.js new file mode 100644 index 0000000..a692661 --- /dev/null +++ b/static/js/common-components.js @@ -0,0 +1,386 @@ +// 公共头部组件渲染函数 +function renderCommonHeader(currentPath) { + return ` +
+ + + + + + + +
+ + + `; +} + +// 用户菜单渲染函数 +function renderUserMenu(isLoggedIn, userInfo) { + if (isLoggedIn && userInfo) { + const defaultAvatar = '/static/avatar/default.png'; + const avatarSrc = localStorage.getItem('user_avatar') || userInfo.avatar || defaultAvatar; + const nickname = localStorage.getItem('user_nickname') || userInfo.nickname || '用户'; + return ` + + `; + } else { + return ` + + `; + } +} + +// 公共底部渲染函数 +function renderCommonFooter() { + return ` + + `; +} + +// 初始化公共组件 +function initCommonComponents() { + const userMenuContainer = document.getElementById('userMenuContainer'); + if (!userMenuContainer) return; + + const userInfo = UserStore.get(); + // 兼容:如果 UserStore 没有 token 但 localStorage 有 user_token,自动同步 + let isLoggedIn = userInfo && userInfo.token; + if (!isLoggedIn && localStorage.getItem('user_token')) { + const token = localStorage.getItem('user_token'); + const nickname = localStorage.getItem('user_nickname') || '用户'; + const avatar = localStorage.getItem('user_avatar') || '/static/avatar/default.png'; + UserStore.set({ token, nickname, avatar }); + isLoggedIn = true; + } + userMenuContainer.innerHTML = renderUserMenu(isLoggedIn, isLoggedIn ? UserStore.get() : null); + + // 渲染移动端用户菜单 + const mobileUserMenu = document.getElementById('mobileUserMenu'); + if (mobileUserMenu) { + mobileUserMenu.innerHTML = renderMobileUserMenu(isLoggedIn, isLoggedIn ? UserStore.get() : null); + } + + if (isLoggedIn) { + const logoutBtn = document.getElementById('logoutBtn'); + if (logoutBtn) { + logoutBtn.addEventListener('click', function(e) { + e.preventDefault(); + fetch('/api/web/logout', { method: 'POST' }).finally(() => { + Auth.logout(); + window.location.href = '/'; + }); + }); + } + + const mobileLogoutBtn = document.getElementById('mobileLogoutBtn'); + if (mobileLogoutBtn) { + mobileLogoutBtn.addEventListener('click', function(e) { + e.preventDefault(); + fetch('/api/web/logout', { method: 'POST' }).finally(() => { + Auth.logout(); + window.location.href = '/'; + }); + }); + } + + const trigger = document.querySelector('.user-dropdown-trigger'); + const menu = document.querySelector('.user-dropdown-menu'); + if (trigger && menu) { + trigger.addEventListener('click', function() { + menu.style.opacity = menu.style.opacity === '1' ? '0' : '1'; + menu.style.visibility = menu.style.visibility === 'visible' ? 'hidden' : 'visible'; + menu.style.transform = menu.style.transform === 'translateY(0)' ? 'translateY(-8px)' : 'translateY(0)'; + }); + document.addEventListener('click', function(e) { + if (!trigger.contains(e.target) && !menu.contains(e.target)) { + menu.style.opacity = '0'; + menu.style.visibility = 'hidden'; + menu.style.transform = 'translateY(-8px)'; + } + }); + } + } + + const navLinks = document.querySelectorAll('.nav-link'); + navLinks.forEach(link => { + link.addEventListener('mouseenter', function() { + this.style.background = '#f1f5f9'; + this.style.color = SITE_CONFIG.brand.primaryColor; + }); + link.addEventListener('mouseleave', function() { + if (!this.classList.contains('active')) { + this.style.background = 'transparent'; + this.style.color = '#475569'; + } + }); + }); + + // 移动端菜单交互 + const mobileMenuBtn = document.getElementById('mobileMenuBtn'); + const mobileDropdownMenu = document.getElementById('mobileDropdownMenu'); + const mobileMenuOverlay = document.getElementById('mobileMenuOverlay'); + + function closeMobileMenu() { + mobileDropdownMenu.style.display = 'none'; + mobileMenuOverlay.style.display = 'none'; + mobileMenuBtn.classList.remove('active'); + document.body.style.overflow = ''; + } + + function openMobileMenu() { + mobileDropdownMenu.style.display = 'block'; + mobileMenuOverlay.style.display = 'block'; + mobileMenuBtn.classList.add('active'); + document.body.style.overflow = 'hidden'; + } + + if (mobileMenuBtn && mobileDropdownMenu && mobileMenuOverlay) { + mobileMenuBtn.addEventListener('click', function(e) { + e.stopPropagation(); + const isOpen = mobileDropdownMenu.style.display === 'block'; + if (isOpen) { + closeMobileMenu(); + } else { + openMobileMenu(); + } + }); + + // 点击遮罩层关闭菜单 + mobileMenuOverlay.addEventListener('click', closeMobileMenu); + + // 点击菜单链接时关闭菜单 + const mobileNavItems = document.querySelectorAll('.mobile-nav-item'); + mobileNavItems.forEach(link => { + link.addEventListener('click', closeMobileMenu); + }); + + // 点击移动端用户菜单链接时关闭菜单 + const mobileUserLinks = mobileUserMenu.querySelectorAll('a'); + mobileUserLinks.forEach(link => { + link.addEventListener('click', closeMobileMenu); + }); + } +} + +// 移动端用户菜单渲染函数 +function renderMobileUserMenu(isLoggedIn, userInfo) { + if (isLoggedIn && userInfo) { + const nickname = localStorage.getItem('user_nickname') || userInfo.nickname || '用户'; + return ` +
+ + + 日历 + + + + 个人中心 + + + + 成长中心 + + + + 退出登录 + +
+ `; + } else { + return ` +
+ + + 登录 / 注册 + +
+ `; + } +} + +function navigateTo(url) { + window.location.href = url; +} \ No newline at end of file diff --git a/static/js/components.js b/static/js/components.js new file mode 100644 index 0000000..4993098 --- /dev/null +++ b/static/js/components.js @@ -0,0 +1,139 @@ +document.addEventListener('DOMContentLoaded', async function() { + const currentPath = window.location.pathname; + + try { + const headerResponse = await fetch('/static/html/_header.html'); + const headerHtml = await headerResponse.text(); + document.body.insertAdjacentHTML('afterbegin', headerHtml); + + const navLinks = document.querySelectorAll('.nav-link'); + navLinks.forEach(link => { + const href = link.getAttribute('href'); + const page = link.getAttribute('data-page'); + + let isActive = false; + if (page === 'home' && (currentPath === '/' || currentPath === '/index.html')) { + isActive = true; + } else if (page === 'growth' && (currentPath === '/growth' || currentPath === '/growth.html')) { + isActive = true; + } else if (page === 'rules' && (currentPath === '/growth-rules' || currentPath === '/growth-rules.html')) { + isActive = true; + } else if (page === 'about' && (currentPath === '/about' || currentPath === '/about.html')) { + isActive = true; + } + + if (isActive) { + link.classList.add('active'); + } + }); + + const mobileMenuBtn = document.getElementById('mobileMenuBtn'); + const mainNav = document.getElementById('mainNav'); + if (mobileMenuBtn && mainNav) { + mobileMenuBtn.addEventListener('click', () => { + mobileMenuBtn.classList.toggle('active'); + mainNav.classList.toggle('active'); + }); + + mainNav.querySelectorAll('.nav-link').forEach(link => { + link.addEventListener('click', () => { + mobileMenuBtn.classList.remove('active'); + mainNav.classList.remove('active'); + }); + }); + } + } catch (error) { + console.error('Failed to load header:', error); + } + + try { + const footerResponse = await fetch('/static/html/_footer.html'); + const footerHtml = await footerResponse.text(); + document.body.insertAdjacentHTML('beforeend', footerHtml); + } catch (error) { + console.error('Failed to load footer:', error); + } + + // Initialize Mini Program links + initMiniProgramLinks(); +}); + +// Mini Program Links Handler +function initMiniProgramLinks() { + const miniproLinks = document.querySelectorAll('.minipro-link'); + + miniproLinks.forEach(link => { + link.addEventListener('click', function(e) { + e.preventDefault(); + const href = this.getAttribute('href'); + let title = this.getAttribute('data-title') || '小程序功能'; + let desc = this.getAttribute('data-desc') || '该功能需要在微信小程序中使用'; + + if (href.includes('todo')) { + title = '待办任务'; + desc = '高效管理日常任务,完成任务即可获得经验值'; + } else if (href.includes('bill')) { + title = '账单记录'; + desc = '简单快捷的记账体验,清晰了解资金流向'; + } else if (href.includes('mood')) { + title = '心情日记'; + desc = '记录每天的心情点滴,留下珍贵的回忆'; + } else if (href.includes('growth')) { + title = '成长之路'; + desc = '丰富的成就系统和等级体系,让记录更有趣'; + } + + showMiniProgramModal(title, desc); + }); + }); +} + +function showMiniProgramModal(title, desc) { + let overlay = document.getElementById('minipro-modal-overlay'); + + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'minipro-modal-overlay'; + overlay.className = 'minipro-modal-overlay'; + overlay.innerHTML = ` +
+
+

提示

+ +
+
+
📱
+

${title}

+

${desc}

+

+ 请打开微信,搜索"简记memo"小程序体验完整功能 +

+
+ +
+ `; + document.body.appendChild(overlay); + + overlay.addEventListener('click', function(e) { + if (e.target === overlay) { + closeMiniProgramModal(); + } + }); + } else { + document.getElementById('minipro-modal-title').textContent = title; + document.getElementById('minipro-modal-desc').textContent = desc; + } + + overlay.classList.add('active'); + document.body.style.overflow = 'hidden'; +} + +function closeMiniProgramModal() { + const overlay = document.getElementById('minipro-modal-overlay'); + if (overlay) { + overlay.classList.remove('active'); + document.body.style.overflow = ''; + } +} diff --git a/static/js/config.js b/static/js/config.js new file mode 100644 index 0000000..9ab3948 --- /dev/null +++ b/static/js/config.js @@ -0,0 +1,133 @@ +/* =================================== + 简记memo - 项目公共配置 + =================================== */ + +const SITE_CONFIG = { + path: '/static/images/logo.png', + name: '简记memo', + subtitle: '让记录更简单', + slogan: '记录,是对抗遗忘的方式。\n生活的美好藏在点滴细节里,\n用简记memo,写下时光的足迹。', + version: 'v1.0.0', + copyright: `© ${new Date().getFullYear()} 简记memo. All rights reserved.`, + email: 'memo@miaoall.cn', + wechat: { + name: '孙三苗', + account: 'sunsanmiao' + }, + defaultAvatar: '/static/avatar/default.png', + brand: { + primaryColor: '#4F9EDE', + secondaryColor: '#3A8BC9', + accentColor: '#67B8E3', + gradient: 'linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%)', + lightGradient: 'linear-gradient(135deg, #67B8E3 0%, #4F9EDE 100%)' + } +}; + +const APP_INFO = { + name: '简记memo', + slogan: '简洁高效的个人事务管理工具', + description: '集待办任务、账单记录、心情日记于一体的个人事务管理工具', + + features: [ + { icon: '📅', text: '日历管理' }, + { icon: '✅', text: '待办事项' }, + { icon: '💰', text: '账单记录' }, + { icon: '😊', text: '心情日记' } + ], + + beian: { + icp: '京ICP备2026013204号', + icpLink: 'https://beian.miit.gov.cn/', + police: '京公网安备11011402055644号', + policeLink: 'http://www.beian.gov.cn/portal/registerSystemInfo?recordcode=京公网安备11011402055644号' + } +}; + +function renderSiteFooter(containerId) { + const container = document.getElementById(containerId); + if (!container) return; + + container.innerHTML = ` + + `; +} + +function renderSimpleFooter(containerId) { + const container = document.getElementById(containerId); + if (!container) return; + + container.innerHTML = ` + + `; +} + +function getAppName() { + return SITE_CONFIG.name; +} + +function getLogoPath() { + return SITE_CONFIG.path; +} + +function getBrandGradient() { + return SITE_CONFIG.brand.gradient; +} + +const UserStore = { + KEY: 'user_info', + + set(data) { + localStorage.setItem(this.KEY, JSON.stringify(data)); + }, + + get() { + const data = localStorage.getItem(this.KEY); + return data ? JSON.parse(data) : null; + }, + + clear() { + localStorage.removeItem(this.KEY); + }, + + setToken(token) { + const user = this.get() || {}; + user.token = token; + this.set(user); + }, + + getToken() { + const user = this.get(); + return user ? user.token : null; + }, + + setUserInfo(info) { + const user = this.get() || {}; + const mergedUser = { ...user, ...info }; + this.set(mergedUser); + }, + + getUserInfo() { + return this.get(); + } +}; \ No newline at end of file diff --git a/static/js/constants.js b/static/js/constants.js new file mode 100644 index 0000000..9022742 --- /dev/null +++ b/static/js/constants.js @@ -0,0 +1,121 @@ +const DEFAULT_CATE_LIST = [ + {"value": "food", "label": "餐饮", "icon": "🍚", "color": "#ff6b6b", "type": 1}, + {"value": "transport", "label": "交通", "icon": "🚇", "color": "#4ecdc4", "type": 1}, + {"value": "shopping", "label": "购物", "icon": "🛍️", "color": "#ffe66d", "type": 1}, + {"value": "entertainment", "label": "娱乐", "icon": "🎮", "color": "#a855f7", "type": 1}, + {"value": "home", "label": "居家", "icon": "🏠", "color": "#84b3d9", "type": 1}, + {"value": "medical", "label": "医疗", "icon": "🏥", "color": "#ef4444", "type": 1}, + {"value": "education", "label": "教育", "icon": "📚", "color": "#22c55e", "type": 1}, + {"value": "social", "label": "社交", "icon": "👥", "color": "#f97316", "type": 1}, + {"value": "digital", "label": "数码", "icon": "📱", "color": "#3b82f6", "type": 1}, + {"value": "clothing", "label": "服饰", "icon": "👔", "color": "#ec4899", "type": 1}, + {"value": "beauty", "label": "美容", "icon": "💄", "color": "#d946ef", "type": 1}, + {"value": "sports", "label": "运动", "icon": "⚽", "color": "#10b981", "type": 1}, + {"value": "pet", "label": "宠物", "icon": "🐶", "color": "#fbbf24", "type": 1}, + {"value": "baby", "label": "育儿", "icon": "🍼", "color": "#fb7185", "type": 1}, + {"value": "travel", "label": "旅游", "icon": "✈️", "color": "#06b6d4", "type": 1}, + {"value": "other_expense", "label": "其他", "icon": "📦", "color": "#9ca3af", "type": 1}, + {"value": "salary", "label": "工资", "icon": "💼", "color": "#22c55e", "type": 2}, + {"value": "bonus", "label": "奖金", "icon": "🎁", "color": "#fbbf24", "type": 2}, + {"value": "side_hustle", "label": "副业", "icon": "💻", "color": "#3b82f6", "type": 2}, + {"value": "investment", "label": "理财", "icon": "📈", "color": "#a855f7", "type": 2}, + {"value": "transfer", "label": "转账", "icon": "💳", "color": "#84b3d9", "type": 2}, + {"value": "red_packet", "label": "红包", "icon": "🧧", "color": "#ef4444", "type": 2}, + {"value": "other_income", "label": "其他", "icon": "🎯", "color": "#6b7280", "type": 2}, +]; + +const TODO_BASE_CATE = [ + {"value": "work", "label": "工作", "color": "#84b3d9"}, + {"value": "life", "label": "生活", "color": "#a0d0e0"}, + {"value": "study", "label": "学习", "color": "#b8d8e6"}, +]; + +const TODO_PRIORITY_LIST = [ + {"label": "高", "value": 1, "icon": "🔴", key: "high"}, + {"label": "中", "value": 2, "icon": "🟡", key: "medium"}, + {"label": "低", "value": 3, "icon": "🟢", key: "low"}, +]; + +const MOOD_LIST = [ + {"emoji": "😊", "label": "开心", "phrases": ["今天心情真好", "阳光灿烂的一天", "嘴角不自觉上扬", "满心欢喜"]}, + {"emoji": "😌", "label": "平静", "phrases": ["岁月静好", "内心一片安宁", "享受这份宁静", "平和自在"]}, + {"emoji": "😔", "label": "低落", "phrases": ["有点小忧伤", "心情沉沉的", "需要一点温暖", "静待花开"]}, + {"emoji": "😤", "label": "生气", "phrases": ["有点烦躁呢", "深呼吸冷静一下", "需要冷静片刻", "平复一下心情"]}, + {"emoji": "😴", "label": "疲惫", "phrases": ["有点累了", "好好休息一下", "给自己放个假", "累并快乐着"]}, + {"emoji": "😰", "label": "焦虑", "phrases": ["有点担心", "相信一切会好", "慢慢来不着急", "放宽心"]}, + {"emoji": "😄", "label": "兴奋", "phrases": ["超级开心!", "激动人心的时刻", "迫不及待了", "满心期待"]}, + {"emoji": "🤔", "label": "思考", "phrases": ["正在思考中", "让我想想", "深思熟虑", "思绪万千"]}, + {"emoji": "😢", "label": "难过", "phrases": ["有点难过", "需要一个拥抱", "一切都会过去", "明天会更好"]}, + {"emoji": "😎", "label": "酷", "phrases": ["今天超酷的", "自信满满", "做自己就好", "洒脱自在"]}, + {"emoji": "🥰", "label": "幸福", "phrases": ["被幸福包围", "甜蜜的感觉", "幸福感爆棚", "满心欢喜"]}, + {"emoji": "😠", "label": "愤怒", "phrases": ["真的生气了", "需要冷静一下", "平复一下情绪", "深呼吸"]}, + {"emoji": "🥳", "label": "庆祝", "phrases": ["值得庆祝!", "太棒了!", "举杯庆祝", "喜笑颜开"]}, + {"emoji": "😱", "label": "惊讶", "phrases": ["哇!好惊喜", "太意外了", "不可思议", "令人震惊"]}, + {"emoji": "😇", "label": "感恩", "phrases": ["心怀感恩", "感谢生活", "感恩遇见", "心存感激"]}, + {"emoji": "💪", "label": "加油", "phrases": ["加油加油!", "相信自己", "勇往直前", "全力以赴"]}, + {"emoji": "😘", "label": "害羞", "phrases": ["有点害羞", "不好意思啦", "脸红心跳", "羞涩一笑"]}, + {"emoji": "🤩", "label": "崇拜", "phrases": ["超级崇拜!", "偶像光芒", "闪闪发光", "心生敬意"]}, + {"emoji": "😪", "label": "困倦", "phrases": ["好困呀", "眼皮打架了", "需要小憩", "睡个好觉"]}, + {"emoji": "😋", "label": "满足", "phrases": ["心满意足", "幸福感满满", "知足常乐", "十分满足"]}, +]; + +const PAY_CHANNELS = [ + {"value": "wechat", "label": "微信", "types": [1, 2]}, + {"value": "alipay", "label": "支付宝", "types": [1, 2]}, + {"value": "unionpay", "label": "云闪付", "types": [1, 2]}, + {"value": "bank", "label": "银行卡", "types": [1, 2]}, + {"value": "cash", "label": "现金", "types": [1, 2]}, + {"value": "online_banking", "label": "网银", "types": [1, 2]}, + {"value": "pos", "label": "POS机", "types": [1, 2]}, + {"value": "other", "label": "其他", "types": [1, 2]}, + {"value": "salary", "label": "工资", "types": [2]}, + {"value": "red_packet", "label": "红包", "types": [2]}, + {"value": "transfer_in", "label": "转账", "types": [2]}, + {"value": "corporate", "label": "对公转账", "types": [2]}, + {"value": "credit", "label": "信用卡", "types": [1]}, + {"value": "huawei", "label": "花呗", "types": [1]}, + {"value": "white", "label": "白条", "types": [1]}, + {"value": "jd_pay", "label": "京东支付", "types": [1]}, + {"value": "meituan_pay", "label": "美团支付", "types": [1]}, + {"value": "gift_card", "label": "储值卡", "types": [1]}, +]; + +const CHANNEL_ICONS = { + 'wechat': '💬', + 'alipay': '🔷', + 'cash': '💵', + 'bank': '🏦', + 'credit': '💳', + 'salary': '💼', + 'red_packet': '🧧', + 'transfer': '🔄', + 'unionpay': '⚡', + 'online_banking': '🌐', + 'pos': '🖥️', + 'other': '📦', + 'huawei': '🌸', + 'white': '🤍', + 'jd_pay': '🔴', + 'meituan_pay': '🔘', + 'gift_card': '🎫', + 'corporate': '🏢', + 'transfer_in': '📥' +}; + +function getCategoryByValue(value, type) { + if (type === 'bill') { + return DEFAULT_CATE_LIST.find(c => c.value === value); + } + if (type === 'todo') { + return TODO_BASE_CATE.find(c => c.value === value); + } + return null; +} + +function getMoodByEmoji(emoji) { + return MOOD_LIST.find(m => m.emoji === emoji); +} + +function getChannelByValue(value) { + return PAY_CHANNELS.find(ch => ch.value === value); +} \ No newline at end of file diff --git a/static/js/fullcalendar.min.js b/static/js/fullcalendar.min.js new file mode 100644 index 0000000..94ea235 --- /dev/null +++ b/static/js/fullcalendar.min.js @@ -0,0 +1,6 @@ +/*! +FullCalendar Standard Bundle v6.1.15 +Docs & License: https://fullcalendar.io/docs/initialize-globals +(c) 2024 Adam Shaw +*/ +var FullCalendar=function(e){"use strict";var t,n,r,i,s,o,a,l,c,d={},u=[],h=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;function f(e,t){for(var n in t)e[n]=t[n];return e}function g(e){var t=e.parentNode;t&&t.removeChild(e)}function p(e,n,r){var i,s,o,a={};for(o in n)"key"==o?i=n[o]:"ref"==o?s=n[o]:a[o]=n[o];if(arguments.length>2&&(a.children=arguments.length>3?t.call(arguments,2):r),"function"==typeof e&&null!=e.defaultProps)for(o in e.defaultProps)void 0===a[o]&&(a[o]=e.defaultProps[o]);return m(e,a,i,s,null)}function m(e,t,i,s,o){var a={type:e,props:t,key:i,ref:s,__k:null,__:null,__b:0,__e:null,__d:void 0,__c:null,__h:null,constructor:void 0,__v:null==o?++r:o};return null==o&&null!=n.vnode&&n.vnode(a),a}function v(){return{current:null}}function y(e){return e.children}function b(e,t,n){"-"===t[0]?e.setProperty(t,null==n?"":n):e[t]=null==n?"":"number"!=typeof n||h.test(t)?n:n+"px"}function E(e,t,n,r,i){var s;e:if("style"===t)if("string"==typeof n)e.style.cssText=n;else{if("string"==typeof r&&(e.style.cssText=r=""),r)for(t in r)n&&t in n||b(e.style,t,"");if(n)for(t in n)r&&n[t]===r[t]||b(e.style,t,n[t])}else if("o"===t[0]&&"n"===t[1])s=t!==(t=t.replace(/Capture$/,"")),t=t.toLowerCase()in e?t.toLowerCase().slice(2):t.slice(2),e.l||(e.l={}),e.l[t+s]=n,n?r||e.addEventListener(t,s?A:S,s):e.removeEventListener(t,s?A:S,s);else if("dangerouslySetInnerHTML"!==t){if(i)t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if("width"!==t&&"height"!==t&&"href"!==t&&"list"!==t&&"form"!==t&&"tabIndex"!==t&&"download"!==t&&t in e)try{e[t]=null==n?"":n;break e}catch(e){}"function"==typeof n||(null==n||!1===n&&-1==t.indexOf("-")?e.removeAttribute(t):e.setAttribute(t,n))}}function S(e){s=!0;try{return this.l[e.type+!1](n.event?n.event(e):e)}finally{s=!1}}function A(e){s=!0;try{return this.l[e.type+!0](n.event?n.event(e):e)}finally{s=!1}}function D(e,t){this.props=e,this.context=t}function w(e,t){if(null==t)return e.__?w(e.__,e.__.__k.indexOf(e)+1):null;for(var n;tt&&o.sort((function(e,t){return e.__v.__b-t.__v.__b})));_.__r=0}function T(e,t,n,r,i,s,o,a,l,c){var h,f,g,p,v,b,E,S=r&&r.__k||u,A=S.length;for(n.__k=[],h=0;h0?m(p.type,p.props,p.key,p.ref?p.ref:null,p.__v):p)){if(p.__=n,p.__b=n.__b+1,null===(g=S[h])||g&&p.key==g.key&&p.type===g.type)S[h]=void 0;else for(f=0;f=0;t--)if((n=e.__k[t])&&(r=O(n)))return r;return null}function N(e,t,r,i,s,o,a,l,c){var d,u,h,g,p,m,v,b,E,S,A,w,C,R,x,_=t.type;if(void 0!==t.constructor)return null;null!=r.__h&&(c=r.__h,l=t.__e=r.__e,t.__h=null,o=[l]),(d=n.__b)&&d(t);try{e:if("function"==typeof _){if(b=t.props,E=(d=_.contextType)&&i[d.__c],S=d?E?E.props.value:d.__:i,r.__c?v=(u=t.__c=r.__c).__=u.__E:("prototype"in _&&_.prototype.render?t.__c=u=new _(b,S):(t.__c=u=new D(b,S),u.constructor=_,u.render=z),E&&E.sub(u),u.props=b,u.state||(u.state={}),u.context=S,u.__n=i,h=u.__d=!0,u.__h=[],u._sb=[]),null==u.__s&&(u.__s=u.state),null!=_.getDerivedStateFromProps&&(u.__s==u.state&&(u.__s=f({},u.__s)),f(u.__s,_.getDerivedStateFromProps(b,u.__s))),g=u.props,p=u.state,u.__v=t,h)null==_.getDerivedStateFromProps&&null!=u.componentWillMount&&u.componentWillMount(),null!=u.componentDidMount&&u.__h.push(u.componentDidMount);else{if(null==_.getDerivedStateFromProps&&b!==g&&null!=u.componentWillReceiveProps&&u.componentWillReceiveProps(b,S),!u.__e&&null!=u.shouldComponentUpdate&&!1===u.shouldComponentUpdate(b,u.__s,S)||t.__v===r.__v){for(t.__v!==r.__v&&(u.props=b,u.state=u.__s,u.__d=!1),t.__e=r.__e,t.__k=r.__k,t.__k.forEach((function(e){e&&(e.__=t)})),A=0;A3;)n.pop()();if(n[1]>>1,1),t.i.removeChild(e)}}),U(p(ue,{context:t.context},e.__v),t.l)):t.l&&t.componentWillUnmount()}function fe(e,t){var n=p(he,{__v:e,i:t});return n.containerInfo=t,n}(ce.prototype=new D).__a=function(e){var t=this,n=le(t.__v),r=t.o.get(e);return r[0]++,function(i){var s=function(){t.props.revealOrder?(r.push(i),de(t,e,r)):i()};n?n(s):s()}},ce.prototype.render=function(e){this.u=null,this.o=new Map;var t=M(e.children);e.revealOrder&&"b"===e.revealOrder[0]&&t.reverse();for(var n=t.length;n--;)this.o.set(t[n],this.u=[1,0,this.u]);return e.children},ce.prototype.componentDidUpdate=ce.prototype.componentDidMount=function(){var e=this;this.o.forEach((function(t,n){de(e,n,t)}))};var ge="undefined"!=typeof Symbol&&Symbol.for&&Symbol.for("react.element")||60103,pe=/^(?:accent|alignment|arabic|baseline|cap|clip(?!PathU)|color|dominant|fill|flood|font|glyph(?!R)|horiz|image|letter|lighting|marker(?!H|W|U)|overline|paint|pointer|shape|stop|strikethrough|stroke|text(?!L)|transform|underline|unicode|units|v|vector|vert|word|writing|x(?!C))[A-Z]/,me="undefined"!=typeof document,ve=function(e){return("undefined"!=typeof Symbol&&"symbol"==typeof Symbol()?/fil|che|rad/i:/fil|che|ra/i).test(e)};D.prototype.isReactComponent={},["componentWillMount","componentWillReceiveProps","componentWillUpdate"].forEach((function(e){Object.defineProperty(D.prototype,e,{configurable:!0,get:function(){return this["UNSAFE_"+e]},set:function(t){Object.defineProperty(this,e,{configurable:!0,writable:!0,value:t})}})}));var ye=n.event;function be(){}function Ee(){return this.cancelBubble}function Se(){return this.defaultPrevented}n.event=function(e){return ye&&(e=ye(e)),e.persist=be,e.isPropagationStopped=Ee,e.isDefaultPrevented=Se,e.nativeEvent=e};var Ae={configurable:!0,get:function(){return this.class}},De=n.vnode;n.vnode=function(e){var t=e.type,n=e.props,r=n;if("string"==typeof t){var i=-1===t.indexOf("-");for(var s in r={},n){var o=n[s];me&&"children"===s&&"noscript"===t||"value"===s&&"defaultValue"in n&&null==o||("defaultValue"===s&&"value"in n&&null==n.value?s="value":"download"===s&&!0===o?o="":/ondoubleclick/i.test(s)?s="ondblclick":/^onchange(textarea|input)/i.test(s+t)&&!ve(n.type)?s="oninput":/^onfocus$/i.test(s)?s="onfocusin":/^onblur$/i.test(s)?s="onfocusout":/^on(Ani|Tra|Tou|BeforeInp|Compo)/.test(s)?s=s.toLowerCase():i&&pe.test(s)?s=s.replace(/[A-Z0-9]/g,"-$&").toLowerCase():null===o&&(o=void 0),/^oninput$/i.test(s)&&(s=s.toLowerCase(),r[s]&&(s="oninputCapture")),r[s]=o)}"select"==t&&r.multiple&&Array.isArray(r.value)&&(r.value=M(n.children).forEach((function(e){e.props.selected=-1!=r.value.indexOf(e.props.value)}))),"select"==t&&null!=r.defaultValue&&(r.value=M(n.children).forEach((function(e){e.props.selected=r.multiple?-1!=r.defaultValue.indexOf(e.props.value):r.defaultValue==e.props.value}))),e.props=r,n.class!=n.className&&(Ae.enumerable="className"in n,null!=n.className&&(r.class=n.className),Object.defineProperty(r,"className",Ae))}e.$$typeof=ge,De&&De(e)};var we=n.__r;n.__r=function(e){we&&we(e),e.__c};const Ce=[],Re=new Map;function xe(e){Ce.push(e),Re.forEach(t=>{Te(t,e)})}function _e(e){let t=Re.get(e);if(!t||!t.isConnected){if(t=e.querySelector("style[data-fullcalendar]"),!t){t=document.createElement("style"),t.setAttribute("data-fullcalendar","");const n=function(){void 0===ke&&(ke=function(){const e=document.querySelector('meta[name="csp-nonce"]');if(e&&e.hasAttribute("content"))return e.getAttribute("content");const t=document.querySelector("script[nonce]");if(t)return t.nonce||"";return""}());return ke}();n&&(t.nonce=n);const r=e===document?document.head:e,i=e===document?r.querySelector("script,link[rel=stylesheet],link[as=style],style"):r.firstChild;r.insertBefore(t,i)}Re.set(e,t),function(e){for(const t of Ce)Te(e,t)}(t)}}function Te(e,t){const{sheet:n}=e,r=n.cssRules.length;t.split("}").forEach((e,t)=>{(e=e.trim())&&n.insertRule(e+"}",r+t)})}let ke;"undefined"!=typeof document&&_e(document);xe(':root{--fc-small-font-size:.85em;--fc-page-bg-color:#fff;--fc-neutral-bg-color:hsla(0,0%,82%,.3);--fc-neutral-text-color:grey;--fc-border-color:#ddd;--fc-button-text-color:#fff;--fc-button-bg-color:#2c3e50;--fc-button-border-color:#2c3e50;--fc-button-hover-bg-color:#1e2b37;--fc-button-hover-border-color:#1a252f;--fc-button-active-bg-color:#1a252f;--fc-button-active-border-color:#151e27;--fc-event-bg-color:#3788d8;--fc-event-border-color:#3788d8;--fc-event-text-color:#fff;--fc-event-selected-overlay-color:rgba(0,0,0,.25);--fc-more-link-bg-color:#d0d0d0;--fc-more-link-text-color:inherit;--fc-event-resizer-thickness:8px;--fc-event-resizer-dot-total-width:8px;--fc-event-resizer-dot-border-width:1px;--fc-non-business-color:hsla(0,0%,84%,.3);--fc-bg-event-color:#8fdf82;--fc-bg-event-opacity:0.3;--fc-highlight-color:rgba(188,232,241,.3);--fc-today-bg-color:rgba(255,220,40,.15);--fc-now-indicator-color:red}.fc-not-allowed,.fc-not-allowed .fc-event{cursor:not-allowed}.fc{display:flex;flex-direction:column;font-size:1em}.fc,.fc *,.fc :after,.fc :before{box-sizing:border-box}.fc table{border-collapse:collapse;border-spacing:0;font-size:1em}.fc th{text-align:center}.fc td,.fc th{padding:0;vertical-align:top}.fc a[data-navlink]{cursor:pointer}.fc a[data-navlink]:hover{text-decoration:underline}.fc-direction-ltr{direction:ltr;text-align:left}.fc-direction-rtl{direction:rtl;text-align:right}.fc-theme-standard td,.fc-theme-standard th{border:1px solid var(--fc-border-color)}.fc-liquid-hack td,.fc-liquid-hack th{position:relative}@font-face{font-family:fcicons;font-style:normal;font-weight:400;src:url("data:application/x-font-ttf;charset=utf-8;base64,AAEAAAALAIAAAwAwT1MvMg8SBfAAAAC8AAAAYGNtYXAXVtKNAAABHAAAAFRnYXNwAAAAEAAAAXAAAAAIZ2x5ZgYydxIAAAF4AAAFNGhlYWQUJ7cIAAAGrAAAADZoaGVhB20DzAAABuQAAAAkaG10eCIABhQAAAcIAAAALGxvY2ED4AU6AAAHNAAAABhtYXhwAA8AjAAAB0wAAAAgbmFtZXsr690AAAdsAAABhnBvc3QAAwAAAAAI9AAAACAAAwPAAZAABQAAApkCzAAAAI8CmQLMAAAB6wAzAQkAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAAAAAAAAAAAAABAAADpBgPA/8AAQAPAAEAAAAABAAAAAAAAAAAAAAAgAAAAAAADAAAAAwAAABwAAQADAAAAHAADAAEAAAAcAAQAOAAAAAoACAACAAIAAQAg6Qb//f//AAAAAAAg6QD//f//AAH/4xcEAAMAAQAAAAAAAAAAAAAAAQAB//8ADwABAAAAAAAAAAAAAgAANzkBAAAAAAEAAAAAAAAAAAACAAA3OQEAAAAAAQAAAAAAAAAAAAIAADc5AQAAAAABAWIAjQKeAskAEwAAJSc3NjQnJiIHAQYUFwEWMjc2NCcCnuLiDQ0MJAz/AA0NAQAMJAwNDcni4gwjDQwM/wANIwz/AA0NDCMNAAAAAQFiAI0CngLJABMAACUBNjQnASYiBwYUHwEHBhQXFjI3AZ4BAA0N/wAMJAwNDeLiDQ0MJAyNAQAMIw0BAAwMDSMM4uINIwwNDQAAAAIA4gC3Ax4CngATACcAACUnNzY0JyYiDwEGFB8BFjI3NjQnISc3NjQnJiIPAQYUHwEWMjc2NCcB87e3DQ0MIw3VDQ3VDSMMDQ0BK7e3DQ0MJAzVDQ3VDCQMDQ3zuLcMJAwNDdUNIwzWDAwNIwy4twwkDA0N1Q0jDNYMDA0jDAAAAgDiALcDHgKeABMAJwAAJTc2NC8BJiIHBhQfAQcGFBcWMjchNzY0LwEmIgcGFB8BBwYUFxYyNwJJ1Q0N1Q0jDA0Nt7cNDQwjDf7V1Q0N1QwkDA0Nt7cNDQwkDLfWDCMN1Q0NDCQMt7gMIw0MDNYMIw3VDQ0MJAy3uAwjDQwMAAADAFUAAAOrA1UAMwBoAHcAABMiBgcOAQcOAQcOARURFBYXHgEXHgEXHgEzITI2Nz4BNz4BNz4BNRE0JicuAScuAScuASMFITIWFx4BFx4BFx4BFREUBgcOAQcOAQcOASMhIiYnLgEnLgEnLgE1ETQ2Nz4BNz4BNz4BMxMhMjY1NCYjISIGFRQWM9UNGAwLFQkJDgUFBQUFBQ4JCRULDBgNAlYNGAwLFQkJDgUFBQUFBQ4JCRULDBgN/aoCVgQIBAQHAwMFAQIBAQIBBQMDBwQECAT9qgQIBAQHAwMFAQIBAQIBBQMDBwQECASAAVYRGRkR/qoRGRkRA1UFBAUOCQkVDAsZDf2rDRkLDBUJCA4FBQUFBQUOCQgVDAsZDQJVDRkLDBUJCQ4FBAVVAgECBQMCBwQECAX9qwQJAwQHAwMFAQICAgIBBQMDBwQDCQQCVQUIBAQHAgMFAgEC/oAZEhEZGRESGQAAAAADAFUAAAOrA1UAMwBoAIkAABMiBgcOAQcOAQcOARURFBYXHgEXHgEXHgEzITI2Nz4BNz4BNz4BNRE0JicuAScuAScuASMFITIWFx4BFx4BFx4BFREUBgcOAQcOAQcOASMhIiYnLgEnLgEnLgE1ETQ2Nz4BNz4BNz4BMxMzFRQWMzI2PQEzMjY1NCYrATU0JiMiBh0BIyIGFRQWM9UNGAwLFQkJDgUFBQUFBQ4JCRULDBgNAlYNGAwLFQkJDgUFBQUFBQ4JCRULDBgN/aoCVgQIBAQHAwMFAQIBAQIBBQMDBwQECAT9qgQIBAQHAwMFAQIBAQIBBQMDBwQECASAgBkSEhmAERkZEYAZEhIZgBEZGREDVQUEBQ4JCRUMCxkN/asNGQsMFQkIDgUFBQUFBQ4JCBUMCxkNAlUNGQsMFQkJDgUEBVUCAQIFAwIHBAQIBf2rBAkDBAcDAwUBAgICAgEFAwMHBAMJBAJVBQgEBAcCAwUCAQL+gIASGRkSgBkSERmAEhkZEoAZERIZAAABAOIAjQMeAskAIAAAExcHBhQXFjI/ARcWMjc2NC8BNzY0JyYiDwEnJiIHBhQX4uLiDQ0MJAzi4gwkDA0N4uINDQwkDOLiDCQMDQ0CjeLiDSMMDQ3h4Q0NDCMN4uIMIw0MDOLiDAwNIwwAAAABAAAAAQAAa5n0y18PPPUACwQAAAAAANivOVsAAAAA2K85WwAAAAADqwNVAAAACAACAAAAAAAAAAEAAAPA/8AAAAQAAAAAAAOrAAEAAAAAAAAAAAAAAAAAAAALBAAAAAAAAAAAAAAAAgAAAAQAAWIEAAFiBAAA4gQAAOIEAABVBAAAVQQAAOIAAAAAAAoAFAAeAEQAagCqAOoBngJkApoAAQAAAAsAigADAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAA4ArgABAAAAAAABAAcAAAABAAAAAAACAAcAYAABAAAAAAADAAcANgABAAAAAAAEAAcAdQABAAAAAAAFAAsAFQABAAAAAAAGAAcASwABAAAAAAAKABoAigADAAEECQABAA4ABwADAAEECQACAA4AZwADAAEECQADAA4APQADAAEECQAEAA4AfAADAAEECQAFABYAIAADAAEECQAGAA4AUgADAAEECQAKADQApGZjaWNvbnMAZgBjAGkAYwBvAG4Ac1ZlcnNpb24gMS4wAFYAZQByAHMAaQBvAG4AIAAxAC4AMGZjaWNvbnMAZgBjAGkAYwBvAG4Ac2ZjaWNvbnMAZgBjAGkAYwBvAG4Ac1JlZ3VsYXIAUgBlAGcAdQBsAGEAcmZjaWNvbnMAZgBjAGkAYwBvAG4Ac0ZvbnQgZ2VuZXJhdGVkIGJ5IEljb01vb24uAEYAbwBuAHQAIABnAGUAbgBlAHIAYQB0AGUAZAAgAGIAeQAgAEkAYwBvAE0AbwBvAG4ALgAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") format("truetype")}.fc-icon{speak:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;display:inline-block;font-family:fcicons!important;font-style:normal;font-variant:normal;font-weight:400;height:1em;line-height:1;text-align:center;text-transform:none;-moz-user-select:none;user-select:none;width:1em}.fc-icon-chevron-left:before{content:"\\e900"}.fc-icon-chevron-right:before{content:"\\e901"}.fc-icon-chevrons-left:before{content:"\\e902"}.fc-icon-chevrons-right:before{content:"\\e903"}.fc-icon-minus-square:before{content:"\\e904"}.fc-icon-plus-square:before{content:"\\e905"}.fc-icon-x:before{content:"\\e906"}.fc .fc-button{border-radius:0;font-family:inherit;font-size:inherit;line-height:inherit;margin:0;overflow:visible;text-transform:none}.fc .fc-button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}.fc .fc-button{-webkit-appearance:button}.fc .fc-button:not(:disabled){cursor:pointer}.fc .fc-button{background-color:transparent;border:1px solid transparent;border-radius:.25em;display:inline-block;font-size:1em;font-weight:400;line-height:1.5;padding:.4em .65em;text-align:center;-moz-user-select:none;user-select:none;vertical-align:middle}.fc .fc-button:hover{text-decoration:none}.fc .fc-button:focus{box-shadow:0 0 0 .2rem rgba(44,62,80,.25);outline:0}.fc .fc-button:disabled{opacity:.65}.fc .fc-button-primary{background-color:var(--fc-button-bg-color);border-color:var(--fc-button-border-color);color:var(--fc-button-text-color)}.fc .fc-button-primary:hover{background-color:var(--fc-button-hover-bg-color);border-color:var(--fc-button-hover-border-color);color:var(--fc-button-text-color)}.fc .fc-button-primary:disabled{background-color:var(--fc-button-bg-color);border-color:var(--fc-button-border-color);color:var(--fc-button-text-color)}.fc .fc-button-primary:focus{box-shadow:0 0 0 .2rem rgba(76,91,106,.5)}.fc .fc-button-primary:not(:disabled).fc-button-active,.fc .fc-button-primary:not(:disabled):active{background-color:var(--fc-button-active-bg-color);border-color:var(--fc-button-active-border-color);color:var(--fc-button-text-color)}.fc .fc-button-primary:not(:disabled).fc-button-active:focus,.fc .fc-button-primary:not(:disabled):active:focus{box-shadow:0 0 0 .2rem rgba(76,91,106,.5)}.fc .fc-button .fc-icon{font-size:1.5em;vertical-align:middle}.fc .fc-button-group{display:inline-flex;position:relative;vertical-align:middle}.fc .fc-button-group>.fc-button{flex:1 1 auto;position:relative}.fc .fc-button-group>.fc-button.fc-button-active,.fc .fc-button-group>.fc-button:active,.fc .fc-button-group>.fc-button:focus,.fc .fc-button-group>.fc-button:hover{z-index:1}.fc-direction-ltr .fc-button-group>.fc-button:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0;margin-left:-1px}.fc-direction-ltr .fc-button-group>.fc-button:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.fc-direction-rtl .fc-button-group>.fc-button:not(:first-child){border-bottom-right-radius:0;border-top-right-radius:0;margin-right:-1px}.fc-direction-rtl .fc-button-group>.fc-button:not(:last-child){border-bottom-left-radius:0;border-top-left-radius:0}.fc .fc-toolbar{align-items:center;display:flex;justify-content:space-between}.fc .fc-toolbar.fc-header-toolbar{margin-bottom:1.5em}.fc .fc-toolbar.fc-footer-toolbar{margin-top:1.5em}.fc .fc-toolbar-title{font-size:1.75em;margin:0}.fc-direction-ltr .fc-toolbar>*>:not(:first-child){margin-left:.75em}.fc-direction-rtl .fc-toolbar>*>:not(:first-child){margin-right:.75em}.fc-direction-rtl .fc-toolbar-ltr{flex-direction:row-reverse}.fc .fc-scroller{-webkit-overflow-scrolling:touch;position:relative}.fc .fc-scroller-liquid{height:100%}.fc .fc-scroller-liquid-absolute{bottom:0;left:0;position:absolute;right:0;top:0}.fc .fc-scroller-harness{direction:ltr;overflow:hidden;position:relative}.fc .fc-scroller-harness-liquid{height:100%}.fc-direction-rtl .fc-scroller-harness>.fc-scroller{direction:rtl}.fc-theme-standard .fc-scrollgrid{border:1px solid var(--fc-border-color)}.fc .fc-scrollgrid,.fc .fc-scrollgrid table{table-layout:fixed;width:100%}.fc .fc-scrollgrid table{border-left-style:hidden;border-right-style:hidden;border-top-style:hidden}.fc .fc-scrollgrid{border-bottom-width:0;border-collapse:separate;border-right-width:0}.fc .fc-scrollgrid-liquid{height:100%}.fc .fc-scrollgrid-section,.fc .fc-scrollgrid-section table,.fc .fc-scrollgrid-section>td{height:1px}.fc .fc-scrollgrid-section-liquid>td{height:100%}.fc .fc-scrollgrid-section>*{border-left-width:0;border-top-width:0}.fc .fc-scrollgrid-section-footer>*,.fc .fc-scrollgrid-section-header>*{border-bottom-width:0}.fc .fc-scrollgrid-section-body table,.fc .fc-scrollgrid-section-footer table{border-bottom-style:hidden}.fc .fc-scrollgrid-section-sticky>*{background:var(--fc-page-bg-color);position:sticky;z-index:3}.fc .fc-scrollgrid-section-header.fc-scrollgrid-section-sticky>*{top:0}.fc .fc-scrollgrid-section-footer.fc-scrollgrid-section-sticky>*{bottom:0}.fc .fc-scrollgrid-sticky-shim{height:1px;margin-bottom:-1px}.fc-sticky{position:sticky}.fc .fc-view-harness{flex-grow:1;position:relative}.fc .fc-view-harness-active>.fc-view{bottom:0;left:0;position:absolute;right:0;top:0}.fc .fc-col-header-cell-cushion{display:inline-block;padding:2px 4px}.fc .fc-bg-event,.fc .fc-highlight,.fc .fc-non-business{bottom:0;left:0;position:absolute;right:0;top:0}.fc .fc-non-business{background:var(--fc-non-business-color)}.fc .fc-bg-event{background:var(--fc-bg-event-color);opacity:var(--fc-bg-event-opacity)}.fc .fc-bg-event .fc-event-title{font-size:var(--fc-small-font-size);font-style:italic;margin:.5em}.fc .fc-highlight{background:var(--fc-highlight-color)}.fc .fc-cell-shaded,.fc .fc-day-disabled{background:var(--fc-neutral-bg-color)}a.fc-event,a.fc-event:hover{text-decoration:none}.fc-event.fc-event-draggable,.fc-event[href]{cursor:pointer}.fc-event .fc-event-main{position:relative;z-index:2}.fc-event-dragging:not(.fc-event-selected){opacity:.75}.fc-event-dragging.fc-event-selected{box-shadow:0 2px 7px rgba(0,0,0,.3)}.fc-event .fc-event-resizer{display:none;position:absolute;z-index:4}.fc-event-selected .fc-event-resizer,.fc-event:hover .fc-event-resizer{display:block}.fc-event-selected .fc-event-resizer{background:var(--fc-page-bg-color);border-color:inherit;border-radius:calc(var(--fc-event-resizer-dot-total-width)/2);border-style:solid;border-width:var(--fc-event-resizer-dot-border-width);height:var(--fc-event-resizer-dot-total-width);width:var(--fc-event-resizer-dot-total-width)}.fc-event-selected .fc-event-resizer:before{bottom:-20px;content:"";left:-20px;position:absolute;right:-20px;top:-20px}.fc-event-selected,.fc-event:focus{box-shadow:0 2px 5px rgba(0,0,0,.2)}.fc-event-selected:before,.fc-event:focus:before{bottom:0;content:"";left:0;position:absolute;right:0;top:0;z-index:3}.fc-event-selected:after,.fc-event:focus:after{background:var(--fc-event-selected-overlay-color);bottom:-1px;content:"";left:-1px;position:absolute;right:-1px;top:-1px;z-index:1}.fc-h-event{background-color:var(--fc-event-bg-color);border:1px solid var(--fc-event-border-color);display:block}.fc-h-event .fc-event-main{color:var(--fc-event-text-color)}.fc-h-event .fc-event-main-frame{display:flex}.fc-h-event .fc-event-time{max-width:100%;overflow:hidden}.fc-h-event .fc-event-title-container{flex-grow:1;flex-shrink:1;min-width:0}.fc-h-event .fc-event-title{display:inline-block;left:0;max-width:100%;overflow:hidden;right:0;vertical-align:top}.fc-h-event.fc-event-selected:before{bottom:-10px;top:-10px}.fc-direction-ltr .fc-daygrid-block-event:not(.fc-event-start),.fc-direction-rtl .fc-daygrid-block-event:not(.fc-event-end){border-bottom-left-radius:0;border-left-width:0;border-top-left-radius:0}.fc-direction-ltr .fc-daygrid-block-event:not(.fc-event-end),.fc-direction-rtl .fc-daygrid-block-event:not(.fc-event-start){border-bottom-right-radius:0;border-right-width:0;border-top-right-radius:0}.fc-h-event:not(.fc-event-selected) .fc-event-resizer{bottom:0;top:0;width:var(--fc-event-resizer-thickness)}.fc-direction-ltr .fc-h-event:not(.fc-event-selected) .fc-event-resizer-start,.fc-direction-rtl .fc-h-event:not(.fc-event-selected) .fc-event-resizer-end{cursor:w-resize;left:calc(var(--fc-event-resizer-thickness)*-.5)}.fc-direction-ltr .fc-h-event:not(.fc-event-selected) .fc-event-resizer-end,.fc-direction-rtl .fc-h-event:not(.fc-event-selected) .fc-event-resizer-start{cursor:e-resize;right:calc(var(--fc-event-resizer-thickness)*-.5)}.fc-h-event.fc-event-selected .fc-event-resizer{margin-top:calc(var(--fc-event-resizer-dot-total-width)*-.5);top:50%}.fc-direction-ltr .fc-h-event.fc-event-selected .fc-event-resizer-start,.fc-direction-rtl .fc-h-event.fc-event-selected .fc-event-resizer-end{left:calc(var(--fc-event-resizer-dot-total-width)*-.5)}.fc-direction-ltr .fc-h-event.fc-event-selected .fc-event-resizer-end,.fc-direction-rtl .fc-h-event.fc-event-selected .fc-event-resizer-start{right:calc(var(--fc-event-resizer-dot-total-width)*-.5)}.fc .fc-popover{box-shadow:0 2px 6px rgba(0,0,0,.15);position:absolute;z-index:9999}.fc .fc-popover-header{align-items:center;display:flex;flex-direction:row;justify-content:space-between;padding:3px 4px}.fc .fc-popover-title{margin:0 2px}.fc .fc-popover-close{cursor:pointer;font-size:1.1em;opacity:.65}.fc-theme-standard .fc-popover{background:var(--fc-page-bg-color);border:1px solid var(--fc-border-color)}.fc-theme-standard .fc-popover-header{background:var(--fc-neutral-bg-color)}');class Me{constructor(e){this.drainedOption=e,this.isRunning=!1,this.isDirty=!1,this.pauseDepths={},this.timeoutId=0}request(e){this.isDirty=!0,this.isPaused()||(this.clearTimeout(),null==e?this.tryDrain():this.timeoutId=setTimeout(this.tryDrain.bind(this),e))}pause(e=""){let{pauseDepths:t}=this;t[e]=(t[e]||0)+1,this.clearTimeout()}resume(e="",t){let{pauseDepths:n}=this;if(e in n){if(t)delete n[e];else{n[e]-=1,n[e]<=0&&delete n[e]}this.tryDrain()}}isPaused(){return Object.keys(this.pauseDepths).length}tryDrain(){if(!this.isRunning&&!this.isPaused()){for(this.isRunning=!0;this.isDirty;)this.isDirty=!1,this.drained();this.isRunning=!1}}clear(){this.clearTimeout(),this.isDirty=!1,this.pauseDepths={}}clearTimeout(){this.timeoutId&&(clearTimeout(this.timeoutId),this.timeoutId=0)}drained(){this.drainedOption&&this.drainedOption()}}function Ie(e){e.parentNode&&e.parentNode.removeChild(e)}function Oe(e,t){if(e.closest)return e.closest(t);if(!document.documentElement.contains(e))return null;do{if(Ne(e,t))return e;e=e.parentElement||e.parentNode}while(null!==e&&1===e.nodeType);return null}function Ne(e,t){return(e.matches||e.matchesSelector||e.msMatchesSelector).call(e,t)}function Pe(e,t){let n=e instanceof HTMLElement?[e]:e,r=[];for(let e=0;e{let r=Oe(n.target,e);r&&t.call(r,n,r)}}(n,r);return e.addEventListener(t,i),()=>{e.removeEventListener(t,i)}}const Ve=["webkitTransitionEnd","otransitionend","oTransitionEnd","msTransitionEnd","transitionend"];function Ge(e,t){let n=r=>{t(r),Ve.forEach(t=>{e.removeEventListener(t,n)})};Ve.forEach(t=>{e.addEventListener(t,n)})}function Qe(e){return Object.assign({onClick:e},qe(e))}function qe(e){return{tabIndex:0,onKeyDown(t){"Enter"!==t.key&&" "!==t.key||(e(t),t.preventDefault())}}}let Ye=0;function Ze(){return Ye+=1,String(Ye)}function Xe(){document.body.classList.add("fc-not-allowed")}function $e(){document.body.classList.remove("fc-not-allowed")}function Je(e){e.style.userSelect="none",e.style.webkitUserSelect="none",e.addEventListener("selectstart",Le)}function Ke(e){e.style.userSelect="",e.style.webkitUserSelect="",e.removeEventListener("selectstart",Le)}function et(e){e.addEventListener("contextmenu",Le)}function tt(e){e.removeEventListener("contextmenu",Le)}function nt(e){let t,n,r=[],i=[];for("string"==typeof e?i=e.split(/\s*,\s*/):"function"==typeof e?i=[e]:Array.isArray(e)&&(i=e),t=0;te.replace("$"+n,t||""),e):n}function lt(e,t){return e-t}function ct(e){return e%1==0}function dt(e){let t=e.querySelector(".fc-scrollgrid-shrink-frame"),n=e.querySelector(".fc-scrollgrid-shrink-cushion");if(!t)throw new Error("needs fc-scrollgrid-shrink-frame className");if(!n)throw new Error("needs fc-scrollgrid-shrink-cushion className");return e.getBoundingClientRect().width-t.getBoundingClientRect().width+n.getBoundingClientRect().width}const ut=["years","months","days","milliseconds"],ht=/^(-?)(?:(\d+)\.)?(\d+):(\d\d)(?::(\d\d)(?:\.(\d\d\d))?)?/;function ft(e,t){return"string"==typeof e?function(e){let t=ht.exec(e);if(t){let e=t[1]?-1:1;return{years:0,months:0,days:e*(t[2]?parseInt(t[2],10):0),milliseconds:e*(60*(t[3]?parseInt(t[3],10):0)*60*1e3+60*(t[4]?parseInt(t[4],10):0)*1e3+1e3*(t[5]?parseInt(t[5],10):0)+(t[6]?parseInt(t[6],10):0))}}return null}(e):"object"==typeof e&&e?gt(e):"number"==typeof e?gt({[t||"milliseconds"]:e}):null}function gt(e){let t={years:e.years||e.year||0,months:e.months||e.month||0,days:e.days||e.day||0,milliseconds:60*(e.hours||e.hour||0)*60*1e3+60*(e.minutes||e.minute||0)*1e3+1e3*(e.seconds||e.second||0)+(e.milliseconds||e.millisecond||e.ms||0)},n=e.weeks||e.week;return n&&(t.days+=7*n,t.specifiedWeeks=!0),t}function pt(e,t){return{years:e.years+t.years,months:e.months+t.months,days:e.days+t.days,milliseconds:e.milliseconds+t.milliseconds}}function mt(e,t){return{years:e.years*t,months:e.months*t,days:e.days*t,milliseconds:e.milliseconds*t}}function vt(e){return yt(e)/864e5}function yt(e){return 31536e6*e.years+2592e6*e.months+864e5*e.days+e.milliseconds}function bt(e,t){let n=null;for(let r=0;r10&&(null==t?r=r.replace("Z",""):0!==t&&(r=r.replace("Z",Vt(t,!0)))),r}function Wt(e){return e.toISOString().replace(/T.*$/,"")}function Lt(e){return e.toISOString().match(/^\d{4}-\d{2}/)[0]}function Ft(e){return ot(e.getUTCHours(),2)+":"+ot(e.getUTCMinutes(),2)+":"+ot(e.getUTCSeconds(),2)}function Vt(e,t=!1){let n=e<0?"-":"+",r=Math.abs(e),i=Math.floor(r/60),s=Math.round(r%60);return t?`${n+ot(i,2)}:${ot(s,2)}`:`GMT${n}${i}${s?":"+ot(s,2):""}`}function Gt(e,t,n){let r,i;return function(...s){if(r){if(!St(r,s)){n&&n(i);let r=e.apply(this,s);t&&t(r,i)||(i=r)}}else i=e.apply(this,s);return r=s,i}}function Qt(e,t,n){let r,i;return s=>{if(r){if(!Cn(r,s)){n&&n(i);let r=e.call(this,s);t&&t(r,i)||(i=r)}}else i=e.call(this,s);return r=s,i}}const qt={week:3,separator:0,omitZeroMinute:0,meridiem:0,omitCommas:0},Yt={timeZoneName:7,era:6,year:5,month:4,day:2,weekday:2,hour:1,minute:1,second:1},Zt=/\s*([ap])\.?m\.?/i,Xt=/,/g,$t=/\s+/g,Jt=/\u200e/g,Kt=/UTC|GMT/;class en{constructor(e){let t={},n={},r=0;for(let i in e)i in qt?(n[i]=e[i],r=Math.max(qt[i],r)):(t[i]=e[i],i in Yt&&(r=Math.max(Yt[i],r)));this.standardDateProps=t,this.extendedSettings=n,this.severity=r,this.buildFormattingFunc=Gt(tn)}format(e,t){return this.buildFormattingFunc(this.standardDateProps,this.extendedSettings,t)(e)}formatRange(e,t,n,r){let{standardDateProps:i,extendedSettings:s}=this,o=function(e,t,n){if(n.getMarkerYear(e)!==n.getMarkerYear(t))return 5;if(n.getMarkerMonth(e)!==n.getMarkerMonth(t))return 4;if(n.getMarkerDay(e)!==n.getMarkerDay(t))return 2;if(zt(e)!==zt(t))return 1;return 0}(e.marker,t.marker,n.calendarSystem);if(!o)return this.format(e,n);let a=o;!(a>1)||"numeric"!==i.year&&"2-digit"!==i.year||"numeric"!==i.month&&"2-digit"!==i.month||"numeric"!==i.day&&"2-digit"!==i.day||(a=1);let l=this.format(e,n),c=this.format(t,n);if(l===c)return l;let d=tn(function(e,t){let n={};for(let r in e)(!(r in Yt)||Yt[r]<=t)&&(n[r]=e[r]);return n}(i,a),s,n),u=d(e),h=d(t),f=function(e,t,n,r){let i=0;for(;iVt(e.timeZoneOffset):0===r&&t.week?e=>function(e,t,n,r,i){let s=[];"long"===i?s.push(n):"short"!==i&&"narrow"!==i||s.push(t);"long"!==i&&"short"!==i||s.push(" ");s.push(r.simpleNumberFormat.format(e)),"rtl"===r.options.direction&&s.reverse();return s.join("")}(n.computeWeekNumber(e.marker),n.weekText,n.weekTextLong,n.locale,t.week):function(e,t,n){e=Object.assign({},e),t=Object.assign({},t),function(e,t){e.timeZoneName&&(e.hour||(e.hour="2-digit"),e.minute||(e.minute="2-digit"));"long"===e.timeZoneName&&(e.timeZoneName="short");t.omitZeroMinute&&(e.second||e.millisecond)&&delete t.omitZeroMinute}(e,t),e.timeZone="UTC";let r,i=new Intl.DateTimeFormat(n.locale.codes,e);if(t.omitZeroMinute){let t=Object.assign({},e);delete t.minute,r=new Intl.DateTimeFormat(n.locale.codes,t)}return s=>{let o,{marker:a}=s;return o=r&&!a.getUTCMinutes()?r:i,function(e,t,n,r,i){e=e.replace(Jt,""),"short"===n.timeZoneName&&(e=function(e,t){let n=!1;e=e.replace(Kt,()=>(n=!0,t)),n||(e+=" "+t);return e}(e,"UTC"===i.timeZone||null==t.timeZoneOffset?"UTC":Vt(t.timeZoneOffset)));r.omitCommas&&(e=e.replace(Xt,"").trim());r.omitZeroMinute&&(e=e.replace(":00",""));!1===r.meridiem?e=e.replace(Zt,"").trim():"narrow"===r.meridiem?e=e.replace(Zt,(e,t)=>t.toLocaleLowerCase()):"short"===r.meridiem?e=e.replace(Zt,(e,t)=>t.toLocaleLowerCase()+"m"):"lowercase"===r.meridiem&&(e=e.replace(Zt,e=>e.toLocaleLowerCase()));return e=(e=e.replace($t," ")).trim()}(o.format(a),s,e,t,n)}}(e,t,n)}function nn(e,t){let n=t.markerToArray(e.marker);return{marker:e.marker,timeZoneOffset:e.timeZoneOffset,array:n,year:n[0],month:n[1],day:n[2],hour:n[3],minute:n[4],second:n[5],millisecond:n[6]}}function rn(e,t,n,r){let i=nn(e,n.calendarSystem);return{date:i,start:i,end:t?nn(t,n.calendarSystem):null,timeZone:n.timeZone,localeCodes:n.locale.codes,defaultSeparator:r||n.defaultSeparator}}class sn{constructor(e){this.cmdStr=e}format(e,t,n){return t.cmdFormatter(this.cmdStr,rn(e,null,t,n))}formatRange(e,t,n,r){return n.cmdFormatter(this.cmdStr,rn(e,t,n,r))}}class on{constructor(e){this.func=e}format(e,t,n){return this.func(rn(e,null,t,n))}formatRange(e,t,n,r){return this.func(rn(e,t,n,r))}}function an(e){return"object"==typeof e&&e?new en(e):"string"==typeof e?new sn(e):"function"==typeof e?new on(e):null}const ln={navLinkDayClick:yn,navLinkWeekClick:yn,duration:ft,bootstrapFontAwesome:yn,buttonIcons:yn,customButtons:yn,defaultAllDayEventDuration:ft,defaultTimedEventDuration:ft,nextDayThreshold:ft,scrollTime:ft,scrollTimeReset:Boolean,slotMinTime:ft,slotMaxTime:ft,dayPopoverFormat:an,slotDuration:ft,snapDuration:ft,headerToolbar:yn,footerToolbar:yn,defaultRangeSeparator:String,titleRangeSeparator:String,forceEventDuration:Boolean,dayHeaders:Boolean,dayHeaderFormat:an,dayHeaderClassNames:yn,dayHeaderContent:yn,dayHeaderDidMount:yn,dayHeaderWillUnmount:yn,dayCellClassNames:yn,dayCellContent:yn,dayCellDidMount:yn,dayCellWillUnmount:yn,initialView:String,aspectRatio:Number,weekends:Boolean,weekNumberCalculation:yn,weekNumbers:Boolean,weekNumberClassNames:yn,weekNumberContent:yn,weekNumberDidMount:yn,weekNumberWillUnmount:yn,editable:Boolean,viewClassNames:yn,viewDidMount:yn,viewWillUnmount:yn,nowIndicator:Boolean,nowIndicatorClassNames:yn,nowIndicatorContent:yn,nowIndicatorDidMount:yn,nowIndicatorWillUnmount:yn,showNonCurrentDates:Boolean,lazyFetching:Boolean,startParam:String,endParam:String,timeZoneParam:String,timeZone:String,locales:yn,locale:yn,themeSystem:String,dragRevertDuration:Number,dragScroll:Boolean,allDayMaintainDuration:Boolean,unselectAuto:Boolean,dropAccept:yn,eventOrder:nt,eventOrderStrict:Boolean,handleWindowResize:Boolean,windowResizeDelay:Number,longPressDelay:Number,eventDragMinDistance:Number,expandRows:Boolean,height:yn,contentHeight:yn,direction:String,weekNumberFormat:an,eventResizableFromStart:Boolean,displayEventTime:Boolean,displayEventEnd:Boolean,weekText:String,weekTextLong:String,progressiveEventRendering:Boolean,businessHours:yn,initialDate:yn,now:yn,eventDataTransform:yn,stickyHeaderDates:yn,stickyFooterScrollbar:yn,viewHeight:yn,defaultAllDay:Boolean,eventSourceFailure:yn,eventSourceSuccess:yn,eventDisplay:String,eventStartEditable:Boolean,eventDurationEditable:Boolean,eventOverlap:yn,eventConstraint:yn,eventAllow:yn,eventBackgroundColor:String,eventBorderColor:String,eventTextColor:String,eventColor:String,eventClassNames:yn,eventContent:yn,eventDidMount:yn,eventWillUnmount:yn,selectConstraint:yn,selectOverlap:yn,selectAllow:yn,droppable:Boolean,unselectCancel:String,slotLabelFormat:yn,slotLaneClassNames:yn,slotLaneContent:yn,slotLaneDidMount:yn,slotLaneWillUnmount:yn,slotLabelClassNames:yn,slotLabelContent:yn,slotLabelDidMount:yn,slotLabelWillUnmount:yn,dayMaxEvents:yn,dayMaxEventRows:yn,dayMinWidth:Number,slotLabelInterval:ft,allDayText:String,allDayClassNames:yn,allDayContent:yn,allDayDidMount:yn,allDayWillUnmount:yn,slotMinWidth:Number,navLinks:Boolean,eventTimeFormat:an,rerenderDelay:Number,moreLinkText:yn,moreLinkHint:yn,selectMinDistance:Number,selectable:Boolean,selectLongPressDelay:Number,eventLongPressDelay:Number,selectMirror:Boolean,eventMaxStack:Number,eventMinHeight:Number,eventMinWidth:Number,eventShortHeight:Number,slotEventOverlap:Boolean,plugins:yn,firstDay:Number,dayCount:Number,dateAlignment:String,dateIncrement:ft,hiddenDays:yn,fixedWeekCount:Boolean,validRange:yn,visibleRange:yn,titleFormat:yn,eventInteractive:Boolean,noEventsText:String,viewHint:yn,navLinkHint:yn,closeHint:String,timeHint:String,eventHint:String,moreLinkClick:yn,moreLinkClassNames:yn,moreLinkContent:yn,moreLinkDidMount:yn,moreLinkWillUnmount:yn,monthStartFormat:an,handleCustomRendering:yn,customRenderingMetaMap:yn,customRenderingReplaces:Boolean},cn={eventDisplay:"auto",defaultRangeSeparator:" - ",titleRangeSeparator:" – ",defaultTimedEventDuration:"01:00:00",defaultAllDayEventDuration:{day:1},forceEventDuration:!1,nextDayThreshold:"00:00:00",dayHeaders:!0,initialView:"",aspectRatio:1.35,headerToolbar:{start:"title",center:"",end:"today prev,next"},weekends:!0,weekNumbers:!1,weekNumberCalculation:"local",editable:!1,nowIndicator:!1,scrollTime:"06:00:00",scrollTimeReset:!0,slotMinTime:"00:00:00",slotMaxTime:"24:00:00",showNonCurrentDates:!0,lazyFetching:!0,startParam:"start",endParam:"end",timeZoneParam:"timeZone",timeZone:"local",locales:[],locale:"",themeSystem:"standard",dragRevertDuration:500,dragScroll:!0,allDayMaintainDuration:!1,unselectAuto:!0,dropAccept:"*",eventOrder:"start,-duration,allDay,title",dayPopoverFormat:{month:"long",day:"numeric",year:"numeric"},handleWindowResize:!0,windowResizeDelay:100,longPressDelay:1e3,eventDragMinDistance:5,expandRows:!1,navLinks:!1,selectable:!1,eventMinHeight:15,eventMinWidth:30,eventShortHeight:30,monthStartFormat:{month:"long",day:"numeric"}},dn={datesSet:yn,eventsSet:yn,eventAdd:yn,eventChange:yn,eventRemove:yn,windowResize:yn,eventClick:yn,eventMouseEnter:yn,eventMouseLeave:yn,select:yn,unselect:yn,loading:yn,_unmount:yn,_beforeprint:yn,_afterprint:yn,_noEventDrop:yn,_noEventResize:yn,_resize:yn,_scrollRequest:yn},un={buttonText:yn,buttonHints:yn,views:yn,plugins:yn,initialEvents:yn,events:yn,eventSources:yn},hn={headerToolbar:fn,footerToolbar:fn,buttonText:fn,buttonHints:fn,buttonIcons:fn,dateIncrement:fn,plugins:gn,events:gn,eventSources:gn,resources:gn};function fn(e,t){return"object"==typeof e&&"object"==typeof t&&e&&t?Cn(e,t):e===t}function gn(e,t){return Array.isArray(e)&&Array.isArray(t)?St(e,t):e===t}const pn={type:String,component:yn,buttonText:String,buttonTextKey:String,dateProfileGeneratorClass:yn,usesMinMaxTime:Boolean,classNames:yn,content:yn,didMount:yn,willUnmount:yn};function mn(e){return En(e,hn)}function vn(e,t){let n={},r={};for(let r in t)r in e&&(n[r]=t[r](e[r]));for(let n in e)n in t||(r[n]=e[n]);return{refined:n,extra:r}}function yn(e){return e}const{hasOwnProperty:bn}=Object.prototype;function En(e,t){let n={};if(t)for(let r in t)if(t[r]===fn){let t=[];for(let i=e.length-1;i>=0;i-=1){let s=e[i][r];if("object"==typeof s&&s)t.unshift(s);else if(void 0!==s){n[r]=s;break}}t.length&&(n[r]=En(t))}for(let t=e.length-1;t>=0;t-=1){let r=e[t];for(let e in r)e in n||(n[e]=r[e])}return n}function Sn(e,t){let n={};for(let r in e)t(e[r],r)&&(n[r]=e[r]);return n}function An(e,t){let n={};for(let r in e)n[r]=t(e[r],r);return n}function Dn(e){let t={};for(let n of e)t[n]=!0;return t}function wn(e){let t=[];for(let n in e)t.push(e[n]);return t}function Cn(e,t){if(e===t)return!0;for(let n in e)if(bn.call(e,n)&&!(n in t))return!1;for(let n in t)if(bn.call(t,n)&&e[n]!==t[n])return!1;return!0}const Rn=/^on[A-Z]/;function xn(e,t){let n=[];for(let r in e)bn.call(e,r)&&(r in t||n.push(r));for(let r in t)bn.call(t,r)&&e[r]!==t[r]&&n.push(r);return n}function _n(e,t,n={}){if(e===t)return!0;for(let r in t)if(!(r in e)||!Tn(e[r],t[r],n[r]))return!1;for(let n in e)if(!(n in t))return!1;return!0}function Tn(e,t,n){return e===t||!0===n||!!n&&n(e,t)}function kn(e,t=0,n,r=1){let i=[];null==n&&(n=Object.keys(e).length);for(let s=t;s=1?Math.min(i,s):i}(e,this.weekDow,this.weekDoy)}format(e,t,n={}){return t.format({marker:e,timeZoneOffset:null!=n.forcedTzo?n.forcedTzo:this.offsetForMarker(e)},this)}formatRange(e,t,n,r={}){return r.isEndExclusive&&(t=Ct(t,-1)),n.formatRange({marker:e,timeZoneOffset:null!=r.forcedStartTzo?r.forcedStartTzo:this.offsetForMarker(e)},{marker:t,timeZoneOffset:null!=r.forcedEndTzo?r.forcedEndTzo:this.offsetForMarker(t)},this,r.defaultSeparator)}formatIso(e,t={}){let n=null;return t.omitTimeZoneOffset||(n=null!=t.forcedTzo?t.forcedTzo:this.offsetForMarker(e)),Ut(e,n,t.omitTime)}timestampToMarker(e){return"local"===this.timeZone?Bt(Nt(new Date(e))):"UTC"!==this.timeZone&&this.namedTimeZoneImpl?Bt(this.namedTimeZoneImpl.timestampToArray(e)):new Date(e)}offsetForMarker(e){return"local"===this.timeZone?-Pt(Ht(e)).getTimezoneOffset():"UTC"===this.timeZone?0:this.namedTimeZoneImpl?this.namedTimeZoneImpl.offsetForArray(Ht(e)):null}toDate(e,t){return"local"===this.timeZone?Pt(Ht(e)):"UTC"===this.timeZone?new Date(e.valueOf()):this.namedTimeZoneImpl?new Date(e.valueOf()-1e3*this.namedTimeZoneImpl.offsetForArray(Ht(e))*60):new Date(e.valueOf()-(t||0))}}class Bn{constructor(e){this.iconOverrideOption&&this.setIconOverride(e[this.iconOverrideOption])}setIconOverride(e){let t,n;if("object"==typeof e&&e){for(n in t=Object.assign({},this.iconClasses),e)t[n]=this.applyIconOverridePrefix(e[n]);this.iconClasses=t}else!1===e&&(this.iconClasses={})}applyIconOverridePrefix(e){let t=this.iconOverridePrefix;return t&&0!==e.indexOf(t)&&(e=t+e),e}getClass(e){return this.classes[e]||""}getIconClass(e,t){let n;return n=t&&this.rtlIconClasses&&this.rtlIconClasses[e]||this.iconClasses[e],n?`${this.baseIconClass} ${n}`:""}getCustomButtonIconClass(e){let t;return this.iconOverrideCustomButtonOption&&(t=e[this.iconOverrideCustomButtonOption],t)?`${this.baseIconClass} ${this.applyIconOverridePrefix(t)}`:""}}function jn(e){e();let t=n.debounceRendering,r=[];for(n.debounceRendering=function(e){r.push(e)},U(p(zn,{}),document.createElement("div"));r.length;)r.shift()();n.debounceRendering=t}Bn.prototype.classes={},Bn.prototype.iconClasses={},Bn.prototype.baseIconClass="",Bn.prototype.iconOverridePrefix="";class zn extends D{render(){return p("div",{})}componentDidMount(){this.setState({})}}function Un(e){let t=function(e,t){var n={__c:t="__cC"+c++,__:e,Consumer:function(e,t){return e.children(t)},Provider:function(e){var n,r;return this.getChildContext||(n=[],(r={})[t]=this,this.getChildContext=function(){return r},this.shouldComponentUpdate=function(e){this.props.value!==e.value&&n.some((function(e){e.__e=!0,x(e)}))},this.sub=function(e){n.push(e);var t=e.componentWillUnmount;e.componentWillUnmount=function(){n.splice(n.indexOf(e),1),t&&t.call(e)}}),e.children}};return n.Provider.__=n.Consumer.contextType=n}(e),n=t.Provider;return t.Provider=function(){let e=!this.getChildContext,t=n.apply(this,arguments);if(e){let e=[];this.shouldComponentUpdate=t=>{this.props.value!==t.value&&e.forEach(e=>{e.context=t.value,e.forceUpdate()})},this.sub=t=>{e.push(t);let n=t.componentWillUnmount;t.componentWillUnmount=()=>{e.splice(e.indexOf(t),1),n&&n.call(t)}}}return t},t}class Wn{constructor(e,t,n,r){this.execFunc=e,this.emitter=t,this.scrollTime=n,this.scrollTimeReset=r,this.handleScrollRequest=e=>{this.queuedRequest=Object.assign({},this.queuedRequest||{},e),this.drain()},t.on("_scrollRequest",this.handleScrollRequest),this.fireInitialScroll()}detach(){this.emitter.off("_scrollRequest",this.handleScrollRequest)}update(e){e&&this.scrollTimeReset?this.fireInitialScroll():this.drain()}fireInitialScroll(){this.handleScrollRequest({time:this.scrollTime})}drain(){this.queuedRequest&&this.execFunc(this.queuedRequest)&&(this.queuedRequest=null)}}const Ln=Un({});function Fn(e,t,n,r,i,s,o,a,l,c,d,u,h){return{dateEnv:i,options:n,pluginHooks:o,emitter:c,dispatch:a,getCurrentData:l,calendarApi:d,viewSpec:e,viewApi:t,dateProfileGenerator:r,theme:s,isRtl:"rtl"===n.direction,addResizeHandler(e){c.on("_resize",e)},removeResizeHandler(e){c.off("_resize",e)},createScrollResponder:e=>new Wn(e,c,ft(n.scrollTime),n.scrollTimeReset),registerInteractiveComponent:u,unregisterInteractiveComponent:h}}class Vn extends D{shouldComponentUpdate(e,t){return this.debug&&console.log(xn(e,this.props),xn(t,this.state)),!_n(this.props,e,this.propEquality)||!_n(this.state,t,this.stateEquality)}safeSetState(e){_n(this.state,Object.assign(Object.assign({},this.state),e),this.stateEquality)||this.setState(e)}}Vn.addPropsEquality=function(e){let t=Object.create(this.prototype.propEquality);Object.assign(t,e),this.prototype.propEquality=t},Vn.addStateEquality=function(e){let t=Object.create(this.prototype.stateEquality);Object.assign(t,e),this.prototype.stateEquality=t},Vn.contextType=Ln,Vn.prototype.propEquality={},Vn.prototype.stateEquality={};class Gn extends Vn{}function Qn(e,t){"function"==typeof e?e(t):e&&(e.current=t)}Gn.contextType=Ln;class qn extends Gn{constructor(){super(...arguments),this.id=Ze(),this.queuedDomNodes=[],this.currentDomNodes=[],this.handleEl=e=>{const{options:t}=this.context,{generatorName:n}=this.props;t.customRenderingReplaces&&Yn(n,t)||this.updateElRef(e)},this.updateElRef=e=>{this.props.elRef&&Qn(this.props.elRef,e)}}render(){const{props:e,context:t}=this,{options:n}=t,{customGenerator:r,defaultGenerator:s,renderProps:o}=e,a=Zn(e,[],this.handleEl);let l,c,d=!1,u=[];if(null!=r){const e="function"==typeof r?r(o,p):r;if(!0===e)d=!0;else{const t=e&&"object"==typeof e;t&&"html"in e?a.dangerouslySetInnerHTML={__html:e.html}:t&&"domNodes"in e?u=Array.prototype.slice.call(e.domNodes):(t?i(e):"function"!=typeof e)?l=e:c=e}}else d=!Yn(e.generatorName,n);return d&&s&&(l=s(o)),this.queuedDomNodes=u,this.currentGeneratorMeta=c,p(e.elTag,a,l)}componentDidMount(){this.applyQueueudDomNodes(),this.triggerCustomRendering(!0)}componentDidUpdate(){this.applyQueueudDomNodes(),this.triggerCustomRendering(!0)}componentWillUnmount(){this.triggerCustomRendering(!1)}triggerCustomRendering(e){var t;const{props:n,context:r}=this,{handleCustomRendering:i,customRenderingMetaMap:s}=r.options;if(i){const r=null!==(t=this.currentGeneratorMeta)&&void 0!==t?t:null==s?void 0:s[n.generatorName];r&&i(Object.assign(Object.assign({id:this.id,isActive:e,containerEl:this.base,reportNewContainerEl:this.updateElRef,generatorMeta:r},n),{elClasses:(n.elClasses||[]).filter(Xn)}))}}applyQueueudDomNodes(){const{queuedDomNodes:e,currentDomNodes:t}=this,n=this.base;if(!St(e,t)){t.forEach(Ie);for(let t of e)n.appendChild(t);this.currentDomNodes=e}}}function Yn(e,t){var n;return Boolean(t.handleCustomRendering&&e&&(null===(n=t.customRenderingMetaMap)||void 0===n?void 0:n[e]))}function Zn(e,t,n){const r=Object.assign(Object.assign({},e.elAttrs),{ref:n});return(e.elClasses||t)&&(r.className=(e.elClasses||[]).concat(t||[]).concat(r.className||[]).filter(Boolean).join(" ")),e.elStyle&&(r.style=e.elStyle),r}function Xn(e){return Boolean(e)}qn.addPropsEquality({elClasses:St,elStyle:Cn,elAttrs:function(e,t){const n=xn(e,t);for(let e of n)if(!Rn.test(e))return!1;return!0},renderProps:Cn});const $n=Un(0);class Jn extends D{constructor(){super(...arguments),this.InnerContent=Kn.bind(void 0,this),this.handleEl=e=>{this.el=e,this.props.elRef&&(Qn(this.props.elRef,e),e&&this.didMountMisfire&&this.componentDidMount())}}render(){const{props:e}=this,t=function(e,t){const n="function"==typeof e?e(t):e||[];return"string"==typeof n?[n]:n}(e.classNameGenerator,e.renderProps);if(e.children){const n=Zn(e,t,this.handleEl),r=e.children(this.InnerContent,e.renderProps,n);return e.elTag?p(e.elTag,n,r):r}return p(qn,Object.assign(Object.assign({},e),{elRef:this.handleEl,elTag:e.elTag||"div",elClasses:(e.elClasses||[]).concat(t),renderId:this.context}))}componentDidMount(){var e,t;this.el?null===(t=(e=this.props).didMount)||void 0===t||t.call(e,Object.assign(Object.assign({},this.props.renderProps),{el:this.el})):this.didMountMisfire=!0}componentWillUnmount(){var e,t;null===(t=(e=this.props).willUnmount)||void 0===t||t.call(e,Object.assign(Object.assign({},this.props.renderProps),{el:this.el}))}}function Kn(e,t){const n=e.props;return p(qn,Object.assign({renderProps:n.renderProps,generatorName:n.generatorName,customGenerator:n.customGenerator,defaultGenerator:n.defaultGenerator,renderId:e.context},t))}Jn.contextType=$n;class er extends Gn{render(){let{props:e,context:t}=this,{options:n}=t,r={view:t.viewApi};return p(Jn,Object.assign({},e,{elTag:e.elTag||"div",elClasses:[...tr(e.viewSpec),...e.elClasses||[]],renderProps:r,classNameGenerator:n.viewClassNames,generatorName:void 0,didMount:n.viewDidMount,willUnmount:n.viewWillUnmount}),()=>e.children)}}function tr(e){return[`fc-${e.type}-view`,"fc-view"]}function nr(e,t){let n,r,i=[],{start:s}=t;for(e.sort(rr),n=0;ns&&i.push({start:s,end:r.start}),r.end>s&&(s=r.end);return st.start)&&(null===e.start||null===t.end||e.start=e.start)&&(null===e.end||null!==t.end&&t.end<=e.end)}function lr(e,t){return(null===e.start||t>=e.start)&&(null===e.end||t=yt(t)&&(r=wt(r,1))}return e.start&&(n=Mt(e.start),r&&r<=n&&(r=wt(n,1))),{start:n,end:r}}function ur(e){let t=dr(e);return xt(t.start,t.end)>1}function hr(e,t,n,r){return"year"===r?ft(n.diffWholeYears(e,t),"year"):"month"===r?ft(n.diffWholeMonths(e,t),"month"):_t(e,t)}function fr(e,t){return"function"==typeof e&&(e=e()),null==e?t.createNowMarker():t.createMarker(e)}class gr{constructor(e){this.props=e,this.nowDate=fr(e.nowInput,e.dateEnv),this.initHiddenDays()}buildPrev(e,t,n){let{dateEnv:r}=this.props,i=r.subtract(r.startOf(t,e.currentRangeUnit),e.dateIncrement);return this.build(i,-1,n)}buildNext(e,t,n){let{dateEnv:r}=this.props,i=r.add(r.startOf(t,e.currentRangeUnit),e.dateIncrement);return this.build(i,1,n)}build(e,t,n=!0){let r,i,s,o,a,l,{props:c}=this;var d,u;return r=this.buildValidRange(),r=this.trimHiddenDays(r),n&&(d=e,e=null!=(u=r).start&&d=u.end?new Date(u.end.valueOf()-1):d),i=this.buildCurrentRangeInfo(e,t),s=/^(year|month|week|day)$/.test(i.unit),o=this.buildRenderRange(this.trimHiddenDays(i.range),i.unit,s),o=this.trimHiddenDays(o),a=o,c.showNonCurrentDates||(a=ir(a,i.range)),a=this.adjustActiveRange(a),a=ir(a,r),l=or(i.range,r),lr(o,e)||(e=o.start),{currentDate:e,validRange:r,currentRange:i.range,currentRangeUnit:i.unit,isRangeAllDay:s,activeRange:a,renderRange:o,slotMinTime:c.slotMinTime,slotMaxTime:c.slotMaxTime,isValid:l,dateIncrement:this.buildDateIncrement(i.duration)}}buildValidRange(){let e=this.props.validRangeInput,t="function"==typeof e?e.call(this.props.calendarApi,this.nowDate):e;return this.refineRange(t)||{start:null,end:null}}buildCurrentRangeInfo(e,t){let n,{props:r}=this,i=null,s=null,o=null;return r.duration?(i=r.duration,s=r.durationUnit,o=this.buildRangeFromDuration(e,t,i,s)):(n=this.props.dayCount)?(s="day",o=this.buildRangeFromDayCount(e,t,n)):(o=this.buildCustomVisibleRange(e))?s=r.dateEnv.greatestWholeUnit(o.start,o.end).unit:(i=this.getFallbackDuration(),s=Et(i).unit,o=this.buildRangeFromDuration(e,t,i,s)),{duration:i,unit:s,range:o}}getFallbackDuration(){return ft({day:1})}adjustActiveRange(e){let{dateEnv:t,usesMinMaxTime:n,slotMinTime:r,slotMaxTime:i}=this.props,{start:s,end:o}=e;return n&&(vt(r)<0&&(s=Mt(s),s=t.add(s,r)),vt(i)>1&&(o=Mt(o),o=wt(o,-1),o=t.add(o,i))),{start:s,end:o}}buildRangeFromDuration(e,t,n,r){let i,s,o,{dateEnv:a,dateAlignment:l}=this.props;if(!l){let{dateIncrement:e}=this.props;l=e&&yt(e)!o[e.defId].recurringDef);for(let e in o){let n=o[e];if(n.recurringDef){let{duration:o}=n.recurringDef;o||(o=n.allDay?s.defaultAllDayEventDuration:s.defaultTimedEventDuration);let l=vr(n,o,t,r,i.recurringTypes);for(let t of l){let n=pr(e,{start:t,end:r.add(t,o)});a[n.instanceId]=n}}}return{defs:o,instances:a}}function vr(e,t,n,r,i){let s=i[e.recurringDef.typeId].expand(e.recurringDef.typeData,{start:r.subtract(n.start,t),end:n.end},r);return e.allDay&&(s=s.map(Mt)),s}const yr={id:String,groupId:String,title:String,url:String,interactive:Boolean},br={start:yn,end:yn,date:yn,allDay:Boolean},Er=Object.assign(Object.assign(Object.assign({},yr),br),{extendedProps:yn});function Sr(e,t,n,r,i=Dr(n),s,o){let{refined:a,extra:l}=Ar(e,n,i),c=function(e,t){let n=null;e&&(n=e.defaultAllDay);null==n&&(n=t.options.defaultAllDay);return n}(t,n),d=function(e,t,n,r){for(let i=0;i{return n=t,r=e,Boolean(n.groupId&&n.groupId===r.groupId);var n,r});return r.defs[t.defId]=t,r.instances[n.instanceId]=n,r}return{defs:{},instances:{}}}function _r(){return{defs:{},instances:{}}}function Tr(e,t){return{defs:Object.assign(Object.assign({},e.defs),t.defs),instances:Object.assign(Object.assign({},e.instances),t.instances)}}function kr(e,t){let n=Sn(e.defs,t),r=Sn(e.instances,e=>n[e.defId]);return{defs:n,instances:r}}function Mr(e){return Array.isArray(e)?e:"string"==typeof e?e.split(/\s+/):[]}const Ir={display:String,editable:Boolean,startEditable:Boolean,durationEditable:Boolean,constraint:yn,overlap:yn,allow:yn,className:Mr,classNames:Mr,color:String,backgroundColor:String,borderColor:String,textColor:String},Or={display:null,startEditable:null,durationEditable:null,constraints:[],overlap:null,allows:[],backgroundColor:"",borderColor:"",textColor:"",classNames:[]};function Nr(e,t){let n=function(e,t){return Array.isArray(e)?Cr(e,null,t,!0):"object"==typeof e&&e?Cr([e],null,t,!0):null!=e?String(e):null}(e.constraint,t);return{display:e.display||null,startEditable:null!=e.startEditable?e.startEditable:e.editable,durationEditable:null!=e.durationEditable?e.durationEditable:e.editable,constraints:null!=n?[n]:[],overlap:null!=e.overlap?e.overlap:null,allows:null!=e.allow?[e.allow]:[],backgroundColor:e.backgroundColor||e.color||"",borderColor:e.borderColor||e.color||"",textColor:e.textColor||"",classNames:(e.className||[]).concat(e.classNames||[])}}function Pr(e){return e.reduce(Hr,Or)}function Hr(e,t){return{display:null!=t.display?t.display:e.display,startEditable:null!=t.startEditable?t.startEditable:e.startEditable,durationEditable:null!=t.durationEditable?t.durationEditable:e.durationEditable,constraints:e.constraints.concat(t.constraints),overlap:"boolean"==typeof t.overlap?t.overlap:e.overlap,allows:e.allows.concat(t.allows),backgroundColor:t.backgroundColor||e.backgroundColor,borderColor:t.borderColor||e.borderColor,textColor:t.textColor||e.textColor,classNames:e.classNames.concat(t.classNames)}}const Br={id:String,defaultAllDay:Boolean,url:String,format:String,events:yn,eventDataTransform:yn,success:yn,failure:yn};function jr(e,t,n=zr(t)){let r;if("string"==typeof e?r={url:e}:"function"==typeof e||Array.isArray(e)?r={events:e}:"object"==typeof e&&e&&(r=e),r){let{refined:i,extra:s}=vn(r,n),o=function(e,t){let n=t.pluginHooks.eventSourceDefs;for(let t=n.length-1;t>=0;t-=1){let r=n[t].parseMeta(e);if(r)return{sourceDefId:t,meta:r}}return null}(i,t);if(o)return{_raw:e,isFetching:!1,latestFetchId:"",fetchRange:null,defaultAllDay:i.defaultAllDay,eventDataTransform:i.eventDataTransform,success:i.success,failure:i.failure,publicId:i.id||"",sourceId:Ze(),sourceDefId:o.sourceDefId,meta:o.meta,ui:Nr(i,t),extendedProps:s}}return null}function zr(e){return Object.assign(Object.assign(Object.assign({},Ir),Br),e.pluginHooks.eventSourceRefiners)}function Ur(e,t,n,r,i){switch(t.type){case"RECEIVE_EVENTS":return function(e,t,n,r,i,s){if(t&&n===t.latestFetchId){let n=Cr(Wr(i,t,s),t,s);return r&&(n=mr(n,r,s)),Tr(Vr(e,t.sourceId),n)}return e}(e,n[t.sourceId],t.fetchId,t.fetchRange,t.rawEvents,i);case"RESET_RAW_EVENTS":return function(e,t,n,r,i){const{defIdMap:s,instanceIdMap:o}=function(e){const{defs:t,instances:n}=e,r={},i={};for(let e in t){const n=t[e],{publicId:i}=n;i&&(r[i]=e)}for(let e in n){const r=n[e],s=t[r.defId],{publicId:o}=s;o&&(i[o]=e)}return{defIdMap:r,instanceIdMap:i}}(e);return mr(Cr(Wr(n,t,i),t,i,!1,s,o),r,i)}(e,n[t.sourceId],t.rawEvents,r.activeRange,i);case"ADD_EVENTS":return function(e,t,n,r){n&&(t=mr(t,n,r));return Tr(e,t)}(e,t.eventStore,r?r.activeRange:null,i);case"RESET_EVENTS":return t.eventStore;case"MERGE_EVENTS":return Tr(e,t.eventStore);case"PREV":case"NEXT":case"CHANGE_DATE":case"CHANGE_VIEW_TYPE":return r?mr(e,r.activeRange,i):e;case"REMOVE_EVENTS":return function(e,t){let{defs:n,instances:r}=e,i={},s={};for(let e in n)t.defs[e]||(i[e]=n[e]);for(let e in r)!t.instances[e]&&i[r[e].defId]&&(s[e]=r[e]);return{defs:i,instances:s}}(e,t.eventStore);case"REMOVE_EVENT_SOURCE":return Vr(e,t.sourceId);case"REMOVE_ALL_EVENT_SOURCES":return kr(e,e=>!e.sourceId);case"REMOVE_ALL_EVENTS":return{defs:{},instances:{}};default:return e}}function Wr(e,t,n){let r=n.options.eventDataTransform,i=t?t.eventDataTransform:null;return i&&(e=Lr(e,i)),r&&(e=Lr(e,r)),e}function Lr(e,t){let n;if(t){n=[];for(let r of e){let e=t(r);e?n.push(e):null==e&&n.push(r)}}else n=e;return n}function Fr(e,t,n){let{defs:r}=e,i=An(e.instances,e=>r[e.defId].allDay?e:Object.assign(Object.assign({},e),{range:{start:n.createMarker(t.toDate(e.range.start,e.forcedStartTzo)),end:n.createMarker(t.toDate(e.range.end,e.forcedEndTzo))},forcedStartTzo:n.canComputeOffset?null:e.forcedStartTzo,forcedEndTzo:n.canComputeOffset?null:e.forcedEndTzo}));return{defs:r,instances:i}}function Vr(e,t){return kr(e,e=>e.sourceId!==t)}class Gr{constructor(){this.handlers={},this.thisContext=null}setThisContext(e){this.thisContext=e}setOptions(e){this.options=e}on(e,t){!function(e,t,n){(e[t]||(e[t]=[])).push(n)}(this.handlers,e,t)}off(e,t){!function(e,t,n){n?e[t]&&(e[t]=e[t].filter(e=>e!==n)):delete e[t]}(this.handlers,e,t)}trigger(e,...t){let n=this.handlers[e]||[],r=this.options&&this.options[e],i=[].concat(r||[],n);for(let e of i)e.apply(this.thisContext,t)}hasHandlers(e){return Boolean(this.handlers[e]&&this.handlers[e].length||this.options&&this.options[e])}}const Qr={startTime:"09:00",endTime:"17:00",daysOfWeek:[1,2,3,4,5],display:"inverse-background",classNames:"fc-non-business",groupId:"_businessHours"};function qr(e,t){return Cr(function(e){let t;t=!0===e?[{}]:Array.isArray(e)?e.filter(e=>e.daysOfWeek):"object"==typeof e&&e?[e]:[];return t=t.map(e=>Object.assign(Object.assign({},Qr),e)),t}(e),null,t)}function Yr(e,t,n){n.emitter.trigger("select",Object.assign(Object.assign({},Zr(e,n)),{jsEvent:t?t.origEvent:null,view:n.viewApi||n.calendarApi.view}))}function Zr(e,t){let n={};for(let r of t.pluginHooks.dateSpanTransforms)Object.assign(n,r(e,t));var r,i;return Object.assign(n,(r=e,i=t.dateEnv,Object.assign(Object.assign({},wi(r.range,i,r.allDay)),{allDay:r.allDay}))),n}function Xr(e,t,n){let{dateEnv:r,options:i}=n,s=t;return e?(s=Mt(s),s=r.add(s,i.defaultAllDayEventDuration)):s=r.add(s,i.defaultTimedEventDuration),s}function $r(e,t,n,r){let i=li(e.defs,t),s={defs:{},instances:{}};for(let t in e.defs){let o=e.defs[t];s.defs[t]=Jr(o,i[t],n,r)}for(let t in e.instances){let o=e.instances[t],a=s.defs[o.defId];s.instances[t]=Kr(o,a,i[o.defId],n,r)}return s}function Jr(e,t,n,r){let i=n.standardProps||{};null==i.hasEnd&&t.durationEditable&&(n.startDelta||n.endDelta)&&(i.hasEnd=!0);let s=Object.assign(Object.assign(Object.assign({},e),i),{ui:Object.assign(Object.assign({},e.ui),i.ui)});n.extendedProps&&(s.extendedProps=Object.assign(Object.assign({},s.extendedProps),n.extendedProps));for(let e of r.pluginHooks.eventDefMutationAppliers)e(s,n,r);return!s.hasEnd&&r.options.forceEventDuration&&(s.hasEnd=!0),s}function Kr(e,t,n,r,i){let{dateEnv:s}=i,o=r.standardProps&&!0===r.standardProps.allDay,a=r.standardProps&&!1===r.standardProps.hasEnd,l=Object.assign({},e);return o&&(l.range=cr(l.range)),r.datesDelta&&n.startEditable&&(l.range={start:s.add(l.range.start,r.datesDelta),end:s.add(l.range.end,r.datesDelta)}),r.startDelta&&n.durationEditable&&(l.range={start:s.add(l.range.start,r.startDelta),end:l.range.end}),r.endDelta&&n.durationEditable&&(l.range={start:l.range.start,end:s.add(l.range.end,r.endDelta)}),a&&(l.range={start:l.range.start,end:Xr(t.allDay,l.range.start,i)}),t.allDay&&(l.range={start:Mt(l.range.start),end:Mt(l.range.end)}),l.range.endci(e,t))}function ci(e,t){let n=[];return t[""]&&n.push(t[""]),t[e.defId]&&n.push(t[e.defId]),n.push(e.ui),Pr(n)}function di(e,t){let n=e.map(ui);return n.sort((e,n)=>rt(e,n,t)),n.map(e=>e._seg)}function ui(e){let{eventRange:t}=e,n=t.def,r=t.instance?t.instance.range:t.range,i=r.start?r.start.valueOf():0,s=r.end?r.end.valueOf():0;return Object.assign(Object.assign(Object.assign({},n.extendedProps),n),{id:n.publicId,start:i,end:s,duration:s-i,allDay:Number(n.allDay),_seg:e})}function hi(e,t){let{pluginHooks:n}=t,r=n.isDraggableTransformers,{def:i,ui:s}=e.eventRange,o=s.startEditable;for(let e of r)o=e(o,i,s,t);return o}function fi(e,t){return e.isStart&&e.eventRange.ui.durationEditable&&t.options.eventResizableFromStart}function gi(e,t){return e.isEnd&&e.eventRange.ui.durationEditable}function pi(e,t,n,r,i,s,o){let{dateEnv:a,options:l}=n,{displayEventTime:c,displayEventEnd:d}=l,u=e.eventRange.def,h=e.eventRange.instance;null==c&&(c=!1!==r),null==d&&(d=!1!==i);let f=h.range.start,g=h.range.end,p=s||e.start||e.eventRange.range.start,m=o||e.end||e.eventRange.range.end,v=Mt(f).valueOf()===Mt(p).valueOf(),y=Mt(Ct(g,-1)).valueOf()===Mt(Ct(m,-1)).valueOf();return c&&!u.allDay&&(v||y)?(p=v?f:p,m=y?g:m,d&&u.hasEnd?a.formatRange(p,m,t,{forcedStartTzo:s?null:h.forcedStartTzo,forcedEndTzo:o?null:h.forcedEndTzo}):a.format(p,t,{forcedTzo:s?null:h.forcedStartTzo})):""}function mi(e,t,n){let r=e.eventRange.range;return{isPast:r.end<=(n||t.start),isFuture:r.start>=(n||t.end),isToday:t&&lr(t,r.start)}}function vi(e){let t=["fc-event"];return e.isMirror&&t.push("fc-event-mirror"),e.isDraggable&&t.push("fc-event-draggable"),(e.isStartResizable||e.isEndResizable)&&t.push("fc-event-resizable"),e.isDragging&&t.push("fc-event-dragging"),e.isResizing&&t.push("fc-event-resizing"),e.isSelected&&t.push("fc-event-selected"),e.isStart&&t.push("fc-event-start"),e.isEnd&&t.push("fc-event-end"),e.isPast&&t.push("fc-event-past"),e.isToday&&t.push("fc-event-today"),e.isFuture&&t.push("fc-event-future"),t}function yi(e){return e.instance?e.instance.instanceId:`${e.def.defId}:${e.range.start.toISOString()}`}function bi(e,t){let{def:n,instance:r}=e.eventRange,{url:i}=n;if(i)return{href:i};let{emitter:s,options:o}=t,{eventInteractive:a}=o;return null==a&&(a=n.interactive,null==a&&(a=Boolean(s.hasHandlers("eventClick")))),a?qe(e=>{s.trigger("eventClick",{el:e.target,event:new ti(t,n,r),jsEvent:e,view:t.viewApi})}):{}}const Ei={start:yn,end:yn,allDay:Boolean};function Si(e,t,n){let r=function(e,t){let{refined:n,extra:r}=vn(e,Ei),i=n.start?t.createMarkerMeta(n.start):null,s=n.end?t.createMarkerMeta(n.end):null,{allDay:o}=n;null==o&&(o=i&&i.isTimeUnspecified&&(!s||s.isTimeUnspecified));return Object.assign({range:{start:i?i.marker:null,end:s?s.marker:null},allDay:o},r)}(e,t),{range:i}=r;if(!i.start)return null;if(!i.end){if(null==n)return null;i.end=t.add(i.start,n)}return r}function Ai(e,t){return sr(e.range,t.range)&&e.allDay===t.allDay&&function(e,t){for(let n in t)if("range"!==n&&"allDay"!==n&&e[n]!==t[n])return!1;for(let n in e)if(!(n in t))return!1;return!0}(e,t)}function Di(e,t,n){return Object.assign(Object.assign({},wi(e,t,n)),{timeZone:t.timeZone})}function wi(e,t,n){return{start:t.toDate(e.start),end:t.toDate(e.end),startStr:t.formatIso(e.start,{omitTime:n}),endStr:t.formatIso(e.end,{omitTime:n})}}function Ci(e,t,n){let r=!1,i=function(e){r||(r=!0,t(e))},s=function(e){r||(r=!0,n(e))},o=e(i,s);o&&"function"==typeof o.then&&o.then(i,s)}class Ri extends Error{constructor(e,t){super(e),this.response=t}}function xi(e,t,n){const r={method:e=e.toUpperCase()};return"GET"===e?t+=(-1===t.indexOf("?")?"?":"&")+new URLSearchParams(n):(r.body=new URLSearchParams(n),r.headers={"Content-Type":"application/x-www-form-urlencoded"}),fetch(t,r).then(e=>{if(e.ok)return e.json().then(t=>[t,e],()=>{throw new Ri("Failure parsing JSON",e)});throw new Ri("Request failed",e)})}let _i;function Ti(){return null==_i&&(_i=function(){if("undefined"==typeof document)return!0;let e=document.createElement("div");e.style.position="absolute",e.style.top="0px",e.style.left="0px",e.innerHTML="
",e.querySelector("table").style.height="100px",e.querySelector("div").style.height="100%",document.body.appendChild(e);let t=e.querySelector("div").offsetHeight>0;return document.body.removeChild(e),t}()),_i}class ki extends Gn{constructor(){super(...arguments),this.state={forPrint:!1},this.handleBeforePrint=()=>{jn(()=>{this.setState({forPrint:!0})})},this.handleAfterPrint=()=>{jn(()=>{this.setState({forPrint:!1})})}}render(){let{props:e}=this,{options:t}=e,{forPrint:n}=this.state,r=n||"auto"===t.height||"auto"===t.contentHeight,i=r||null==t.height?"":t.height,s=["fc",n?"fc-media-print":"fc-media-screen","fc-direction-"+t.direction,e.theme.getClass("root")];return Ti()||s.push("fc-liquid-hack"),e.children(s,i,r,n)}componentDidMount(){let{emitter:e}=this.props;e.on("_beforeprint",this.handleBeforePrint),e.on("_afterprint",this.handleAfterPrint)}componentWillUnmount(){let{emitter:e}=this.props;e.off("_beforeprint",this.handleBeforePrint),e.off("_afterprint",this.handleAfterPrint)}}class Mi{constructor(e){this.component=e.component,this.isHitComboAllowed=e.isHitComboAllowed||null}destroy(){}}function Ii(e){return{[e.component.uid]:e}}const Oi={};class Ni{getCurrentData(){return this.currentDataManager.getCurrentData()}dispatch(e){this.currentDataManager.dispatch(e)}get view(){return this.getCurrentData().viewApi}batchRendering(e){e()}updateSize(){this.trigger("_resize",!0)}setOption(e,t){this.dispatch({type:"SET_OPTION",optionName:e,rawOptionValue:t})}getOption(e){return this.currentDataManager.currentCalendarOptionsInput[e]}getAvailableLocaleCodes(){return Object.keys(this.getCurrentData().availableRawLocales)}on(e,t){let{currentDataManager:n}=this;n.currentCalendarOptionsRefiners[e]?n.emitter.on(e,t):console.warn(`Unknown listener name '${e}'`)}off(e,t){this.currentDataManager.emitter.off(e,t)}trigger(e,...t){this.currentDataManager.emitter.trigger(e,...t)}changeView(e,t){this.batchRendering(()=>{if(this.unselect(),t)if(t.start&&t.end)this.dispatch({type:"CHANGE_VIEW_TYPE",viewType:e}),this.dispatch({type:"SET_OPTION",optionName:"visibleRange",rawOptionValue:t});else{let{dateEnv:n}=this.getCurrentData();this.dispatch({type:"CHANGE_VIEW_TYPE",viewType:e,dateMarker:n.createMarker(t)})}else this.dispatch({type:"CHANGE_VIEW_TYPE",viewType:e})})}zoomTo(e,t){let n;t=t||"day",n=this.getCurrentData().viewSpecs[t]||this.getUnitViewSpec(t),this.unselect(),n?this.dispatch({type:"CHANGE_VIEW_TYPE",viewType:n.type,dateMarker:e}):this.dispatch({type:"CHANGE_DATE",dateMarker:e})}getUnitViewSpec(e){let t,n,{viewSpecs:r,toolbarConfig:i}=this.getCurrentData(),s=[].concat(i.header?i.header.viewsWithButtons:[],i.footer?i.footer.viewsWithButtons:[]);for(let e in r)s.push(e);for(t=0;t{this.dispatch({type:"REMOVE_EVENTS",eventStore:ni(e)})}})}getEventById(e){let t=this.getCurrentData(),{defs:n,instances:r}=t.eventStore;e=String(e);for(let i in n){let s=n[i];if(s.publicId===e){if(s.recurringDef)return new ti(t,s,null);for(let e in r){let n=r[e];if(n.defId===s.defId)return new ti(t,s,n)}}}return null}getEvents(){let e=this.getCurrentData();return ri(e.eventStore,e)}removeAllEvents(){this.dispatch({type:"REMOVE_ALL_EVENTS"})}getEventSources(){let e=this.getCurrentData(),t=e.eventSources,n=[];for(let r in t)n.push(new ei(e,t[r]));return n}getEventSourceById(e){let t=this.getCurrentData(),n=t.eventSources;e=String(e);for(let r in n)if(n[r].publicId===e)return new ei(t,n[r]);return null}addEventSource(e){let t=this.getCurrentData();if(e instanceof ei)return t.eventSources[e.internalEventSource.sourceId]||this.dispatch({type:"ADD_EVENT_SOURCES",sources:[e.internalEventSource]}),e;let n=jr(e,t);return n?(this.dispatch({type:"ADD_EVENT_SOURCES",sources:[n]}),new ei(t,n)):null}removeAllEventSources(){this.dispatch({type:"REMOVE_ALL_EVENT_SOURCES"})}refetchEvents(){this.dispatch({type:"FETCH_EVENT_SOURCES",isRefetch:!0})}scrollToTime(e){let t=ft(e);t&&this.trigger("_scrollRequest",{time:t})}}function Pi(e,t){return e.left>=t.left&&e.left=t.top&&e.topthis.eventUiBuilders[t]||Gt(Li));for(let n in t){let c=t[n],d=s[n]||Ui,u=this.eventUiBuilders[n];l[n]={businessHours:c.businessHours||e.businessHours,dateSelection:r[n]||null,eventStore:d,eventUiBases:u(e.eventUiBases[""],c.ui,i[n]),eventSelection:d.instances[e.eventSelection]?e.eventSelection:"",eventDrag:o[n]||null,eventResize:a[n]||null}}return l}_splitDateSpan(e){let t={};if(e){let n=this.getKeysForDateSpan(e);for(let r of n)t[r]=e}return t}_getKeysForEventDefs(e){return An(e.defs,e=>this.getKeysForEventDef(e))}_splitEventStore(e,t){let{defs:n,instances:r}=e,i={};for(let e in n)for(let r of t[e])i[r]||(i[r]={defs:{},instances:{}}),i[r].defs[e]=n[e];for(let e in r){let n=r[e];for(let r of t[n.defId])i[r]&&(i[r].instances[e]=n)}return i}_splitIndividualUi(e,t){let n={};for(let r in e)if(r)for(let i of t[r])n[i]||(n[i]={}),n[i][r]=e[r];return n}_splitInteraction(e){let t={};if(e){let n=this._splitEventStore(e.affectedEvents,this._getKeysForEventDefs(e.affectedEvents)),r=this._getKeysForEventDefs(e.mutatedEvents),i=this._splitEventStore(e.mutatedEvents,r),s=r=>{t[r]||(t[r]={affectedEvents:n[r]||Ui,mutatedEvents:i[r]||Ui,isEvent:e.isEvent})};for(let e in n)s(e);for(let e in i)s(e)}return t}}function Li(e,t,n){let r=[];e&&r.push(e),t&&r.push(t);let i={"":Pr(r)};return n&&Object.assign(i,n),i}function Fi(e,t,n,r){return{dow:e.getUTCDay(),isDisabled:Boolean(r&&!lr(r.activeRange,e)),isOther:Boolean(r&&!lr(r.currentRange,e)),isToday:Boolean(t&&lr(t,e)),isPast:Boolean(n?en:!!t&&e>=t.end)}}function Vi(e,t){let n=["fc-day","fc-day-"+At[e.dow]];return e.isDisabled?n.push("fc-day-disabled"):(e.isToday&&(n.push("fc-day-today"),n.push(t.getClass("today"))),e.isPast&&n.push("fc-day-past"),e.isFuture&&n.push("fc-day-future"),e.isOther&&n.push("fc-day-other")),n}const Gi=an({year:"numeric",month:"long",day:"numeric"}),Qi=an({week:"long"});function qi(e,t,n="day",r=!0){const{dateEnv:i,options:s,calendarApi:o}=e;let a=i.format(t,"week"===n?Qi:Gi);if(s.navLinks){let e=i.toDate(t);const l=e=>{let r="day"===n?s.navLinkDayClick:"week"===n?s.navLinkWeekClick:null;"function"==typeof r?r.call(o,i.toDate(t),e):("string"==typeof r&&(n=r),o.zoomTo(t,n))};return Object.assign({title:at(s.navLinkHint,[a,e],a),"data-navlink":""},r?Qe(l):{onClick:l})}return{"aria-label":a}}let Yi,Zi=null;function Xi(){return null===Zi&&(Zi=function(){let e=document.createElement("div");Be(e,{position:"absolute",top:-1e3,left:0,border:0,padding:0,overflow:"scroll",direction:"rtl"}),e.innerHTML="
",document.body.appendChild(e);let t=e.firstChild.getBoundingClientRect().left>e.getBoundingClientRect().left;return Ie(e),t}()),Zi}function $i(){return Yi||(Yi=function(){let e=document.createElement("div");e.style.overflow="scroll",e.style.position="absolute",e.style.top="-9999px",e.style.left="-9999px",document.body.appendChild(e);let t=Ji(e);return document.body.removeChild(e),t}()),Yi}function Ji(e){return{x:e.offsetHeight-e.clientHeight,y:e.offsetWidth-e.clientWidth}}function Ki(e,t=!1){let n=window.getComputedStyle(e),r=parseInt(n.borderLeftWidth,10)||0,i=parseInt(n.borderRightWidth,10)||0,s=parseInt(n.borderTopWidth,10)||0,o=parseInt(n.borderBottomWidth,10)||0,a=Ji(e),l=a.y-r-i,c={borderLeft:r,borderRight:i,borderTop:s,borderBottom:o,scrollbarBottom:a.x-s-o,scrollbarLeft:0,scrollbarRight:0};return Xi()&&"rtl"===n.direction?c.scrollbarLeft=l:c.scrollbarRight=l,t&&(c.paddingLeft=parseInt(n.paddingLeft,10)||0,c.paddingRight=parseInt(n.paddingRight,10)||0,c.paddingTop=parseInt(n.paddingTop,10)||0,c.paddingBottom=parseInt(n.paddingBottom,10)||0),c}function es(e,t=!1,n){let r=n?e.getBoundingClientRect():ts(e),i=Ki(e,t),s={left:r.left+i.borderLeft+i.scrollbarLeft,right:r.right-i.borderRight-i.scrollbarRight,top:r.top+i.borderTop,bottom:r.bottom-i.borderBottom-i.scrollbarBottom};return t&&(s.left+=i.paddingLeft,s.right-=i.paddingRight,s.top+=i.paddingTop,s.bottom-=i.paddingBottom),s}function ts(e){let t=e.getBoundingClientRect();return{left:t.left+window.scrollX,top:t.top+window.scrollY,right:t.right+window.scrollX,bottom:t.bottom+window.scrollY}}function ns(e){let t=[];for(;e instanceof HTMLElement;){let n=window.getComputedStyle(e);if("fixed"===n.position)break;/(auto|scroll)/.test(n.overflow+n.overflowY+n.overflowX)&&t.push(e),e=e.parentNode}return t}class rs{constructor(e,t,n,r){this.els=t;let i=this.originClientRect=e.getBoundingClientRect();n&&this.buildElHorizontals(i.left),r&&this.buildElVerticals(i.top)}buildElHorizontals(e){let t=[],n=[];for(let r of this.els){let i=r.getBoundingClientRect();t.push(i.left-e),n.push(i.right-e)}this.lefts=t,this.rights=n}buildElVerticals(e){let t=[],n=[];for(let r of this.els){let i=r.getBoundingClientRect();t.push(i.top-e),n.push(i.bottom-e)}this.tops=t,this.bottoms=n}leftToIndex(e){let t,{lefts:n,rights:r}=this,i=n.length;for(t=0;t=n[t]&&e=n[t]&&e0}canScrollHorizontally(){return this.getMaxScrollLeft()>0}canScrollUp(){return this.getScrollTop()>0}canScrollDown(){return this.getScrollTop()0}canScrollRight(){return this.getScrollLeft()e.thickness||1)){this.getEntryThickness=e,this.strictOrder=!1,this.allowReslicing=!1,this.maxCoord=-1,this.maxStackCnt=-1,this.levelCoords=[],this.entriesByLevel=[],this.stackCnts={}}addSegs(e){let t=[];for(let n of e)this.insertEntry(n,t);return t}insertEntry(e,t){let n=this.findInsertion(e);this.isInsertionValid(n,e)?this.insertEntryAt(e,n):this.handleInvalidInsertion(n,e,t)}isInsertionValid(e,t){return(-1===this.maxCoord||e.levelCoord+this.getEntryThickness(t)<=this.maxCoord)&&(-1===this.maxStackCnt||e.stackCnti.end&&this.insertEntry({index:e.index,thickness:e.thickness,span:{start:i.end,end:r.end}},n)}insertEntryAt(e,t){let{entriesByLevel:n,levelCoords:r}=this;-1===t.lateral?(gs(r,t.level,t.levelCoord),gs(n,t.level,[e])):gs(n[t.level],t.lateral,e),this.stackCnts[us(e)]=t.stackCnt}findInsertion(e){let{levelCoords:t,entriesByLevel:n,strictOrder:r,stackCnts:i}=this,s=t.length,o=0,a=-1,l=-1,c=null,d=0;for(let u=0;u=o+this.getEntryThickness(e))break;let h,f=n[u],g=ps(f,e.span.start,ds),p=g[0]+g[1];for(;(h=f[p])&&h.span.starto&&(o=e,c=h,a=u,l=p),e===o&&(d=Math.max(d,i[us(h)]+1)),p+=1}}let u=0;if(c)for(u=a+1;un(e[i-1]))return[i,0];for(;ro))return[s,1];r=s+1}}return[r,0]}class ms{constructor(e,t){this.emitter=new Gr}destroy(){}setMirrorIsVisible(e){}setMirrorNeedsRevert(e){}setAutoScrollEnabled(e){}}const vs={},ys={startTime:ft,duration:ft,create:Boolean,sourceId:String};function bs(e){let{refined:t,extra:n}=vn(e,ys);return{startTime:t.startTime||null,duration:t.duration||null,create:null==t.create||t.create,sourceId:t.sourceId,leftoverProps:n}}function Es(e,t){return an(!e||t>10?{weekday:"short"}:t>1?{weekday:"short",month:"numeric",day:"numeric",omitCommas:!0}:{weekday:"long"})}const Ss="fc-col-header-cell";function As(e){return e.text}class Ds extends Gn{render(){let{dateEnv:e,options:t,theme:n,viewApi:r}=this.context,{props:i}=this,{date:s,dateProfile:o}=i,a=Fi(s,i.todayRange,null,o),l=[Ss].concat(Vi(a,n)),c=e.format(s,i.dayHeaderFormat),d=!a.isDisabled&&i.colCnt>1?qi(this.context,s):{},u=Object.assign(Object.assign(Object.assign({date:e.toDate(s),view:r},i.extraRenderProps),{text:c}),a);return p(Jn,{elTag:"th",elClasses:l,elAttrs:Object.assign({role:"columnheader",colSpan:i.colSpan,"data-date":a.isDisabled?void 0:Wt(s)},i.extraDataAttrs),renderProps:u,generatorName:"dayHeaderContent",customGenerator:t.dayHeaderContent,defaultGenerator:As,classNameGenerator:t.dayHeaderClassNames,didMount:t.dayHeaderDidMount,willUnmount:t.dayHeaderWillUnmount},e=>p("div",{className:"fc-scrollgrid-sync-inner"},!a.isDisabled&&p(e,{elTag:"a",elAttrs:d,elClasses:["fc-col-header-cell-cushion",i.isSticky&&"fc-sticky"]})))}}const ws=an({weekday:"long"});class Cs extends Gn{render(){let{props:e}=this,{dateEnv:t,theme:n,viewApi:r,options:i}=this.context,s=wt(new Date(2592e5),e.dow),o={dow:e.dow,isDisabled:!1,isFuture:!1,isPast:!1,isToday:!1,isOther:!1},a=t.format(s,e.dayHeaderFormat),l=Object.assign(Object.assign(Object.assign(Object.assign({date:s},o),{view:r}),e.extraRenderProps),{text:a});return p(Jn,{elTag:"th",elClasses:[Ss,...Vi(o,n),...e.extraClassNames||[]],elAttrs:Object.assign({role:"columnheader",colSpan:e.colSpan},e.extraDataAttrs),renderProps:l,generatorName:"dayHeaderContent",customGenerator:i.dayHeaderContent,defaultGenerator:As,classNameGenerator:i.dayHeaderClassNames,didMount:i.dayHeaderDidMount,willUnmount:i.dayHeaderWillUnmount},n=>p("div",{className:"fc-scrollgrid-sync-inner"},p(n,{elTag:"a",elClasses:["fc-col-header-cell-cushion",e.isSticky&&"fc-sticky"],elAttrs:{"aria-label":t.format(s,ws)}})))}}class Rs extends D{constructor(e,t){super(e,t),this.initialNowDate=fr(t.options.now,t.dateEnv),this.initialNowQueriedMs=(new Date).valueOf(),this.state=this.computeTiming().currentState}render(){let{props:e,state:t}=this;return e.children(t.nowDate,t.todayRange)}componentDidMount(){this.setTimeout()}componentDidUpdate(e){e.unit!==this.props.unit&&(this.clearTimeout(),this.setTimeout())}componentWillUnmount(){this.clearTimeout()}computeTiming(){let{props:e,context:t}=this,n=Ct(this.initialNowDate,(new Date).valueOf()-this.initialNowQueriedMs),r=t.dateEnv.startOf(n,e.unit),i=t.dateEnv.add(r,ft(1,e.unit)),s=i.valueOf()-n.valueOf();return s=Math.min(864e5,s),{currentState:{nowDate:r,todayRange:xs(r)},nextState:{nowDate:i,todayRange:xs(i)},waitMs:s}}setTimeout(){let{nextState:e,waitMs:t}=this.computeTiming();this.timeoutId=setTimeout(()=>{this.setState(e,()=>{this.setTimeout()})},t)}clearTimeout(){this.timeoutId&&clearTimeout(this.timeoutId)}}function xs(e){let t=Mt(e);return{start:t,end:wt(t,1)}}Rs.contextType=Ln;class _s extends Gn{constructor(){super(...arguments),this.createDayHeaderFormatter=Gt(Ts)}render(){let{context:e}=this,{dates:t,dateProfile:n,datesRepDistinctDays:r,renderIntro:i}=this.props,s=this.createDayHeaderFormatter(e.options.dayHeaderFormat,r,t.length);return p(Rs,{unit:"day"},(e,o)=>p("tr",{role:"row"},i&&i("day"),t.map(e=>r?p(Ds,{key:e.toISOString(),date:e,dateProfile:n,todayRange:o,colCnt:t.length,dayHeaderFormat:s}):p(Cs,{key:e.getUTCDay(),dow:e.getUTCDay(),dayHeaderFormat:s}))))}}function Ts(e,t,n){return e||Es(t,n)}class ks{constructor(e,t){let n=e.start,{end:r}=e,i=[],s=[],o=-1;for(;n=t.length?t[t.length-1]+1:t[n]}}class Ms{constructor(e,t){let n,r,i,{dates:s}=e;if(t){for(r=s[0].getUTCDay(),n=1;n!p[e.instanceId])}),u=d.defs,h=d.instances,f=li(u,e.eventUiBases);var g,p;for(let r in l){let o=l[r],g=o.range,p=c[o.defId],m=a[o.defId];if(!js(p.constraints,g,d,e.businessHours,t))return!1;let{eventOverlap:v}=t.options,y="function"==typeof v?v:null;for(let e in h){let n=h[e];if(or(g,n.range)){if(!1===f[n.defId].overlap&&s.isEvent)return!1;if(!1===p.overlap)return!1;if(y&&!y(new ti(t,u[n.defId],n),new ti(t,m,o)))return!1}}let b=i.eventStore;for(let e of p.allows){let i,s=Object.assign(Object.assign({},n),{range:o.range,allDay:m.allDay}),a=b.defs[m.defId],l=b.instances[r];if(i=a?new ti(t,a,l):new ti(t,m),!e(Zr(s,t),i))return!1}}return!0}(e,t,n,r))&&!(e.dateSelection&&!function(e,t,n,r){let i=e.eventStore,s=i.defs,o=i.instances,a=e.dateSelection,l=a.range,{selectionConfig:c}=t.getCurrentData();r&&(c=r(c));if(!js(c.constraints,l,i,e.businessHours,t))return!1;let{selectOverlap:d}=t.options,u="function"==typeof d?d:null;for(let e in o){let n=o[e];if(or(l,n.range)){if(!1===c.overlap)return!1;if(u&&!u(new ti(t,s[n.defId],n),null))return!1}}for(let e of c.allows){let r=Object.assign(Object.assign({},n),a);if(!e(Zr(r,t),null))return!1}return!0}(e,t,n,r))}function js(e,t,n,r,i){for(let s of e)if(!Ws(zs(s,t,n,r,i),t))return!1;return!0}function zs(e,t,n,r,i){return"businessHours"===e?Us(mr(r,t,i)):"string"==typeof e?Us(kr(n,t=>t.groupId===e)):"object"==typeof e&&e?Us(mr(e,t,i)):[]}function Us(e){let{instances:t}=e,n=[];for(let e in t)n.push(t[e].range);return n}function Ws(e,t){for(let n of e)if(ar(n,t))return!0;return!1}const Ls=/^(visible|hidden)$/;class Fs extends Gn{constructor(){super(...arguments),this.handleEl=e=>{this.el=e,Qn(this.props.elRef,e)}}render(){let{props:e}=this,{liquid:t,liquidIsAbsolute:n}=e,r=t&&n,i=["fc-scroller"];return t&&(n?i.push("fc-scroller-liquid-absolute"):i.push("fc-scroller-liquid")),p("div",{ref:this.handleEl,className:i.join(" "),style:{overflowX:e.overflowX,overflowY:e.overflowY,left:r&&-(e.overcomeLeft||0)||"",right:r&&-(e.overcomeRight||0)||"",bottom:r&&-(e.overcomeBottom||0)||"",marginLeft:!r&&-(e.overcomeLeft||0)||"",marginRight:!r&&-(e.overcomeRight||0)||"",marginBottom:!r&&-(e.overcomeBottom||0)||"",maxHeight:e.maxHeight||""}},e.children)}needsXScrolling(){if(Ls.test(this.props.overflowX))return!1;let{el:e}=this,t=this.el.getBoundingClientRect().width-this.getYScrollbarWidth(),{children:n}=e;for(let e=0;et)return!0}return!1}needsYScrolling(){if(Ls.test(this.props.overflowY))return!1;let{el:e}=this,t=this.el.getBoundingClientRect().height-this.getXScrollbarWidth(),{children:n}=e;for(let e=0;et)return!0}return!1}getXScrollbarWidth(){return Ls.test(this.props.overflowX)?0:this.el.offsetHeight-this.el.clientHeight}getYScrollbarWidth(){return Ls.test(this.props.overflowY)?0:this.el.offsetWidth-this.el.clientWidth}}class Vs{constructor(e){this.masterCallback=e,this.currentMap={},this.depths={},this.callbackMap={},this.handleValue=(e,t)=>{let{depths:n,currentMap:r}=this,i=!1,s=!1;null!==e?(i=t in r,r[t]=e,n[t]=(n[t]||0)+1,s=!0):(n[t]-=1,n[t]||(delete r[t],delete this.callbackMap[t],i=!0)),this.masterCallback&&(i&&this.masterCallback(null,String(t)),s&&this.masterCallback(e,String(t)))}}createRef(e){let t=this.callbackMap[e];return t||(t=this.callbackMap[e]=t=>{this.handleValue(t,String(e))}),t}collect(e,t,n){return kn(this.currentMap,e,t,n)}getAll(){return wn(this.currentMap)}}function Gs(e){let t=Pe(e,".fc-scrollgrid-shrink"),n=0;for(let e of t)n=Math.max(n,dt(e));return Math.ceil(n)}function Qs(e,t){return e.liquid&&t.liquid}function qs(e,t){return null!=t.maxHeight||Qs(e,t)}function Ys(e,t,n,r){let{expandRows:i}=n;return"function"==typeof t.content?t.content(n):p("table",{role:"presentation",className:[t.tableClassName,e.syncRowHeights?"fc-scrollgrid-sync-table":""].join(" "),style:{minWidth:n.tableMinWidth,width:n.clientWidth,height:i?n.clientHeight:""}},n.tableColGroupNode,p(r?"thead":"tbody",{role:"presentation"},"function"==typeof t.rowContent?t.rowContent(n):t.rowContent))}function Zs(e,t){return St(e,t,Cn)}function Xs(e,t){let n=[];for(let r of e){let e=r.span||1;for(let i=0;ie,Zs),this.renderMicroColGroup=Gt(Xs),this.scrollerRefs=new Vs,this.scrollerElRefs=new Vs(this._handleScrollerEl.bind(this)),this.state={shrinkWidth:null,forceYScrollbars:!1,scrollerClientWidths:{},scrollerClientHeights:{}},this.handleSizing=()=>{this.safeSetState(Object.assign({shrinkWidth:this.computeShrinkWidth()},this.computeScrollerDims()))}}render(){let{props:e,state:t,context:n}=this,r=e.sections||[],i=this.processCols(e.cols),s=this.renderMicroColGroup(i,t.shrinkWidth),o=Ks(e.liquid,n);e.collapsibleWidth&&o.push("fc-scrollgrid-collapsible");let a,l=r.length,c=0,d=[],u=[],h=[];for(;c{}},r);return p(r?"th":"td",{ref:n.elRef,role:"presentation"},p("div",{className:"fc-scroller-harness"+(c?" fc-scroller-harness-liquid":"")},p(Fs,{ref:this.scrollerRefs.createRef(u),elRef:this.scrollerElRefs.createRef(u),overflowY:d,overflowX:i.liquid?"hidden":"visible",maxHeight:e.maxHeight,liquid:c,liquidIsAbsolute:!0},h)))}_handleScrollerEl(e,t){let n=function(e,t){for(let n of e)if(n.key===t)return n;return null}(this.props.sections,t);n&&Qn(n.chunk.scrollerElRef,e)}componentDidMount(){this.handleSizing(),this.context.addResizeHandler(this.handleSizing)}componentDidUpdate(){this.handleSizing()}componentWillUnmount(){this.context.removeResizeHandler(this.handleSizing)}computeShrinkWidth(){return Js(this.props.cols)?Gs(this.scrollerElRefs.getAll()):0}computeScrollerDims(){let e=$i(),{scrollerRefs:t,scrollerElRefs:n}=this,r=!1,i={},s={};for(let e in t.currentMap){let n=t.currentMap[e];if(n&&n.needsYScrolling()){r=!0;break}}for(let t of this.props.sections){let o=t.key,a=n.currentMap[o];if(a){let t=a.parentNode;i[o]=Math.floor(t.getBoundingClientRect().width-(r?e.y:0)),s[o]=Math.floor(t.getBoundingClientRect().height)}}return{forceYScrollbars:r,scrollerClientWidths:i,scrollerClientHeights:s}}}io.addStateEquality({scrollerClientWidths:Cn,scrollerClientHeights:Cn});class so extends Gn{constructor(){super(...arguments),this.handleEl=e=>{this.el=e,e&&oi(e,this.props.seg)}}render(){const{props:e,context:t}=this,{options:n}=t,{seg:r}=e,{eventRange:i}=r,{ui:s}=i,o={event:new ti(t,i.def,i.instance),view:t.viewApi,timeText:e.timeText,textColor:s.textColor,backgroundColor:s.backgroundColor,borderColor:s.borderColor,isDraggable:!e.disableDragging&&hi(r,t),isStartResizable:!e.disableResizing&&fi(r,t),isEndResizable:!e.disableResizing&&gi(r),isMirror:Boolean(e.isDragging||e.isResizing||e.isDateSelecting),isStart:Boolean(r.isStart),isEnd:Boolean(r.isEnd),isPast:Boolean(e.isPast),isFuture:Boolean(e.isFuture),isToday:Boolean(e.isToday),isSelected:Boolean(e.isSelected),isDragging:Boolean(e.isDragging),isResizing:Boolean(e.isResizing)};return p(Jn,Object.assign({},e,{elRef:this.handleEl,elClasses:[...vi(o),...r.eventRange.ui.classNames,...e.elClasses||[]],renderProps:o,generatorName:"eventContent",customGenerator:n.eventContent,defaultGenerator:e.defaultGenerator,classNameGenerator:n.eventClassNames,didMount:n.eventDidMount,willUnmount:n.eventWillUnmount}))}componentDidUpdate(e){this.el&&this.props.seg!==e.seg&&oi(this.el,this.props.seg)}}class oo extends Gn{render(){let{props:e,context:t}=this,{options:n}=t,{seg:r}=e,{ui:i}=r.eventRange,s=pi(r,n.eventTimeFormat||e.defaultTimeFormat,t,e.defaultDisplayEventTime,e.defaultDisplayEventEnd);return p(so,Object.assign({},e,{elTag:"a",elStyle:{borderColor:i.borderColor,backgroundColor:i.backgroundColor},elAttrs:bi(r,t),defaultGenerator:ao,timeText:s}),(e,t)=>p(y,null,p(e,{elTag:"div",elClasses:["fc-event-main"],elStyle:{color:t.textColor}}),Boolean(t.isStartResizable)&&p("div",{className:"fc-event-resizer fc-event-resizer-start"}),Boolean(t.isEndResizable)&&p("div",{className:"fc-event-resizer fc-event-resizer-end"})))}}function ao(e){return p("div",{className:"fc-event-main-frame"},e.timeText&&p("div",{className:"fc-event-time"},e.timeText),p("div",{className:"fc-event-title-container"},p("div",{className:"fc-event-title fc-sticky"},e.event.title||p(y,null," "))))}const lo=e=>p(Ln.Consumer,null,t=>{let{options:n}=t,r={isAxis:e.isAxis,date:t.dateEnv.toDate(e.date),view:t.viewApi};return p(Jn,Object.assign({},e,{elTag:e.elTag||"div",renderProps:r,generatorName:"nowIndicatorContent",customGenerator:n.nowIndicatorContent,classNameGenerator:n.nowIndicatorClassNames,didMount:n.nowIndicatorDidMount,willUnmount:n.nowIndicatorWillUnmount}))}),co=an({day:"numeric"});class uo extends Gn{constructor(){super(...arguments),this.refineRenderProps=Qt(fo)}render(){let{props:e,context:t}=this,{options:n}=t,r=this.refineRenderProps({date:e.date,dateProfile:e.dateProfile,todayRange:e.todayRange,isMonthStart:e.isMonthStart||!1,showDayNumber:e.showDayNumber,extraRenderProps:e.extraRenderProps,viewApi:t.viewApi,dateEnv:t.dateEnv,monthStartFormat:n.monthStartFormat});return p(Jn,Object.assign({},e,{elClasses:[...Vi(r,t.theme),...e.elClasses||[]],elAttrs:Object.assign(Object.assign({},e.elAttrs),r.isDisabled?{}:{"data-date":Wt(e.date)}),renderProps:r,generatorName:"dayCellContent",customGenerator:n.dayCellContent,defaultGenerator:e.defaultGenerator,classNameGenerator:r.isDisabled?void 0:n.dayCellClassNames,didMount:n.dayCellDidMount,willUnmount:n.dayCellWillUnmount}))}}function ho(e){return Boolean(e.dayCellContent||Yn("dayCellContent",e))}function fo(e){let{date:t,dateEnv:n,dateProfile:r,isMonthStart:i}=e,s=Fi(t,e.todayRange,null,r),o=e.showDayNumber?n.format(t,i?e.monthStartFormat:co):"";return Object.assign(Object.assign(Object.assign({date:n.toDate(t),view:e.viewApi},s),{isMonthStart:i,dayNumberText:o}),e.extraRenderProps)}class go extends Gn{render(){let{props:e}=this,{seg:t}=e;return p(so,{elTag:"div",elClasses:["fc-bg-event"],elStyle:{backgroundColor:t.eventRange.ui.backgroundColor},defaultGenerator:po,seg:t,timeText:"",isDragging:!1,isResizing:!1,isDateSelecting:!1,isSelected:!1,isPast:e.isPast,isFuture:e.isFuture,isToday:e.isToday,disableDragging:!0,disableResizing:!0})}}function po(e){let{title:t}=e.event;return t&&p("div",{className:"fc-event-title"},e.event.title)}function mo(e){return p("div",{className:"fc-"+e})}const vo=e=>p(Ln.Consumer,null,t=>{let{dateEnv:n,options:r}=t,{date:i}=e,s=r.weekNumberFormat||e.defaultFormat,o={num:n.computeWeekNumber(i),text:n.format(i,s),date:i};return p(Jn,Object.assign({},e,{renderProps:o,generatorName:"weekNumberContent",customGenerator:r.weekNumberContent,defaultGenerator:yo,classNameGenerator:r.weekNumberClassNames,didMount:r.weekNumberDidMount,willUnmount:r.weekNumberWillUnmount}))});function yo(e){return e.text}class bo extends Gn{constructor(){super(...arguments),this.state={titleId:We()},this.handleRootEl=e=>{this.rootEl=e,this.props.elRef&&Qn(this.props.elRef,e)},this.handleDocumentMouseDown=e=>{const t=ze(e);this.rootEl.contains(t)||this.handleCloseClick()},this.handleDocumentKeyDown=e=>{"Escape"===e.key&&this.handleCloseClick()},this.handleCloseClick=()=>{let{onClose:e}=this.props;e&&e()}}render(){let{theme:e,options:t}=this.context,{props:n,state:r}=this,i=["fc-popover",e.getClass("popover")].concat(n.extraClassNames||[]);return fe(p("div",Object.assign({},n.extraAttrs,{id:n.id,className:i.join(" "),"aria-labelledby":r.titleId,ref:this.handleRootEl}),p("div",{className:"fc-popover-header "+e.getClass("popoverHeader")},p("span",{className:"fc-popover-title",id:r.titleId},n.title),p("span",{className:"fc-popover-close "+e.getIconClass("close"),title:t.closeHint,onClick:this.handleCloseClick})),p("div",{className:"fc-popover-body "+e.getClass("popoverContent")},n.children)),n.parentEl)}componentDidMount(){document.addEventListener("mousedown",this.handleDocumentMouseDown),document.addEventListener("keydown",this.handleDocumentKeyDown),this.updateSize()}componentWillUnmount(){document.removeEventListener("mousedown",this.handleDocumentMouseDown),document.removeEventListener("keydown",this.handleDocumentKeyDown)}updateSize(){let{isRtl:e}=this.context,{alignmentEl:t,alignGridTop:n}=this.props,{rootEl:r}=this,i=function(e){let t=ns(e),n=e.getBoundingClientRect();for(let e of t){let t=Hi(n,e.getBoundingClientRect());if(!t)return null;n=t}return n}(t);if(i){let s=r.getBoundingClientRect(),o=n?Oe(t,".fc-scrollgrid").getBoundingClientRect().top:i.top,a=e?i.right-s.width:i.left;o=Math.max(o,10),a=Math.min(a,document.documentElement.clientWidth-10-s.width),a=Math.max(a,10);let l=r.offsetParent.getBoundingClientRect();Be(r,{top:o-l.top,left:a-l.left})}}}class Eo extends ls{constructor(){super(...arguments),this.handleRootEl=e=>{this.rootEl=e,e?this.context.registerInteractiveComponent(this,{el:e,useEventCenter:!1}):this.context.unregisterInteractiveComponent(this)}}render(){let{options:e,dateEnv:t}=this.context,{props:n}=this,{startDate:r,todayRange:i,dateProfile:s}=n,o=t.format(r,e.dayPopoverFormat);return p(uo,{elRef:this.handleRootEl,date:r,dateProfile:s,todayRange:i},(t,r,i)=>p(bo,{elRef:i.ref,id:n.id,title:o,extraClassNames:["fc-more-popover"].concat(i.className||[]),extraAttrs:i,parentEl:n.parentEl,alignmentEl:n.alignmentEl,alignGridTop:n.alignGridTop,onClose:n.onClose},ho(e)&&p(t,{elTag:"div",elClasses:["fc-more-popover-misc"]}),n.children))}queryHit(e,t,n,r){let{rootEl:i,props:s}=this;return e>=0&&e=0&&t{this.linkEl=e,this.props.elRef&&Qn(this.props.elRef,e)},this.handleClick=e=>{let{props:t,context:n}=this,{moreLinkClick:r}=n.options,i=Do(t).start;function s(e){let{def:t,instance:r,range:i}=e.eventRange;return{event:new ti(n,t,r),start:n.dateEnv.toDate(i.start),end:n.dateEnv.toDate(i.end),isStart:e.isStart,isEnd:e.isEnd}}"function"==typeof r&&(r=r({date:i,allDay:Boolean(t.allDayDate),allSegs:t.allSegs.map(s),hiddenSegs:t.hiddenSegs.map(s),jsEvent:e,view:n.viewApi})),r&&"popover"!==r?"string"==typeof r&&n.calendarApi.zoomTo(i,r):this.setState({isPopoverOpen:!0})},this.handlePopoverClose=()=>{this.setState({isPopoverOpen:!1})}}render(){let{props:e,state:t}=this;return p(Ln.Consumer,null,n=>{let{viewApi:r,options:i,calendarApi:s}=n,{moreLinkText:o}=i,{moreCnt:a}=e,l=Do(e),c="function"==typeof o?o.call(s,a):`+${a} ${o}`,d=at(i.moreLinkHint,[a],c),u={num:a,shortText:"+"+a,text:c,view:r};return p(y,null,Boolean(e.moreCnt)&&p(Jn,{elTag:e.elTag||"a",elRef:this.handleLinkEl,elClasses:[...e.elClasses||[],"fc-more-link"],elStyle:e.elStyle,elAttrs:Object.assign(Object.assign(Object.assign({},e.elAttrs),Qe(this.handleClick)),{title:d,"aria-expanded":t.isPopoverOpen,"aria-controls":t.isPopoverOpen?t.popoverId:""}),renderProps:u,generatorName:"moreLinkContent",customGenerator:i.moreLinkContent,defaultGenerator:e.defaultGenerator||Ao,classNameGenerator:i.moreLinkClassNames,didMount:i.moreLinkDidMount,willUnmount:i.moreLinkWillUnmount},e.children),t.isPopoverOpen&&p(Eo,{id:t.popoverId,startDate:l.start,endDate:l.end,dateProfile:e.dateProfile,todayRange:e.todayRange,extraDateSpan:e.extraDateSpan,parentEl:this.parentEl,alignmentEl:e.alignmentElRef?e.alignmentElRef.current:this.linkEl,alignGridTop:e.alignGridTop,forceTimed:e.forceTimed,onClose:this.handlePopoverClose},e.popoverContent()))})}componentDidMount(){this.updateParentEl()}componentDidUpdate(){this.updateParentEl()}updateParentEl(){this.linkEl&&(this.parentEl=Oe(this.linkEl,".fc-view-harness"))}}function Ao(e){return e.text}function Do(e){if(e.allDayDate)return{start:e.allDayDate,end:wt(e.allDayDate,1)};let{hiddenSegs:t}=e;return{start:wo(t),end:(n=t,n.reduce(Ro).eventRange.range.end)};var n}function wo(e){return e.reduce(Co).eventRange.range.start}function Co(e,t){return e.eventRange.range.startt.eventRange.range.end?e:t}var xo={__proto__:null,BASE_OPTION_DEFAULTS:cn,BaseComponent:Gn,BgEvent:go,CalendarImpl:Ni,CalendarRoot:ki,ContentContainer:Jn,CustomRenderingStore:class extends class{constructor(){this.handlers=[]}set(e){this.currentValue=e;for(let t of this.handlers)t(e)}subscribe(e){this.handlers.push(e),void 0!==this.currentValue&&e(this.currentValue)}}{constructor(){super(...arguments),this.map=new Map}handle(e){const{map:t}=this;let n=!1;e.isActive?(t.set(e.id,e),n=!0):t.has(e.id)&&(t.delete(e.id),n=!0),n&&this.set(t)}},DateComponent:ls,DateEnv:Hn,DateProfileGenerator:gr,DayCellContainer:uo,DayHeader:_s,DaySeriesModel:ks,DayTableModel:Ms,DelayedRunner:Me,ElementDragging:ms,ElementScrollController:os,Emitter:Gr,EventContainer:so,EventImpl:ti,Interaction:Mi,MoreLinkContainer:So,NamedTimeZoneImpl:class{constructor(e){this.timeZoneName=e}},NowIndicatorContainer:lo,NowTimer:Rs,PositionCache:rs,RefMap:Vs,ScrollController:ss,ScrollResponder:Wn,Scroller:Fs,SegHierarchy:cs,SimpleScrollGrid:io,Slicer:Is,Splitter:Wi,StandardEvent:oo,TableDateCell:Ds,TableDowCell:Cs,Theme:Bn,ViewContainer:er,ViewContextType:Ln,WeekNumberContainer:vo,WindowScrollController:as,addDays:wt,addDurations:pt,addMs:Ct,addWeeks:Dt,allowContextMenu:tt,allowSelection:Ke,applyMutationToEventStore:$r,applyStyle:Be,asCleanDays:function(e){return e.years||e.months||e.milliseconds?0:e.days},asRoughMinutes:function(e){return yt(e)/6e4},asRoughMs:yt,asRoughSeconds:function(e){return yt(e)/1e3},binarySearch:ps,buildElAttrs:Zn,buildEntryKey:us,buildEventApis:ri,buildEventRangeKey:yi,buildIsoString:Ut,buildNavLinkAttrs:qi,buildSegTimeText:pi,collectFromHash:kn,combineEventUis:Pr,compareByFieldSpecs:rt,compareNumbers:lt,compareObjs:_n,computeEarliestSegStart:wo,computeEdges:Ki,computeFallbackHeaderFormat:Es,computeInnerRect:es,computeRect:ts,computeShrinkWidth:Gs,computeVisibleDayRange:dr,config:vs,constrainPoint:Bi,createDuration:ft,createEmptyEventStore:_r,createEventInstance:pr,createEventUi:Nr,createFormatter:an,diffDates:hr,diffDayAndTime:_t,diffDays:xt,diffPoints:zi,diffWeeks:Rt,diffWholeDays:kt,diffWholeWeeks:Tt,disableCursor:Xe,elementClosest:Oe,elementMatches:Ne,enableCursor:$e,eventTupleToStore:Rr,filterHash:Sn,findDirectChildren:function(e,t){let n=e instanceof HTMLElement?[e]:e,r=[];for(let e=0;e{let o=r.length,a=s.length,l=0;for(;l{let o={};for(let a in s)if(i[a])if(St(r[a],s[a]))o[a]=i[a];else{n&&n(i[a]);let r=e.apply(this,s[a]);o[a]=t&&t(r,i[a])?i[a]:r}else o[a]=e.apply(this,s[a]);return r=s,i=o,o}},memoizeObjArg:Qt,mergeEventStores:Tr,multiplyDuration:mt,padStart:ot,parseBusinessHours:qr,parseClassNames:Mr,parseDragMeta:bs,parseEventDef:wr,parseFieldSpecs:nt,parseMarker:Pn,pointInsideRect:Pi,preventContextMenu:et,preventDefault:Le,preventSelection:Je,rangeContainsMarker:lr,rangeContainsRange:ar,rangesEqual:sr,rangesIntersect:or,refineEventDef:Ar,refineProps:vn,removeElement:Ie,removeExact:function(e,t){let n=0,r=0;for(;r2&&(a.children=arguments.length>3?t.call(arguments,2):r),m(e.type,a,i||e.key,s||e.ref,null)},createElement:p,createRef:v,h:p,hydrate:function e(t,n){U(t,n,e)},get isValidElement(){return i},get options(){return n},render:U,toChildArray:M};const To=[],ko={code:"en",week:{dow:0,doy:4},direction:"ltr",buttonText:{prev:"prev",next:"next",prevYear:"prev year",nextYear:"next year",year:"year",today:"today",month:"month",week:"week",day:"day",list:"list"},weekText:"W",weekTextLong:"Week",closeHint:"Close",timeHint:"Time",eventHint:"Event",allDayText:"all-day",moreLinkText:"more",noEventsText:"No events to display"},Mo=Object.assign(Object.assign({},ko),{buttonHints:{prev:"Previous $0",next:"Next $0",today:(e,t)=>"day"===t?"Today":"This "+e},viewHint:"$0 view",navLinkHint:"Go to $0",moreLinkHint:e=>`Show ${e} more event${1===e?"":"s"}`});function Io(e){let t=e.length>0?e[0].code:"en",n=To.concat(e),r={en:Mo};for(let e of n)r[e.code]=e;return{map:r,defaultCode:t}}function Oo(e,t){return"object"!=typeof e||Array.isArray(e)?function(e,t){let n=[].concat(e||[]),r=function(e,t){for(let n=0;n0;e-=1){let n=r.slice(0,e).join("-");if(t[n])return t[n]}}return null}(n,t)||Mo;return No(e,n,r)}(e,t):No(e.code,[e.code],e)}function No(e,t,n){let r=En([ko,n],["buttonText"]);delete r.code;let{week:i}=r;return delete r.week,{codeArg:e,codes:t,week:i,simpleNumberFormat:new Intl.NumberFormat(e),options:r}}function Po(e){return{id:Ze(),name:e.name,premiumReleaseDate:e.premiumReleaseDate?new Date(e.premiumReleaseDate):void 0,deps:e.deps||[],reducers:e.reducers||[],isLoadingFuncs:e.isLoadingFuncs||[],contextInit:[].concat(e.contextInit||[]),eventRefiners:e.eventRefiners||{},eventDefMemberAdders:e.eventDefMemberAdders||[],eventSourceRefiners:e.eventSourceRefiners||{},isDraggableTransformers:e.isDraggableTransformers||[],eventDragMutationMassagers:e.eventDragMutationMassagers||[],eventDefMutationAppliers:e.eventDefMutationAppliers||[],dateSelectionTransformers:e.dateSelectionTransformers||[],datePointTransforms:e.datePointTransforms||[],dateSpanTransforms:e.dateSpanTransforms||[],views:e.views||{},viewPropsTransformers:e.viewPropsTransformers||[],isPropsValid:e.isPropsValid||null,externalDefTransforms:e.externalDefTransforms||[],viewContainerAppends:e.viewContainerAppends||[],eventDropTransformers:e.eventDropTransformers||[],componentInteractions:e.componentInteractions||[],calendarInteractions:e.calendarInteractions||[],themeClasses:e.themeClasses||{},eventSourceDefs:e.eventSourceDefs||[],cmdFormatter:e.cmdFormatter,recurringTypes:e.recurringTypes||[],namedTimeZonedImpl:e.namedTimeZonedImpl,initialView:e.initialView||"",elementDraggingImpl:e.elementDraggingImpl,optionChangeHandlers:e.optionChangeHandlers||{},scrollGridImpl:e.scrollGridImpl||null,listenerRefiners:e.listenerRefiners||{},optionRefiners:e.optionRefiners||{},propSetHandlers:e.propSetHandlers||{}}}function Ho(){let e,t=[],n=[];return(r,i)=>(e&&St(r,t)&&St(i,n)||(e=function(e,t){let n={},r={premiumReleaseDate:void 0,reducers:[],isLoadingFuncs:[],contextInit:[],eventRefiners:{},eventDefMemberAdders:[],eventSourceRefiners:{},isDraggableTransformers:[],eventDragMutationMassagers:[],eventDefMutationAppliers:[],dateSelectionTransformers:[],datePointTransforms:[],dateSpanTransforms:[],views:{},viewPropsTransformers:[],isPropsValid:null,externalDefTransforms:[],viewContainerAppends:[],eventDropTransformers:[],componentInteractions:[],calendarInteractions:[],themeClasses:{},eventSourceDefs:[],cmdFormatter:null,recurringTypes:[],namedTimeZonedImpl:null,initialView:"",elementDraggingImpl:null,optionChangeHandlers:{},scrollGridImpl:null,listenerRefiners:{},optionRefiners:{},propSetHandlers:{}};function i(e){for(let o of e){const e=o.name,a=n[e];void 0===a?(n[e]=o.id,i(o.deps),s=o,r={premiumReleaseDate:Bo((t=r).premiumReleaseDate,s.premiumReleaseDate),reducers:t.reducers.concat(s.reducers),isLoadingFuncs:t.isLoadingFuncs.concat(s.isLoadingFuncs),contextInit:t.contextInit.concat(s.contextInit),eventRefiners:Object.assign(Object.assign({},t.eventRefiners),s.eventRefiners),eventDefMemberAdders:t.eventDefMemberAdders.concat(s.eventDefMemberAdders),eventSourceRefiners:Object.assign(Object.assign({},t.eventSourceRefiners),s.eventSourceRefiners),isDraggableTransformers:t.isDraggableTransformers.concat(s.isDraggableTransformers),eventDragMutationMassagers:t.eventDragMutationMassagers.concat(s.eventDragMutationMassagers),eventDefMutationAppliers:t.eventDefMutationAppliers.concat(s.eventDefMutationAppliers),dateSelectionTransformers:t.dateSelectionTransformers.concat(s.dateSelectionTransformers),datePointTransforms:t.datePointTransforms.concat(s.datePointTransforms),dateSpanTransforms:t.dateSpanTransforms.concat(s.dateSpanTransforms),views:Object.assign(Object.assign({},t.views),s.views),viewPropsTransformers:t.viewPropsTransformers.concat(s.viewPropsTransformers),isPropsValid:s.isPropsValid||t.isPropsValid,externalDefTransforms:t.externalDefTransforms.concat(s.externalDefTransforms),viewContainerAppends:t.viewContainerAppends.concat(s.viewContainerAppends),eventDropTransformers:t.eventDropTransformers.concat(s.eventDropTransformers),calendarInteractions:t.calendarInteractions.concat(s.calendarInteractions),componentInteractions:t.componentInteractions.concat(s.componentInteractions),themeClasses:Object.assign(Object.assign({},t.themeClasses),s.themeClasses),eventSourceDefs:t.eventSourceDefs.concat(s.eventSourceDefs),cmdFormatter:s.cmdFormatter||t.cmdFormatter,recurringTypes:t.recurringTypes.concat(s.recurringTypes),namedTimeZonedImpl:s.namedTimeZonedImpl||t.namedTimeZonedImpl,initialView:t.initialView||s.initialView,elementDraggingImpl:t.elementDraggingImpl||s.elementDraggingImpl,optionChangeHandlers:Object.assign(Object.assign({},t.optionChangeHandlers),s.optionChangeHandlers),scrollGridImpl:s.scrollGridImpl||t.scrollGridImpl,listenerRefiners:Object.assign(Object.assign({},t.listenerRefiners),s.listenerRefiners),optionRefiners:Object.assign(Object.assign({},t.optionRefiners),s.optionRefiners),propSetHandlers:Object.assign(Object.assign({},t.propSetHandlers),s.propSetHandlers)}):a!==o.id&&console.warn(`Duplicate plugin '${e}'`)}var t,s}return e&&i(e),i(t),r}(r,i)),t=r,n=i,e)}function Bo(e,t){return void 0===e?t:void 0===t?e:new Date(Math.max(e.valueOf(),t.valueOf()))}class jo extends Bn{}function zo(e,t,n,r){if(t[e])return t[e];let i=function(e,t,n,r){let i=n[e],s=r[e],o=e=>i&&null!==i[e]?i[e]:s&&null!==s[e]?s[e]:null,a=o("component"),l=o("superType"),c=null;if(l){if(l===e)throw new Error("Can't have a custom view type that references itself");c=zo(l,t,n,r)}!a&&c&&(a=c.component);if(!a)return null;return{type:e,component:a,defaults:Object.assign(Object.assign({},c?c.defaults:{}),i?i.rawOptions:{}),overrides:Object.assign(Object.assign({},c?c.overrides:{}),s?s.rawOptions:{})}}(e,t,n,r);return i&&(t[e]=i),i}function Uo(e){return An(e,Wo)}function Wo(e){let t="function"==typeof e?{component:e}:e,{component:n}=t;return t.content?n=Lo(t):!n||n.prototype instanceof Gn||(n=Lo(Object.assign(Object.assign({},t),{content:n}))),{superType:t.type,component:n,rawOptions:t}}function Lo(e){return t=>p(Ln.Consumer,null,n=>p(Jn,{elTag:"div",elClasses:tr(n.viewSpec),renderProps:Object.assign(Object.assign({},t),{nextDayThreshold:n.options.nextDayThreshold}),generatorName:void 0,customGenerator:e.content,classNameGenerator:e.classNames,didMount:e.didMount,willUnmount:e.willUnmount}))}function Fo(e,t,n,r){let i=Uo(e),s=Uo(t.views);return An(function(e,t){let n,r={};for(n in e)zo(n,r,e,t);for(n in t)zo(n,r,e,t);return r}(i,s),e=>function(e,t,n,r,i){let s=e.overrides.duration||e.defaults.duration||r.duration||n.duration,o=null,a="",l="",c={};if(s&&(o=function(e){let t=JSON.stringify(e),n=Vo[t];void 0===n&&(n=ft(e),Vo[t]=n);return n}(s),o)){let e=Et(o);a=e.unit,1===e.value&&(l=a,c=t[a]?t[a].rawOptions:{})}let d=t=>{let n=t.buttonText||{},r=e.defaults.buttonTextKey;return null!=r&&null!=n[r]?n[r]:null!=n[e.type]?n[e.type]:null!=n[l]?n[l]:null},u=t=>{let n=t.buttonHints||{},r=e.defaults.buttonTextKey;return null!=r&&null!=n[r]?n[r]:null!=n[e.type]?n[e.type]:null!=n[l]?n[l]:null};return{type:e.type,component:e.component,duration:o,durationUnit:a,singleUnit:l,optionDefaults:e.defaults,optionOverrides:Object.assign(Object.assign({},c),e.overrides),buttonTextOverride:d(r)||d(n)||e.overrides.buttonText,buttonTextDefault:d(i)||e.defaults.buttonText||d(cn)||e.type,buttonTitleOverride:u(r)||u(n)||e.overrides.buttonHint,buttonTitleDefault:u(i)||e.defaults.buttonHint||u(cn)}}(e,s,t,n,r))}jo.prototype.classes={root:"fc-theme-standard",tableCellShaded:"fc-cell-shaded",buttonGroup:"fc-button-group",button:"fc-button fc-button-primary",buttonActive:"fc-button-active"},jo.prototype.baseIconClass="fc-icon",jo.prototype.iconClasses={close:"fc-icon-x",prev:"fc-icon-chevron-left",next:"fc-icon-chevron-right",prevYear:"fc-icon-chevrons-left",nextYear:"fc-icon-chevrons-right"},jo.prototype.rtlIconClasses={prev:"fc-icon-chevron-right",next:"fc-icon-chevron-left",prevYear:"fc-icon-chevrons-right",nextYear:"fc-icon-chevrons-left"},jo.prototype.iconOverrideOption="buttonIcons",jo.prototype.iconOverrideCustomButtonOption="icon",jo.prototype.iconOverridePrefix="fc-icon-";let Vo={};function Go(e,t,n){let r=t?t.activeRange:null;return Yo({},function(e,t){let n=zr(t),r=[].concat(e.eventSources||[]),i=[];e.initialEvents&&r.unshift(e.initialEvents);e.events&&r.unshift(e.events);for(let e of r){let r=jr(e,t,n);r&&i.push(r)}return i}(e,n),r,n)}function Qo(e,t,n,r){let i=n?n.activeRange:null;switch(t.type){case"ADD_EVENT_SOURCES":return Yo(e,t.sources,i,r);case"REMOVE_EVENT_SOURCE":return s=e,o=t.sourceId,Sn(s,e=>e.sourceId!==o);case"PREV":case"NEXT":case"CHANGE_DATE":case"CHANGE_VIEW_TYPE":return n?Zo(e,i,r):e;case"FETCH_EVENT_SOURCES":return Xo(e,t.sourceIds?Dn(t.sourceIds):Jo(e,r),i,t.isRefetch||!1,r);case"RECEIVE_EVENTS":case"RECEIVE_EVENT_ERROR":return function(e,t,n,r){let i=e[t];if(i&&n===i.latestFetchId)return Object.assign(Object.assign({},e),{[t]:Object.assign(Object.assign({},i),{isFetching:!1,fetchRange:r})});return e}(e,t.sourceId,t.fetchId,t.fetchRange);case"REMOVE_ALL_EVENT_SOURCES":return{};default:return e}var s,o}function qo(e){for(let t in e)if(e[t].isFetching)return!0;return!1}function Yo(e,t,n,r){let i={};for(let e of t)i[e.sourceId]=e;return n&&(i=Zo(i,n,r)),Object.assign(Object.assign({},e),i)}function Zo(e,t,n){return Xo(e,Sn(e,e=>function(e,t,n){if(!Ko(e,n))return!e.latestFetchId;return!n.options.lazyFetching||!e.fetchRange||e.isFetching||t.starte.fetchRange.end}(e,t,n)),t,!1,n)}function Xo(e,t,n,r,i){let s={};for(let o in e){let a=e[o];t[o]?s[o]=$o(a,n,r,i):s[o]=a}return s}function $o(e,t,n,r){let{options:i,calendarApi:s}=r,o=r.pluginHooks.eventSourceDefs[e.sourceDefId],a=Ze();return o.fetch({eventSource:e,range:t,isRefetch:n,context:r},n=>{let{rawEvents:o}=n;i.eventSourceSuccess&&(o=i.eventSourceSuccess.call(s,o,n.response)||o),e.success&&(o=e.success.call(s,o,n.response)||o),r.dispatch({type:"RECEIVE_EVENTS",sourceId:e.sourceId,fetchId:a,fetchRange:t,rawEvents:o})},n=>{let o=!1;i.eventSourceFailure&&(i.eventSourceFailure.call(s,n),o=!0),e.failure&&(e.failure(n),o=!0),o||console.warn(n.message,n),r.dispatch({type:"RECEIVE_EVENT_ERROR",sourceId:e.sourceId,fetchId:a,fetchRange:t,error:n})}),Object.assign(Object.assign({},e),{isFetching:!0,latestFetchId:a})}function Jo(e,t){return Sn(e,e=>Ko(e,t))}function Ko(e,t){return!t.pluginHooks.eventSourceDefs[e.sourceDefId].ignoreRange}function ea(e,t){switch(t.type){case"UNSELECT_DATES":return null;case"SELECT_DATES":return t.selection;default:return e}}function ta(e,t){switch(t.type){case"UNSELECT_EVENT":return"";case"SELECT_EVENT":return t.eventInstanceId;default:return e}}function na(e,t){let n;switch(t.type){case"UNSET_EVENT_DRAG":return null;case"SET_EVENT_DRAG":return n=t.state,{affectedEvents:n.affectedEvents,mutatedEvents:n.mutatedEvents,isEvent:n.isEvent};default:return e}}function ra(e,t){let n;switch(t.type){case"UNSET_EVENT_RESIZE":return null;case"SET_EVENT_RESIZE":return n=t.state,{affectedEvents:n.affectedEvents,mutatedEvents:n.mutatedEvents,isEvent:n.isEvent};default:return e}}function ia(e,t,n,r,i){return{header:e.headerToolbar?sa(e.headerToolbar,e,t,n,r,i):null,footer:e.footerToolbar?sa(e.footerToolbar,e,t,n,r,i):null}}function sa(e,t,n,r,i,s){let o={},a=[],l=!1;for(let c in e){let d=oa(e[c],t,n,r,i,s);o[c]=d.widgets,a.push(...d.viewsWithButtons),l=l||d.hasTitle}return{sectionWidgets:o,viewsWithButtons:a,hasTitle:l}}function oa(e,t,n,r,i,s){let o="rtl"===t.direction,a=t.customButtons||{},l=n.buttonText||{},c=t.buttonText||{},d=n.buttonHints||{},u=t.buttonHints||{},h=e?e.split(" "):[],f=[],g=!1;return{widgets:h.map(e=>e.split(",").map(e=>{if("title"===e)return g=!0,{buttonName:e};let n,h,p,m,v,y;if(n=a[e])p=e=>{n.click&&n.click.call(e.target,e,e.target)},(m=r.getCustomButtonIconClass(n))||(m=r.getIconClass(e,o))||(v=n.text),y=n.hint||n.text;else if(h=i[e]){f.push(e),p=()=>{s.changeView(e)},(v=h.buttonTextOverride)||(m=r.getIconClass(e,o))||(v=h.buttonTextDefault);let n=h.buttonTextOverride||h.buttonTextDefault;y=at(h.buttonTitleOverride||h.buttonTitleDefault||t.viewHint,[n,e],n)}else if(s[e])if(p=()=>{s[e]()},(v=l[e])||(m=r.getIconClass(e,o))||(v=c[e]),"prevYear"===e||"nextYear"===e){let t="prevYear"===e?"prev":"next";y=at(d[t]||u[t],[c.year||"year","year"],c[e])}else y=t=>at(d[e]||u[e],[c[t]||t,t],c[e]);return{buttonName:e,buttonClick:p,buttonIcon:m,buttonText:v,buttonHint:y}})),viewsWithButtons:f,hasTitle:g}}class aa{constructor(e,t,n){this.type=e,this.getCurrentData=t,this.dateEnv=n}get calendar(){return this.getCurrentData().calendarApi}get title(){return this.getCurrentData().viewTitle}get activeStart(){return this.dateEnv.toDate(this.getCurrentData().dateProfile.activeRange.start)}get activeEnd(){return this.dateEnv.toDate(this.getCurrentData().dateProfile.activeRange.end)}get currentStart(){return this.dateEnv.toDate(this.getCurrentData().dateProfile.currentRange.start)}get currentEnd(){return this.dateEnv.toDate(this.getCurrentData().dateProfile.currentRange.end)}getOption(e){return this.getCurrentData().options[e]}}function la(e,t){let n=wn(t.getCurrentData().eventSources);if(1===n.length&&1===e.length&&Array.isArray(n[0]._raw)&&Array.isArray(e[0]))return void t.dispatch({type:"RESET_RAW_EVENTS",sourceId:n[0].sourceId,rawEvents:e[0]});let r=[];for(let t of e){let e=!1;for(let r=0;rArray.isArray(e.events)?e.events:null,fetch(e,t){t({rawEvents:e.eventSource.meta})}}]}),Po({name:"func-event-source",eventSourceDefs:[{parseMeta:e=>"function"==typeof e.events?e.events:null,fetch(e,t,n){const{dateEnv:r}=e.context;Ci(e.eventSource.meta.bind(null,Di(e.range,r)),e=>t({rawEvents:e}),n)}}]}),Po({name:"json-event-source",eventSourceRefiners:{method:String,extraParams:yn,startParam:String,endParam:String,timeZoneParam:String},eventSourceDefs:[{parseMeta:e=>!e.url||"json"!==e.format&&e.format?null:{url:e.url,format:"json",method:(e.method||"GET").toUpperCase(),extraParams:e.extraParams,startParam:e.startParam,endParam:e.endParam,timeZoneParam:e.timeZoneParam},fetch(e,t,n){const{meta:r}=e.eventSource,i=function(e,t,n){let r,i,s,o,{dateEnv:a,options:l}=n,c={};r=e.startParam,null==r&&(r=l.startParam);i=e.endParam,null==i&&(i=l.endParam);s=e.timeZoneParam,null==s&&(s=l.timeZoneParam);o="function"==typeof e.extraParams?e.extraParams():e.extraParams||{};Object.assign(c,o),c[r]=a.formatIso(t.start),c[i]=a.formatIso(t.end),"local"!==a.timeZone&&(c[s]=a.timeZone);return c}(r,e.range,e.context);xi(r.method,r.url,i).then(([e,n])=>{t({rawEvents:e,response:n})},n)}}]}),Po({name:"simple-recurring-event",recurringTypes:[{parse(e,t){if(e.daysOfWeek||e.startTime||e.endTime||e.startRecur||e.endRecur){let i,s={daysOfWeek:e.daysOfWeek||null,startTime:e.startTime||null,endTime:e.endTime||null,startRecur:e.startRecur?t.createMarker(e.startRecur):null,endRecur:e.endRecur?t.createMarker(e.endRecur):null};return e.duration&&(i=e.duration),!i&&e.startTime&&e.endTime&&(n=e.endTime,r=e.startTime,i={years:n.years-r.years,months:n.months-r.months,days:n.days-r.days,milliseconds:n.milliseconds-r.milliseconds}),{allDayGuess:Boolean(!e.startTime&&!e.endTime),duration:i,typeData:s}}var n,r;return null},expand(e,t,n){let r=ir(t,{start:e.startRecur,end:e.endRecur});return r?function(e,t,n,r){let i=e?Dn(e):null,s=Mt(n.start),o=n.end,a=[];for(;sqo(e.eventSources)],propSetHandlers:{dateProfile:function(e,t){t.emitter.trigger("datesSet",Object.assign(Object.assign({},Di(e.activeRange,t.dateEnv)),{view:t.viewApi}))},eventStore:function(e,t){let{emitter:n}=t;n.hasHandlers("eventsSet")&&n.trigger("eventsSet",ri(e,t))}}})];class da{constructor(e,t){this.runTaskOption=e,this.drainedOption=t,this.queue=[],this.delayedRunner=new Me(this.drain.bind(this))}request(e,t){this.queue.push(e),this.delayedRunner.request(t)}pause(e){this.delayedRunner.pause(e)}resume(e,t){this.delayedRunner.resume(e,t)}drain(){let{queue:e}=this;for(;e.length;){let t,n=[];for(;t=e.shift();)this.runTask(t),n.push(t);this.drained(n)}}runTask(e){this.runTaskOption&&this.runTaskOption(e)}drained(e){this.drainedOption&&this.drainedOption(e)}}function ua(e,t,n){let r;return r=/^(year|month)$/.test(e.currentRangeUnit)?e.currentRange:e.activeRange,n.formatRange(r.start,r.end,an(t.titleFormat||function(e){let{currentRangeUnit:t}=e;if("year"===t)return{year:"numeric"};if("month"===t)return{year:"numeric",month:"long"};let n=kt(e.currentRange.start,e.currentRange.end);if(null!==n&&n>1)return{year:"numeric",month:"short",day:"numeric"};return{year:"numeric",month:"long",day:"numeric"}}(e)),{isEndExclusive:e.isRangeAllDay,defaultSeparator:t.titleRangeSeparator})}class ha{constructor(e){this.computeCurrentViewData=Gt(this._computeCurrentViewData),this.organizeRawLocales=Gt(Io),this.buildLocale=Gt(Oo),this.buildPluginHooks=Ho(),this.buildDateEnv=Gt(fa),this.buildTheme=Gt(ga),this.parseToolbars=Gt(ia),this.buildViewSpecs=Gt(Fo),this.buildDateProfileGenerator=Qt(pa),this.buildViewApi=Gt(ma),this.buildViewUiProps=Qt(ba),this.buildEventUiBySource=Gt(va,Cn),this.buildEventUiBases=Gt(ya),this.parseContextBusinessHours=Qt(Sa),this.buildTitle=Gt(ua),this.emitter=new Gr,this.actionRunner=new da(this._handleAction.bind(this),this.updateData.bind(this)),this.currentCalendarOptionsInput={},this.currentCalendarOptionsRefined={},this.currentViewOptionsInput={},this.currentViewOptionsRefined={},this.currentCalendarOptionsRefiners={},this.optionsForRefining=[],this.optionsForHandling=[],this.getCurrentData=()=>this.data,this.dispatch=e=>{this.actionRunner.request(e)},this.props=e,this.actionRunner.pause();let t={},n=this.computeOptionsData(e.optionOverrides,t,e.calendarApi),r=n.calendarOptions.initialView||n.pluginHooks.initialView,i=this.computeCurrentViewData(r,n,e.optionOverrides,t);e.calendarApi.currentDataManager=this,this.emitter.setThisContext(e.calendarApi),this.emitter.setOptions(i.options);let s=function(e,t){let n=e.initialDate;return null!=n?t.createMarker(n):fr(e.now,t)}(n.calendarOptions,n.dateEnv),o=i.dateProfileGenerator.build(s);lr(o.activeRange,s)||(s=o.currentRange.start);let a={dateEnv:n.dateEnv,options:n.calendarOptions,pluginHooks:n.pluginHooks,calendarApi:e.calendarApi,dispatch:this.dispatch,emitter:this.emitter,getCurrentData:this.getCurrentData};for(let e of n.pluginHooks.contextInit)e(a);let l=Go(n.calendarOptions,o,a),c={dynamicOptionOverrides:t,currentViewType:r,currentDate:s,dateProfile:o,businessHours:this.parseContextBusinessHours(a),eventSources:l,eventUiBases:{},eventStore:{defs:{},instances:{}},renderableEventStore:{defs:{},instances:{}},dateSelection:null,eventSelection:"",eventDrag:null,eventResize:null,selectionConfig:this.buildViewUiProps(a).selectionConfig},d=Object.assign(Object.assign({},a),c);for(let e of n.pluginHooks.reducers)Object.assign(c,e(null,null,d));Ea(c,a)&&this.emitter.trigger("loading",!0),this.state=c,this.updateData(),this.actionRunner.resume()}resetOptions(e,t){let{props:n}=this;void 0===t?n.optionOverrides=e:(n.optionOverrides=Object.assign(Object.assign({},n.optionOverrides||{}),e),this.optionsForRefining.push(...t)),(void 0===t||t.length)&&this.actionRunner.request({type:"NOTHING"})}_handleAction(e){let{props:t,state:n,emitter:r}=this,i=function(e,t){switch(t.type){case"SET_OPTION":return Object.assign(Object.assign({},e),{[t.optionName]:t.rawOptionValue});default:return e}}(n.dynamicOptionOverrides,e),s=this.computeOptionsData(t.optionOverrides,i,t.calendarApi),o=function(e,t){switch(t.type){case"CHANGE_VIEW_TYPE":e=t.viewType}return e}(n.currentViewType,e),a=this.computeCurrentViewData(o,s,t.optionOverrides,i);t.calendarApi.currentDataManager=this,r.setThisContext(t.calendarApi),r.setOptions(a.options);let l={dateEnv:s.dateEnv,options:s.calendarOptions,pluginHooks:s.pluginHooks,calendarApi:t.calendarApi,dispatch:this.dispatch,emitter:r,getCurrentData:this.getCurrentData},{currentDate:c,dateProfile:d}=n;this.data&&this.data.dateProfileGenerator!==a.dateProfileGenerator&&(d=a.dateProfileGenerator.build(c)),c=function(e,t){switch(t.type){case"CHANGE_DATE":return t.dateMarker;default:return e}}(c,e),d=function(e,t,n,r){let i;switch(t.type){case"CHANGE_VIEW_TYPE":return r.build(t.dateMarker||n);case"CHANGE_DATE":return r.build(t.dateMarker);case"PREV":if(i=r.buildPrev(e,n),i.isValid)return i;break;case"NEXT":if(i=r.buildNext(e,n),i.isValid)return i}return e}(d,e,c,a.dateProfileGenerator),"PREV"!==e.type&&"NEXT"!==e.type&&lr(d.currentRange,c)||(c=d.currentRange.start);let u=Qo(n.eventSources,e,d,l),h=Ur(n.eventStore,e,u,d,l),f=qo(u)&&!a.options.progressiveEventRendering&&n.renderableEventStore||h,{eventUiSingleBase:g,selectionConfig:p}=this.buildViewUiProps(l),m=this.buildEventUiBySource(u),v={dynamicOptionOverrides:i,currentViewType:o,currentDate:c,dateProfile:d,eventSources:u,eventStore:h,renderableEventStore:f,selectionConfig:p,eventUiBases:this.buildEventUiBases(f.defs,g,m),businessHours:this.parseContextBusinessHours(l),dateSelection:ea(n.dateSelection,e),eventSelection:ta(n.eventSelection,e),eventDrag:na(n.eventDrag,e),eventResize:ra(n.eventResize,e)},y=Object.assign(Object.assign({},l),v);for(let t of s.pluginHooks.reducers)Object.assign(v,t(n,e,y));let b=Ea(n,l),E=Ea(v,l);!b&&E?r.trigger("loading",!0):b&&!E&&r.trigger("loading",!1),this.state=v,t.onAction&&t.onAction(e)}updateData(){let{props:e,state:t}=this,n=this.data,r=this.computeOptionsData(e.optionOverrides,t.dynamicOptionOverrides,e.calendarApi),i=this.computeCurrentViewData(t.currentViewType,r,e.optionOverrides,t.dynamicOptionOverrides),s=this.data=Object.assign(Object.assign(Object.assign({viewTitle:this.buildTitle(t.dateProfile,i.options,r.dateEnv),calendarApi:e.calendarApi,dispatch:this.dispatch,emitter:this.emitter,getCurrentData:this.getCurrentData},r),i),t),o=r.pluginHooks.optionChangeHandlers,a=n&&n.calendarOptions,l=r.calendarOptions;if(a&&a!==l){a.timeZone!==l.timeZone&&(t.eventSources=s.eventSources=function(e,t,n){let r=t?t.activeRange:null;return Xo(e,Jo(e,n),r,!0,n)}(s.eventSources,t.dateProfile,s),t.eventStore=s.eventStore=Fr(s.eventStore,n.dateEnv,s.dateEnv),t.renderableEventStore=s.renderableEventStore=Fr(s.renderableEventStore,n.dateEnv,s.dateEnv));for(let e in o)-1===this.optionsForHandling.indexOf(e)&&a[e]===l[e]||o[e](l[e],s)}this.optionsForHandling=[],e.onData&&e.onData(s)}computeOptionsData(e,t,n){if(!this.optionsForRefining.length&&e===this.stableOptionOverrides&&t===this.stableDynamicOptionOverrides)return this.stableCalendarOptionsData;let{refinedOptions:r,pluginHooks:i,localeDefaults:s,availableLocaleData:o,extra:a}=this.processRawCalendarOptions(e,t);Aa(a);let l=this.buildDateEnv(r.timeZone,r.locale,r.weekNumberCalculation,r.firstDay,r.weekText,i,o,r.defaultRangeSeparator),c=this.buildViewSpecs(i.views,this.stableOptionOverrides,this.stableDynamicOptionOverrides,s),d=this.buildTheme(r,i),u=this.parseToolbars(r,this.stableOptionOverrides,d,c,n);return this.stableCalendarOptionsData={calendarOptions:r,pluginHooks:i,dateEnv:l,viewSpecs:c,theme:d,toolbarConfig:u,localeDefaults:s,availableRawLocales:o.map}}processRawCalendarOptions(e,t){let{locales:n,locale:r}=mn([cn,e,t]),i=this.organizeRawLocales(n),s=i.map,o=this.buildLocale(r||i.defaultCode,s).options,a=this.buildPluginHooks(e.plugins||[],ca),l=this.currentCalendarOptionsRefiners=Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({},ln),dn),un),a.listenerRefiners),a.optionRefiners),c={},d=mn([cn,o,e,t]),u={},h=this.currentCalendarOptionsInput,f=this.currentCalendarOptionsRefined,g=!1;for(let e in d)-1===this.optionsForRefining.indexOf(e)&&(d[e]===h[e]||hn[e]&&e in h&&hn[e](h[e],d[e]))?u[e]=f[e]:l[e]?(u[e]=l[e](d[e]),g=!0):c[e]=h[e];return g&&(this.currentCalendarOptionsInput=d,this.currentCalendarOptionsRefined=u,this.stableOptionOverrides=e,this.stableDynamicOptionOverrides=t),this.optionsForHandling.push(...this.optionsForRefining),this.optionsForRefining=[],{rawOptions:this.currentCalendarOptionsInput,refinedOptions:this.currentCalendarOptionsRefined,pluginHooks:a,availableLocaleData:i,localeDefaults:o,extra:c}}_computeCurrentViewData(e,t,n,r){let i=t.viewSpecs[e];if(!i)throw new Error(`viewType "${e}" is not available. Please make sure you've loaded all neccessary plugins`);let{refinedOptions:s,extra:o}=this.processRawViewOptions(i,t.pluginHooks,t.localeDefaults,n,r);return Aa(o),{viewSpec:i,options:s,dateProfileGenerator:this.buildDateProfileGenerator({dateProfileGeneratorClass:i.optionDefaults.dateProfileGeneratorClass,duration:i.duration,durationUnit:i.durationUnit,usesMinMaxTime:i.optionDefaults.usesMinMaxTime,dateEnv:t.dateEnv,calendarApi:this.props.calendarApi,slotMinTime:s.slotMinTime,slotMaxTime:s.slotMaxTime,showNonCurrentDates:s.showNonCurrentDates,dayCount:s.dayCount,dateAlignment:s.dateAlignment,dateIncrement:s.dateIncrement,hiddenDays:s.hiddenDays,weekends:s.weekends,nowInput:s.now,validRangeInput:s.validRange,visibleRangeInput:s.visibleRange,fixedWeekCount:s.fixedWeekCount}),viewApi:this.buildViewApi(e,this.getCurrentData,t.dateEnv)}}processRawViewOptions(e,t,n,r,i){let s=mn([cn,e.optionDefaults,n,r,e.optionOverrides,i]),o=Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({},ln),dn),un),pn),t.listenerRefiners),t.optionRefiners),a={},l=this.currentViewOptionsInput,c=this.currentViewOptionsRefined,d=!1,u={};for(let e in s)s[e]===l[e]||hn[e]&&hn[e](s[e],l[e])?a[e]=c[e]:(s[e]===this.currentCalendarOptionsInput[e]||hn[e]&&hn[e](s[e],this.currentCalendarOptionsInput[e])?e in this.currentCalendarOptionsRefined&&(a[e]=this.currentCalendarOptionsRefined[e]):o[e]?a[e]=o[e](s[e]):u[e]=s[e],d=!0);return d&&(this.currentViewOptionsInput=s,this.currentViewOptionsRefined=a),{rawOptions:this.currentViewOptionsInput,refinedOptions:this.currentViewOptionsRefined,extra:u}}}function fa(e,t,n,r,i,s,o,a){let l=Oo(t||o.defaultCode,o.map);return new Hn({calendarSystem:"gregory",timeZone:e,namedTimeZoneImpl:s.namedTimeZonedImpl,locale:l,weekNumberCalculation:n,firstDay:r,weekText:i,cmdFormatter:s.cmdFormatter,defaultSeparator:a})}function ga(e,t){return new(t.themeClasses[e.themeSystem]||jo)(e)}function pa(e){return new(e.dateProfileGeneratorClass||gr)(e)}function ma(e,t,n){return new aa(e,t,n)}function va(e){return An(e,e=>e.ui)}function ya(e,t,n){let r={"":t};for(let t in e){let i=e[t];i.sourceId&&n[i.sourceId]&&(r[t]=n[i.sourceId])}return r}function ba(e){let{options:t}=e;return{eventUiSingleBase:Nr({display:t.eventDisplay,editable:t.editable,startEditable:t.eventStartEditable,durationEditable:t.eventDurationEditable,constraint:t.eventConstraint,overlap:"boolean"==typeof t.eventOverlap?t.eventOverlap:void 0,allow:t.eventAllow,backgroundColor:t.eventBackgroundColor,borderColor:t.eventBorderColor,textColor:t.eventTextColor,color:t.eventColor},e),selectionConfig:Nr({constraint:t.selectConstraint,overlap:"boolean"==typeof t.selectOverlap?t.selectOverlap:void 0,allow:t.selectAllow},e)}}function Ea(e,t){for(let n of t.pluginHooks.isLoadingFuncs)if(n(e))return!0;return!1}function Sa(e){return qr(e.options.businessHours,e)}function Aa(e,t){for(let n in e)console.warn(`Unknown option '${n}'`+(t?` for view '${t}'`:""))}class Da extends Gn{render(){return p("div",{className:"fc-toolbar-chunk"},...this.props.widgetGroups.map(e=>this.renderWidgetGroup(e)))}renderWidgetGroup(e){let{props:t}=this,{theme:n}=this.context,r=[],i=!0;for(let s of e){let{buttonName:e,buttonClick:o,buttonText:a,buttonIcon:l,buttonHint:c}=s;if("title"===e)i=!1,r.push(p("h2",{className:"fc-toolbar-title",id:t.titleId},t.title));else{let i=e===t.activeButton,s=!t.isTodayEnabled&&"today"===e||!t.isPrevEnabled&&"prev"===e||!t.isNextEnabled&&"next"===e,d=[`fc-${e}-button`,n.getClass("button")];i&&d.push(n.getClass("buttonActive")),r.push(p("button",{type:"button",title:"function"==typeof c?c(t.navUnit):c,disabled:s,"aria-pressed":i,className:d.join(" "),onClick:o},a||(l?p("span",{className:l,role:"img"}):"")))}}if(r.length>1){return p("div",{className:i&&n.getClass("buttonGroup")||""},...r)}return r[0]}}class wa extends Gn{render(){let e,t,{model:n,extraClassName:r}=this.props,i=!1,s=n.sectionWidgets,o=s.center;return s.left?(i=!0,e=s.left):e=s.start,s.right?(i=!0,t=s.right):t=s.end,p("div",{className:[r||"","fc-toolbar",i?"fc-toolbar-ltr":""].join(" ")},this.renderSection("start",e||[]),this.renderSection("center",o||[]),this.renderSection("end",t||[]))}renderSection(e,t){let{props:n}=this;return p(Da,{key:e,widgetGroups:t,title:n.title,navUnit:n.navUnit,activeButton:n.activeButton,isTodayEnabled:n.isTodayEnabled,isPrevEnabled:n.isPrevEnabled,isNextEnabled:n.isNextEnabled,titleId:n.titleId})}}class Ca extends Gn{constructor(){super(...arguments),this.state={availableWidth:null},this.handleEl=e=>{this.el=e,Qn(this.props.elRef,e),this.updateAvailableWidth()},this.handleResize=()=>{this.updateAvailableWidth()}}render(){let{props:e,state:t}=this,{aspectRatio:n}=e,r=["fc-view-harness",n||e.liquid||e.height?"fc-view-harness-active":"fc-view-harness-passive"],i="",s="";return n?null!==t.availableWidth?i=t.availableWidth/n:s=1/n*100+"%":i=e.height||"",p("div",{"aria-labelledby":e.labeledById,ref:this.handleEl,className:r.join(" "),style:{height:i,paddingBottom:s}},e.children)}componentDidMount(){this.context.addResizeHandler(this.handleResize)}componentWillUnmount(){this.context.removeResizeHandler(this.handleResize)}updateAvailableWidth(){this.el&&this.props.aspectRatio&&this.setState({availableWidth:this.el.offsetWidth})}}class Ra extends Mi{constructor(e){super(e),this.handleSegClick=(e,t)=>{let{component:n}=this,{context:r}=n,i=ai(t);if(i&&n.isValidSegDownEl(e.target)){let s=Oe(e.target,".fc-event-forced-url"),o=s?s.querySelector("a[href]").href:"";r.emitter.trigger("eventClick",{el:t,event:new ti(n.context,i.eventRange.def,i.eventRange.instance),jsEvent:e,view:r.viewApi}),o&&!e.defaultPrevented&&(window.location.href=o)}},this.destroy=Fe(e.el,"click",".fc-event",this.handleSegClick)}}class xa extends Mi{constructor(e){super(e),this.handleEventElRemove=e=>{e===this.currentSegEl&&this.handleSegLeave(null,this.currentSegEl)},this.handleSegEnter=(e,t)=>{ai(t)&&(this.currentSegEl=t,this.triggerEvent("eventMouseEnter",e,t))},this.handleSegLeave=(e,t)=>{this.currentSegEl&&(this.currentSegEl=null,this.triggerEvent("eventMouseLeave",e,t))},this.removeHoverListeners=function(e,t,n,r){let i;return Fe(e,"mouseover",t,(e,t)=>{if(t!==i){i=t,n(e,t);let s=e=>{i=null,r(e,t),t.removeEventListener("mouseleave",s)};t.addEventListener("mouseleave",s)}})}(e.el,".fc-event",this.handleSegEnter,this.handleSegLeave)}destroy(){this.removeHoverListeners()}triggerEvent(e,t,n){let{component:r}=this,{context:i}=r,s=ai(n);t&&!r.isValidSegDownEl(t.target)||i.emitter.trigger(e,{el:n,event:new ti(i,s.eventRange.def,s.eventRange.instance),jsEvent:t,view:i.viewApi})}}class _a extends Vn{constructor(){super(...arguments),this.buildViewContext=Gt(Fn),this.buildViewPropTransformers=Gt(ka),this.buildToolbarProps=Gt(Ta),this.headerRef={current:null},this.footerRef={current:null},this.interactionsStore={},this.state={viewLabelId:We()},this.registerInteractiveComponent=(e,t)=>{let n=function(e,t){return{component:e,el:t.el,useEventCenter:null==t.useEventCenter||t.useEventCenter,isHitComboAllowed:t.isHitComboAllowed||null}}(e,t),r=[Ra,xa].concat(this.props.pluginHooks.componentInteractions).map(e=>new e(n));this.interactionsStore[e.uid]=r,Oi[e.uid]=n},this.unregisterInteractiveComponent=e=>{let t=this.interactionsStore[e.uid];if(t){for(let e of t)e.destroy();delete this.interactionsStore[e.uid]}delete Oi[e.uid]},this.resizeRunner=new Me(()=>{this.props.emitter.trigger("_resize",!0),this.props.emitter.trigger("windowResize",{view:this.props.viewApi})}),this.handleWindowResize=e=>{let{options:t}=this.props;t.handleWindowResize&&e.target===window&&this.resizeRunner.request(t.windowResizeDelay)}}render(){let e,{props:t}=this,{toolbarConfig:n,options:r}=t,i=this.buildToolbarProps(t.viewSpec,t.dateProfile,t.dateProfileGenerator,t.currentDate,fr(t.options.now,t.dateEnv),t.viewTitle),s=!1,o="";t.isHeightAuto||t.forPrint?o="":null!=r.height?s=!0:null!=r.contentHeight?o=r.contentHeight:e=Math.max(r.aspectRatio,.5);let a=this.buildViewContext(t.viewSpec,t.viewApi,t.options,t.dateProfileGenerator,t.dateEnv,t.theme,t.pluginHooks,t.dispatch,t.getCurrentData,t.emitter,t.calendarApi,this.registerInteractiveComponent,this.unregisterInteractiveComponent),l=n.header&&n.header.hasTitle?this.state.viewLabelId:void 0;return p(Ln.Provider,{value:a},n.header&&p(wa,Object.assign({ref:this.headerRef,extraClassName:"fc-header-toolbar",model:n.header,titleId:l},i)),p(Ca,{liquid:s,height:o,aspectRatio:e,labeledById:l},this.renderView(t),this.buildAppendContent()),n.footer&&p(wa,Object.assign({ref:this.footerRef,extraClassName:"fc-footer-toolbar",model:n.footer,titleId:""},i)))}componentDidMount(){let{props:e}=this;this.calendarInteractions=e.pluginHooks.calendarInteractions.map(t=>new t(e)),window.addEventListener("resize",this.handleWindowResize);let{propSetHandlers:t}=e.pluginHooks;for(let n in t)t[n](e[n],e)}componentDidUpdate(e){let{props:t}=this,{propSetHandlers:n}=t.pluginHooks;for(let r in n)t[r]!==e[r]&&n[r](t[r],t)}componentWillUnmount(){window.removeEventListener("resize",this.handleWindowResize),this.resizeRunner.clear();for(let e of this.calendarInteractions)e.destroy();this.props.emitter.trigger("_unmount")}buildAppendContent(){let{props:e}=this;return p(y,{},...e.pluginHooks.viewContainerAppends.map(t=>t(e)))}renderView(e){let{pluginHooks:t}=e,{viewSpec:n}=e,r={dateProfile:e.dateProfile,businessHours:e.businessHours,eventStore:e.renderableEventStore,eventUiBases:e.eventUiBases,dateSelection:e.dateSelection,eventSelection:e.eventSelection,eventDrag:e.eventDrag,eventResize:e.eventResize,isHeightAuto:e.isHeightAuto,forPrint:e.forPrint},i=this.buildViewPropTransformers(t.viewPropsTransformers);for(let t of i)Object.assign(r,t.transform(r,e));return p(n.component,Object.assign({},r))}}function Ta(e,t,n,r,i,s){let o=n.build(i,void 0,!1),a=n.buildPrev(t,r,!1),l=n.buildNext(t,r,!1);return{title:s,activeButton:e.type,navUnit:e.singleUnit,isTodayEnabled:o.isValid&&!lr(t.currentRange,i),isPrevEnabled:a.isValid,isNextEnabled:l.isValid}}function ka(e){return e.map(e=>new e)}function Ma(e){let t=Oo(e.locale||"en",Io([]).map);return new Hn(Object.assign(Object.assign({timeZone:cn.timeZone,calendarSystem:"gregory"},e),{locale:t}))}vs.touchMouseIgnoreWait=500;let Ia=0,Oa=0,Na=!1;class Pa{constructor(e){this.subjectEl=null,this.selector="",this.handleSelector="",this.shouldIgnoreMove=!1,this.shouldWatchScroll=!0,this.isDragging=!1,this.isTouchDragging=!1,this.wasTouchScroll=!1,this.handleMouseDown=e=>{if(!this.shouldIgnoreMouse()&&function(e){return 0===e.button&&!e.ctrlKey}(e)&&this.tryStart(e)){let t=this.createEventFromMouse(e,!0);this.emitter.trigger("pointerdown",t),this.initScrollWatch(t),this.shouldIgnoreMove||document.addEventListener("mousemove",this.handleMouseMove),document.addEventListener("mouseup",this.handleMouseUp)}},this.handleMouseMove=e=>{let t=this.createEventFromMouse(e);this.recordCoords(t),this.emitter.trigger("pointermove",t)},this.handleMouseUp=e=>{document.removeEventListener("mousemove",this.handleMouseMove),document.removeEventListener("mouseup",this.handleMouseUp),this.emitter.trigger("pointerup",this.createEventFromMouse(e)),this.cleanup()},this.handleTouchStart=e=>{if(this.tryStart(e)){this.isTouchDragging=!0;let t=this.createEventFromTouch(e,!0);this.emitter.trigger("pointerdown",t),this.initScrollWatch(t);let n=e.target;this.shouldIgnoreMove||n.addEventListener("touchmove",this.handleTouchMove),n.addEventListener("touchend",this.handleTouchEnd),n.addEventListener("touchcancel",this.handleTouchEnd),window.addEventListener("scroll",this.handleTouchScroll,!0)}},this.handleTouchMove=e=>{let t=this.createEventFromTouch(e);this.recordCoords(t),this.emitter.trigger("pointermove",t)},this.handleTouchEnd=e=>{if(this.isDragging){let t=e.target;t.removeEventListener("touchmove",this.handleTouchMove),t.removeEventListener("touchend",this.handleTouchEnd),t.removeEventListener("touchcancel",this.handleTouchEnd),window.removeEventListener("scroll",this.handleTouchScroll,!0),this.emitter.trigger("pointerup",this.createEventFromTouch(e)),this.cleanup(),this.isTouchDragging=!1,Ia+=1,setTimeout(()=>{Ia-=1},vs.touchMouseIgnoreWait)}},this.handleTouchScroll=()=>{this.wasTouchScroll=!0},this.handleScroll=e=>{if(!this.shouldIgnoreMove){let t=window.scrollX-this.prevScrollX+this.prevPageX,n=window.scrollY-this.prevScrollY+this.prevPageY;this.emitter.trigger("pointermove",{origEvent:e,isTouch:this.isTouchDragging,subjectEl:this.subjectEl,pageX:t,pageY:n,deltaX:t-this.origPageX,deltaY:n-this.origPageY})}},this.containerEl=e,this.emitter=new Gr,e.addEventListener("mousedown",this.handleMouseDown),e.addEventListener("touchstart",this.handleTouchStart,{passive:!0}),Oa+=1,1===Oa&&window.addEventListener("touchmove",Ha,{passive:!1})}destroy(){this.containerEl.removeEventListener("mousedown",this.handleMouseDown),this.containerEl.removeEventListener("touchstart",this.handleTouchStart,{passive:!0}),Oa-=1,Oa||window.removeEventListener("touchmove",Ha,{passive:!1})}tryStart(e){let t=this.querySubjectEl(e),n=e.target;return!(!t||this.handleSelector&&!Oe(n,this.handleSelector))&&(this.subjectEl=t,this.isDragging=!0,this.wasTouchScroll=!1,!0)}cleanup(){Na=!1,this.isDragging=!1,this.subjectEl=null,this.destroyScrollWatch()}querySubjectEl(e){return this.selector?Oe(e.target,this.selector):this.containerEl}shouldIgnoreMouse(){return Ia||this.isTouchDragging}cancelTouchScroll(){this.isDragging&&(Na=!0)}initScrollWatch(e){this.shouldWatchScroll&&(this.recordCoords(e),window.addEventListener("scroll",this.handleScroll,!0))}recordCoords(e){this.shouldWatchScroll&&(this.prevPageX=e.pageX,this.prevPageY=e.pageY,this.prevScrollX=window.scrollX,this.prevScrollY=window.scrollY)}destroyScrollWatch(){this.shouldWatchScroll&&window.removeEventListener("scroll",this.handleScroll,!0)}createEventFromMouse(e,t){let n=0,r=0;return t?(this.origPageX=e.pageX,this.origPageY=e.pageY):(n=e.pageX-this.origPageX,r=e.pageY-this.origPageY),{origEvent:e,isTouch:!1,subjectEl:this.subjectEl,pageX:e.pageX,pageY:e.pageY,deltaX:n,deltaY:r}}createEventFromTouch(e,t){let n,r,i=e.touches,s=0,o=0;return i&&i.length?(n=i[0].pageX,r=i[0].pageY):(n=e.pageX,r=e.pageY),t?(this.origPageX=n,this.origPageY=r):(s=n-this.origPageX,o=r-this.origPageY),{origEvent:e,isTouch:!0,subjectEl:this.subjectEl,pageX:n,pageY:r,deltaX:s,deltaY:o}}}function Ha(e){Na&&e.preventDefault()}class Ba{constructor(){this.isVisible=!1,this.sourceEl=null,this.mirrorEl=null,this.sourceElRect=null,this.parentNode=document.body,this.zIndex=9999,this.revertDuration=0}start(e,t,n){this.sourceEl=e,this.sourceElRect=this.sourceEl.getBoundingClientRect(),this.origScreenX=t-window.scrollX,this.origScreenY=n-window.scrollY,this.deltaX=0,this.deltaY=0,this.updateElPosition()}handleMove(e,t){this.deltaX=e-window.scrollX-this.origScreenX,this.deltaY=t-window.scrollY-this.origScreenY,this.updateElPosition()}setIsVisible(e){e?this.isVisible||(this.mirrorEl&&(this.mirrorEl.style.display=""),this.isVisible=e,this.updateElPosition()):this.isVisible&&(this.mirrorEl&&(this.mirrorEl.style.display="none"),this.isVisible=e)}stop(e,t){let n=()=>{this.cleanup(),t()};e&&this.mirrorEl&&this.isVisible&&this.revertDuration&&(this.deltaX||this.deltaY)?this.doRevertAnimation(n,this.revertDuration):setTimeout(n,0)}doRevertAnimation(e,t){let n=this.mirrorEl,r=this.sourceEl.getBoundingClientRect();n.style.transition="top "+t+"ms,left "+t+"ms",Be(n,{left:r.left,top:r.top}),Ge(n,()=>{n.style.transition="",e()})}cleanup(){this.mirrorEl&&(Ie(this.mirrorEl),this.mirrorEl=null),this.sourceEl=null}updateElPosition(){this.sourceEl&&this.isVisible&&Be(this.getMirrorEl(),{left:this.sourceElRect.left+this.deltaX,top:this.sourceElRect.top+this.deltaY})}getMirrorEl(){let e=this.sourceElRect,t=this.mirrorEl;return t||(t=this.mirrorEl=this.sourceEl.cloneNode(!0),t.style.userSelect="none",t.style.webkitUserSelect="none",t.style.pointerEvents="none",t.classList.add("fc-event-dragging"),Be(t,{position:"fixed",zIndex:this.zIndex,visibility:"",boxSizing:"border-box",width:e.right-e.left,height:e.bottom-e.top,right:"auto",bottom:"auto",margin:0}),this.parentNode.appendChild(t)),t}}class ja extends ss{constructor(e,t){super(),this.handleScroll=()=>{this.scrollTop=this.scrollController.getScrollTop(),this.scrollLeft=this.scrollController.getScrollLeft(),this.handleScrollChange()},this.scrollController=e,this.doesListening=t,this.scrollTop=this.origScrollTop=e.getScrollTop(),this.scrollLeft=this.origScrollLeft=e.getScrollLeft(),this.scrollWidth=e.getScrollWidth(),this.scrollHeight=e.getScrollHeight(),this.clientWidth=e.getClientWidth(),this.clientHeight=e.getClientHeight(),this.clientRect=this.computeClientRect(),this.doesListening&&this.getEventTarget().addEventListener("scroll",this.handleScroll)}destroy(){this.doesListening&&this.getEventTarget().removeEventListener("scroll",this.handleScroll)}getScrollTop(){return this.scrollTop}getScrollLeft(){return this.scrollLeft}setScrollTop(e){this.scrollController.setScrollTop(e),this.doesListening||(this.scrollTop=Math.max(Math.min(e,this.getMaxScrollTop()),0),this.handleScrollChange())}setScrollLeft(e){this.scrollController.setScrollLeft(e),this.doesListening||(this.scrollLeft=Math.max(Math.min(e,this.getMaxScrollLeft()),0),this.handleScrollChange())}getClientWidth(){return this.clientWidth}getClientHeight(){return this.clientHeight}getScrollWidth(){return this.scrollWidth}getScrollHeight(){return this.scrollHeight}handleScrollChange(){}}class za extends ja{constructor(e,t){super(new os(e),t)}getEventTarget(){return this.scrollController.el}computeClientRect(){return es(this.scrollController.el)}}class Ua extends ja{constructor(e){super(new as,e)}getEventTarget(){return window}computeClientRect(){return{left:this.scrollLeft,right:this.scrollLeft+this.clientWidth,top:this.scrollTop,bottom:this.scrollTop+this.clientHeight}}handleScrollChange(){this.clientRect=this.computeClientRect()}}const Wa="function"==typeof performance?performance.now:Date.now;class La{constructor(){this.isEnabled=!0,this.scrollQuery=[window,".fc-scroller"],this.edgeThreshold=50,this.maxVelocity=300,this.pointerScreenX=null,this.pointerScreenY=null,this.isAnimating=!1,this.scrollCaches=null,this.everMovedUp=!1,this.everMovedDown=!1,this.everMovedLeft=!1,this.everMovedRight=!1,this.animate=()=>{if(this.isAnimating){let e=this.computeBestEdge(this.pointerScreenX+window.scrollX,this.pointerScreenY+window.scrollY);if(e){let t=Wa();this.handleSide(e,(t-this.msSinceRequest)/1e3),this.requestAnimation(t)}else this.isAnimating=!1}}}start(e,t,n){this.isEnabled&&(this.scrollCaches=this.buildCaches(n),this.pointerScreenX=null,this.pointerScreenY=null,this.everMovedUp=!1,this.everMovedDown=!1,this.everMovedLeft=!1,this.everMovedRight=!1,this.handleMove(e,t))}handleMove(e,t){if(this.isEnabled){let n=e-window.scrollX,r=t-window.scrollY,i=null===this.pointerScreenY?0:r-this.pointerScreenY,s=null===this.pointerScreenX?0:n-this.pointerScreenX;i<0?this.everMovedUp=!0:i>0&&(this.everMovedDown=!0),s<0?this.everMovedLeft=!0:s>0&&(this.everMovedRight=!0),this.pointerScreenX=n,this.pointerScreenY=r,this.isAnimating||(this.isAnimating=!0,this.requestAnimation(Wa()))}}stop(){if(this.isEnabled){this.isAnimating=!1;for(let e of this.scrollCaches)e.destroy();this.scrollCaches=null}}requestAnimation(e){this.msSinceRequest=e,requestAnimationFrame(this.animate)}handleSide(e,t){let{scrollCache:n}=e,{edgeThreshold:r}=this,i=r-e.distance,s=i*i/(r*r)*this.maxVelocity*t,o=1;switch(e.name){case"left":o=-1;case"right":n.setScrollLeft(n.getScrollLeft()+s*o);break;case"top":o=-1;case"bottom":n.setScrollTop(n.getScrollTop()+s*o)}}computeBestEdge(e,t){let{edgeThreshold:n}=this,r=null,i=this.scrollCaches||[];for(let s of i){let i=s.clientRect,o=e-i.left,a=i.right-e,l=t-i.top,c=i.bottom-t;o>=0&&a>=0&&l>=0&&c>=0&&(l<=n&&this.everMovedUp&&s.canScrollUp()&&(!r||r.distance>l)&&(r={scrollCache:s,name:"top",distance:l}),c<=n&&this.everMovedDown&&s.canScrollDown()&&(!r||r.distance>c)&&(r={scrollCache:s,name:"bottom",distance:c}),o<=n&&this.everMovedLeft&&s.canScrollLeft()&&(!r||r.distance>o)&&(r={scrollCache:s,name:"left",distance:o}),a<=n&&this.everMovedRight&&s.canScrollRight()&&(!r||r.distance>a)&&(r={scrollCache:s,name:"right",distance:a}))}return r}buildCaches(e){return this.queryScrollEls(e).map(e=>e===window?new Ua(!1):new za(e,!1))}queryScrollEls(e){let t=[];for(let n of this.scrollQuery)"object"==typeof n?t.push(n):t.push(...Array.prototype.slice.call(e.getRootNode().querySelectorAll(n)));return t}}class Fa extends ms{constructor(e,t){super(e),this.containerEl=e,this.delay=null,this.minDistance=0,this.touchScrollAllowed=!0,this.mirrorNeedsRevert=!1,this.isInteracting=!1,this.isDragging=!1,this.isDelayEnded=!1,this.isDistanceSurpassed=!1,this.delayTimeoutId=null,this.onPointerDown=e=>{this.isDragging||(this.isInteracting=!0,this.isDelayEnded=!1,this.isDistanceSurpassed=!1,Je(document.body),et(document.body),e.isTouch||e.origEvent.preventDefault(),this.emitter.trigger("pointerdown",e),this.isInteracting&&!this.pointer.shouldIgnoreMove&&(this.mirror.setIsVisible(!1),this.mirror.start(e.subjectEl,e.pageX,e.pageY),this.startDelay(e),this.minDistance||this.handleDistanceSurpassed(e)))},this.onPointerMove=e=>{if(this.isInteracting){if(this.emitter.trigger("pointermove",e),!this.isDistanceSurpassed){let t,n=this.minDistance,{deltaX:r,deltaY:i}=e;t=r*r+i*i,t>=n*n&&this.handleDistanceSurpassed(e)}this.isDragging&&("scroll"!==e.origEvent.type&&(this.mirror.handleMove(e.pageX,e.pageY),this.autoScroller.handleMove(e.pageX,e.pageY)),this.emitter.trigger("dragmove",e))}},this.onPointerUp=e=>{this.isInteracting&&(this.isInteracting=!1,Ke(document.body),tt(document.body),this.emitter.trigger("pointerup",e),this.isDragging&&(this.autoScroller.stop(),this.tryStopDrag(e)),this.delayTimeoutId&&(clearTimeout(this.delayTimeoutId),this.delayTimeoutId=null))};let n=this.pointer=new Pa(e);n.emitter.on("pointerdown",this.onPointerDown),n.emitter.on("pointermove",this.onPointerMove),n.emitter.on("pointerup",this.onPointerUp),t&&(n.selector=t),this.mirror=new Ba,this.autoScroller=new La}destroy(){this.pointer.destroy(),this.onPointerUp({})}startDelay(e){"number"==typeof this.delay?this.delayTimeoutId=setTimeout(()=>{this.delayTimeoutId=null,this.handleDelayEnd(e)},this.delay):this.handleDelayEnd(e)}handleDelayEnd(e){this.isDelayEnded=!0,this.tryStartDrag(e)}handleDistanceSurpassed(e){this.isDistanceSurpassed=!0,this.tryStartDrag(e)}tryStartDrag(e){this.isDelayEnded&&this.isDistanceSurpassed&&(this.pointer.wasTouchScroll&&!this.touchScrollAllowed||(this.isDragging=!0,this.mirrorNeedsRevert=!1,this.autoScroller.start(e.pageX,e.pageY,this.containerEl),this.emitter.trigger("dragstart",e),!1===this.touchScrollAllowed&&this.pointer.cancelTouchScroll()))}tryStopDrag(e){this.mirror.stop(this.mirrorNeedsRevert,this.stopDrag.bind(this,e))}stopDrag(e){this.isDragging=!1,this.emitter.trigger("dragend",e)}setIgnoreMove(e){this.pointer.shouldIgnoreMove=e}setMirrorIsVisible(e){this.mirror.setIsVisible(e)}setMirrorNeedsRevert(e){this.mirrorNeedsRevert=e}setAutoScrollEnabled(e){this.autoScroller.isEnabled=e}}class Va{constructor(e){this.el=e,this.origRect=ts(e),this.scrollCaches=ns(e).map(e=>new za(e,!0))}destroy(){for(let e of this.scrollCaches)e.destroy()}computeLeft(){let e=this.origRect.left;for(let t of this.scrollCaches)e+=t.origScrollLeft-t.getScrollLeft();return e}computeTop(){let e=this.origRect.top;for(let t of this.scrollCaches)e+=t.origScrollTop-t.getScrollTop();return e}isWithinClipping(e,t){let n={left:e,top:t};for(let e of this.scrollCaches)if(!Ga(e.getEventTarget())&&!Pi(n,e.clientRect))return!1;return!0}}function Ga(e){let t=e.tagName;return"HTML"===t||"BODY"===t}class Qa{constructor(e,t){this.useSubjectCenter=!1,this.requireInitial=!0,this.disablePointCheck=!1,this.initialHit=null,this.movingHit=null,this.finalHit=null,this.handlePointerDown=e=>{let{dragging:t}=this;this.initialHit=null,this.movingHit=null,this.finalHit=null,this.prepareHits(),this.processFirstCoord(e),this.initialHit||!this.requireInitial?(t.setIgnoreMove(!1),this.emitter.trigger("pointerdown",e)):t.setIgnoreMove(!0)},this.handleDragStart=e=>{this.emitter.trigger("dragstart",e),this.handleMove(e,!0)},this.handleDragMove=e=>{this.emitter.trigger("dragmove",e),this.handleMove(e)},this.handlePointerUp=e=>{this.releaseHits(),this.emitter.trigger("pointerup",e)},this.handleDragEnd=e=>{this.movingHit&&this.emitter.trigger("hitupdate",null,!0,e),this.finalHit=this.movingHit,this.movingHit=null,this.emitter.trigger("dragend",e)},this.droppableStore=t,e.emitter.on("pointerdown",this.handlePointerDown),e.emitter.on("dragstart",this.handleDragStart),e.emitter.on("dragmove",this.handleDragMove),e.emitter.on("pointerup",this.handlePointerUp),e.emitter.on("dragend",this.handleDragEnd),this.dragging=e,this.emitter=new Gr}processFirstCoord(e){let t,n={left:e.pageX,top:e.pageY},r=n,i=e.subjectEl;i instanceof HTMLElement&&(t=ts(i),r=Bi(r,t));let s=this.initialHit=this.queryHitForOffset(r.left,r.top);if(s){if(this.useSubjectCenter&&t){let e=Hi(t,s.rect);e&&(r=ji(e))}this.coordAdjust=zi(r,n)}else this.coordAdjust={left:0,top:0}}handleMove(e,t){let n=this.queryHitForOffset(e.pageX+this.coordAdjust.left,e.pageY+this.coordAdjust.top);!t&&qa(this.movingHit,n)||(this.movingHit=n,this.emitter.trigger("hitupdate",n,!1,e))}prepareHits(){this.offsetTrackers=An(this.droppableStore,e=>(e.component.prepareHits(),new Va(e.el)))}releaseHits(){let{offsetTrackers:e}=this;for(let t in e)e[t].destroy();this.offsetTrackers={}}queryHitForOffset(e,t){let{droppableStore:n,offsetTrackers:r}=this,i=null;for(let s in n){let o=n[s].component,a=r[s];if(a&&a.isWithinClipping(e,t)){let n=a.computeLeft(),r=a.computeTop(),l=e-n,c=t-r,{origRect:d}=a,u=d.right-d.left,h=d.bottom-d.top;if(l>=0&&l=0&&ci.layer)&&(e.componentId=s,e.context=o.context,e.rect.left+=n,e.rect.right+=n,e.rect.top+=r,e.rect.bottom+=r,i=e)}}}return i}}function qa(e,t){return!e&&!t||Boolean(e)===Boolean(t)&&Ai(e.dateSpan,t.dateSpan)}function Ya(e,t){let n={};for(let r of t.pluginHooks.datePointTransforms)Object.assign(n,r(e,t));var r,i;return Object.assign(n,(r=e,{date:(i=t.dateEnv).toDate(r.range.start),dateStr:i.formatIso(r.range.start,{omitTime:r.allDay}),allDay:r.allDay})),n}class Za extends Mi{constructor(e){super(e),this.subjectEl=null,this.subjectSeg=null,this.isDragging=!1,this.eventRange=null,this.relevantEvents=null,this.receivingContext=null,this.validMutation=null,this.mutatedRelevantEvents=null,this.handlePointerDown=e=>{let t=e.origEvent.target,{component:n,dragging:r}=this,{mirror:i}=r,{options:s}=n.context,o=n.context;this.subjectEl=e.subjectEl;let a=this.subjectSeg=ai(e.subjectEl),l=(this.eventRange=a.eventRange).instance.instanceId;this.relevantEvents=xr(o.getCurrentData().eventStore,l),r.minDistance=e.isTouch?0:s.eventDragMinDistance,r.delay=e.isTouch&&l!==n.props.eventSelection?function(e){let{options:t}=e.context,n=t.eventLongPressDelay;null==n&&(n=t.longPressDelay);return n}(n):null,s.fixedMirrorParent?i.parentNode=s.fixedMirrorParent:i.parentNode=Oe(t,".fc"),i.revertDuration=s.dragRevertDuration;let c=n.isValidSegDownEl(t)&&!Oe(t,".fc-event-resizer");r.setIgnoreMove(!c),this.isDragging=c&&e.subjectEl.classList.contains("fc-event-draggable")},this.handleDragStart=e=>{let t=this.component.context,n=this.eventRange,r=n.instance.instanceId;e.isTouch?r!==this.component.props.eventSelection&&t.dispatch({type:"SELECT_EVENT",eventInstanceId:r}):t.dispatch({type:"UNSELECT_EVENT"}),this.isDragging&&(t.calendarApi.unselect(e),t.emitter.trigger("eventDragStart",{el:this.subjectEl,event:new ti(t,n.def,n.instance),jsEvent:e.origEvent,view:t.viewApi}))},this.handleHitUpdate=(e,t)=>{if(!this.isDragging)return;let n=this.relevantEvents,r=this.hitDragging.initialHit,i=this.component.context,s=null,o=null,a=null,l=!1,c={affectedEvents:n,mutatedEvents:{defs:{},instances:{}},isEvent:!0};if(e){s=e.context;let t=s.options;i===s||t.editable&&t.droppable?(o=function(e,t,n,r){let i=e.dateSpan,s=t.dateSpan,o=i.range.start,a=s.range.start,l={};i.allDay!==s.allDay&&(l.allDay=s.allDay,l.hasEnd=t.context.options.allDayMaintainDuration,o=s.allDay?Mt(n):n);let c=hr(o,a,e.context.dateEnv,e.componentId===t.componentId?e.largeUnit:null);c.milliseconds&&(l.allDay=!1);let d={datesDelta:c,standardProps:l};for(let n of r)n(d,e,t);return d}(r,e,this.eventRange.instance.range.start,s.getCurrentData().pluginHooks.eventDragMutationMassagers),o&&(a=$r(n,s.getCurrentData().eventUiBases,o,s),c.mutatedEvents=a,Ns(c,e.dateProfile,s)||(l=!0,o=null,a=null,c.mutatedEvents={defs:{},instances:{}}))):s=null}this.displayDrag(s,c),l?Xe():$e(),t||(i===s&&qa(r,e)&&(o=null),this.dragging.setMirrorNeedsRevert(!o),this.dragging.setMirrorIsVisible(!e||!this.subjectEl.getRootNode().querySelector(".fc-event-mirror")),this.receivingContext=s,this.validMutation=o,this.mutatedRelevantEvents=a)},this.handlePointerUp=()=>{this.isDragging||this.cleanup()},this.handleDragEnd=e=>{if(this.isDragging){let t=this.component.context,n=t.viewApi,{receivingContext:r,validMutation:i}=this,s=this.eventRange.def,o=this.eventRange.instance,a=new ti(t,s,o),l=this.relevantEvents,c=this.mutatedRelevantEvents,{finalHit:d}=this.hitDragging;if(this.clearDrag(),t.emitter.trigger("eventDragStop",{el:this.subjectEl,event:a,jsEvent:e.origEvent,view:n}),i){if(r===t){let r=new ti(t,c.defs[s.defId],o?c.instances[o.instanceId]:null);t.dispatch({type:"MERGE_EVENTS",eventStore:c});let d={oldEvent:a,event:r,relatedEvents:ri(c,t,o),revert(){t.dispatch({type:"MERGE_EVENTS",eventStore:l})}},u={};for(let e of t.getCurrentData().pluginHooks.eventDropTransformers)Object.assign(u,e(i,t));t.emitter.trigger("eventDrop",Object.assign(Object.assign(Object.assign({},d),u),{el:e.subjectEl,delta:i.datesDelta,jsEvent:e.origEvent,view:n})),t.emitter.trigger("eventChange",d)}else if(r){let i={event:a,relatedEvents:ri(l,t,o),revert(){t.dispatch({type:"MERGE_EVENTS",eventStore:l})}};t.emitter.trigger("eventLeave",Object.assign(Object.assign({},i),{draggedEl:e.subjectEl,view:n})),t.dispatch({type:"REMOVE_EVENTS",eventStore:l}),t.emitter.trigger("eventRemove",i);let u=c.defs[s.defId],h=c.instances[o.instanceId],f=new ti(r,u,h);r.dispatch({type:"MERGE_EVENTS",eventStore:c});let g={event:f,relatedEvents:ri(c,r,h),revert(){r.dispatch({type:"REMOVE_EVENTS",eventStore:c})}};r.emitter.trigger("eventAdd",g),e.isTouch&&r.dispatch({type:"SELECT_EVENT",eventInstanceId:o.instanceId}),r.emitter.trigger("drop",Object.assign(Object.assign({},Ya(d.dateSpan,r)),{draggedEl:e.subjectEl,jsEvent:e.origEvent,view:d.context.viewApi})),r.emitter.trigger("eventReceive",Object.assign(Object.assign({},g),{draggedEl:e.subjectEl,view:d.context.viewApi}))}}else t.emitter.trigger("_noEventDrop")}this.cleanup()};let{component:t}=this,{options:n}=t.context,r=this.dragging=new Fa(e.el);r.pointer.selector=Za.SELECTOR,r.touchScrollAllowed=!1,r.autoScroller.isEnabled=n.dragScroll;let i=this.hitDragging=new Qa(this.dragging,Oi);i.useSubjectCenter=e.useEventCenter,i.emitter.on("pointerdown",this.handlePointerDown),i.emitter.on("dragstart",this.handleDragStart),i.emitter.on("hitupdate",this.handleHitUpdate),i.emitter.on("pointerup",this.handlePointerUp),i.emitter.on("dragend",this.handleDragEnd)}destroy(){this.dragging.destroy()}displayDrag(e,t){let n=this.component.context,r=this.receivingContext;r&&r!==e&&(r===n?r.dispatch({type:"SET_EVENT_DRAG",state:{affectedEvents:t.affectedEvents,mutatedEvents:{defs:{},instances:{}},isEvent:!0}}):r.dispatch({type:"UNSET_EVENT_DRAG"})),e&&e.dispatch({type:"SET_EVENT_DRAG",state:t})}clearDrag(){let e=this.component.context,{receivingContext:t}=this;t&&t.dispatch({type:"UNSET_EVENT_DRAG"}),e!==t&&e.dispatch({type:"UNSET_EVENT_DRAG"})}cleanup(){this.subjectSeg=null,this.isDragging=!1,this.eventRange=null,this.relevantEvents=null,this.receivingContext=null,this.validMutation=null,this.mutatedRelevantEvents=null}}Za.SELECTOR=".fc-event-draggable, .fc-event-resizable";const Xa={fixedMirrorParent:yn},$a={dateClick:yn,eventDragStart:yn,eventDragStop:yn,eventDrop:yn,eventResizeStart:yn,eventResizeStop:yn,eventResize:yn,drop:yn,eventReceive:yn,eventLeave:yn};class Ja{constructor(e,t){this.receivingContext=null,this.droppableEvent=null,this.suppliedDragMeta=null,this.dragMeta=null,this.handleDragStart=e=>{this.dragMeta=this.buildDragMeta(e.subjectEl)},this.handleHitUpdate=(e,t,n)=>{let{dragging:r}=this.hitDragging,i=null,s=null,o=!1,a={affectedEvents:{defs:{},instances:{}},mutatedEvents:{defs:{},instances:{}},isEvent:this.dragMeta.create};e&&(i=e.context,this.canDropElOnCalendar(n.subjectEl,i)&&(s=function(e,t,n){let r=Object.assign({},t.leftoverProps);for(let i of n.pluginHooks.externalDefTransforms)Object.assign(r,i(e,t));let{refined:i,extra:s}=Ar(r,n),o=wr(i,s,t.sourceId,e.allDay,n.options.forceEventDuration||Boolean(t.duration),n),a=e.range.start;e.allDay&&t.startTime&&(a=n.dateEnv.add(a,t.startTime));let l=t.duration?n.dateEnv.add(a,t.duration):Xr(e.allDay,a,n),c=pr(o.defId,{start:a,end:l});return{def:o,instance:c}}(e.dateSpan,this.dragMeta,i),a.mutatedEvents=Rr(s),o=!Ns(a,e.dateProfile,i),o&&(a.mutatedEvents={defs:{},instances:{}},s=null))),this.displayDrag(i,a),r.setMirrorIsVisible(t||!s||!document.querySelector(".fc-event-mirror")),o?Xe():$e(),t||(r.setMirrorNeedsRevert(!s),this.receivingContext=i,this.droppableEvent=s)},this.handleDragEnd=e=>{let{receivingContext:t,droppableEvent:n}=this;if(this.clearDrag(),t&&n){let r=this.hitDragging.finalHit,i=r.context.viewApi,s=this.dragMeta;if(t.emitter.trigger("drop",Object.assign(Object.assign({},Ya(r.dateSpan,t)),{draggedEl:e.subjectEl,jsEvent:e.origEvent,view:i})),s.create){let r=Rr(n);t.dispatch({type:"MERGE_EVENTS",eventStore:r}),e.isTouch&&t.dispatch({type:"SELECT_EVENT",eventInstanceId:n.instance.instanceId}),t.emitter.trigger("eventReceive",{event:new ti(t,n.def,n.instance),relatedEvents:[],revert(){t.dispatch({type:"REMOVE_EVENTS",eventStore:r})},draggedEl:e.subjectEl,view:i})}}this.receivingContext=null,this.droppableEvent=null};let n=this.hitDragging=new Qa(e,Oi);n.requireInitial=!1,n.emitter.on("dragstart",this.handleDragStart),n.emitter.on("hitupdate",this.handleHitUpdate),n.emitter.on("dragend",this.handleDragEnd),this.suppliedDragMeta=t}buildDragMeta(e){return"object"==typeof this.suppliedDragMeta?bs(this.suppliedDragMeta):"function"==typeof this.suppliedDragMeta?bs(this.suppliedDragMeta(e)):function(e){let t=function(e,t){let n=vs.dataAttrPrefix,r=(n?n+"-":"")+t;return e.getAttribute("data-"+r)||""}(e,"event");return bs(t?JSON.parse(t):{create:!1})}(e)}displayDrag(e,t){let n=this.receivingContext;n&&n!==e&&n.dispatch({type:"UNSET_EVENT_DRAG"}),e&&e.dispatch({type:"SET_EVENT_DRAG",state:t})}clearDrag(){this.receivingContext&&this.receivingContext.dispatch({type:"UNSET_EVENT_DRAG"})}canDropElOnCalendar(e,t){let n=t.options.dropAccept;return"function"==typeof n?n.call(t.calendarApi,e):"string"!=typeof n||!n||Boolean(Ne(e,n))}}vs.dataAttrPrefix="";class Ka extends ms{constructor(e){super(e),this.shouldIgnoreMove=!1,this.mirrorSelector="",this.currentMirrorEl=null,this.handlePointerDown=e=>{this.emitter.trigger("pointerdown",e),this.shouldIgnoreMove||this.emitter.trigger("dragstart",e)},this.handlePointerMove=e=>{this.shouldIgnoreMove||this.emitter.trigger("dragmove",e)},this.handlePointerUp=e=>{this.emitter.trigger("pointerup",e),this.shouldIgnoreMove||this.emitter.trigger("dragend",e)};let t=this.pointer=new Pa(e);t.emitter.on("pointerdown",this.handlePointerDown),t.emitter.on("pointermove",this.handlePointerMove),t.emitter.on("pointerup",this.handlePointerUp)}destroy(){this.pointer.destroy()}setIgnoreMove(e){this.shouldIgnoreMove=e}setMirrorIsVisible(e){if(e)this.currentMirrorEl&&(this.currentMirrorEl.style.visibility="",this.currentMirrorEl=null);else{let e=this.mirrorSelector?document.querySelector(this.mirrorSelector):null;e&&(this.currentMirrorEl=e,e.style.visibility="hidden")}}}var el=Po({name:"@fullcalendar/interaction",componentInteractions:[class extends Mi{constructor(e){super(e),this.handlePointerDown=e=>{let{dragging:t}=this,n=e.origEvent.target;t.setIgnoreMove(!this.component.isValidDateDownEl(n))},this.handleDragEnd=e=>{let{component:t}=this,{pointer:n}=this.dragging;if(!n.wasTouchScroll){let{initialHit:n,finalHit:r}=this.hitDragging;if(n&&r&&qa(n,r)){let{context:r}=t,i=Object.assign(Object.assign({},Ya(n.dateSpan,r)),{dayEl:n.dayEl,jsEvent:e.origEvent,view:r.viewApi||r.calendarApi.view});r.emitter.trigger("dateClick",i)}}},this.dragging=new Fa(e.el),this.dragging.autoScroller.isEnabled=!1;let t=this.hitDragging=new Qa(this.dragging,Ii(e));t.emitter.on("pointerdown",this.handlePointerDown),t.emitter.on("dragend",this.handleDragEnd)}destroy(){this.dragging.destroy()}},class extends Mi{constructor(e){super(e),this.dragSelection=null,this.handlePointerDown=e=>{let{component:t,dragging:n}=this,{options:r}=t.context,i=r.selectable&&t.isValidDateDownEl(e.origEvent.target);n.setIgnoreMove(!i),n.delay=e.isTouch?function(e){let{options:t}=e.context,n=t.selectLongPressDelay;null==n&&(n=t.longPressDelay);return n}(t):null},this.handleDragStart=e=>{this.component.context.calendarApi.unselect(e)},this.handleHitUpdate=(e,t)=>{let{context:n}=this.component,r=null,i=!1;if(e){let t=this.hitDragging.initialHit;e.componentId===t.componentId&&this.isHitComboAllowed&&!this.isHitComboAllowed(t,e)||(r=function(e,t,n){let r=e.dateSpan,i=t.dateSpan,s=[r.range.start,r.range.end,i.range.start,i.range.end];s.sort(lt);let o={};for(let r of n){let n=r(e,t);if(!1===n)return null;n&&Object.assign(o,n)}return o.range={start:s[0],end:s[3]},o.allDay=r.allDay,o}(t,e,n.pluginHooks.dateSelectionTransformers)),r&&Ps(r,e.dateProfile,n)||(i=!0,r=null)}r?n.dispatch({type:"SELECT_DATES",selection:r}):t||n.dispatch({type:"UNSELECT_DATES"}),i?Xe():$e(),t||(this.dragSelection=r)},this.handlePointerUp=e=>{this.dragSelection&&(Yr(this.dragSelection,e,this.component.context),this.dragSelection=null)};let{component:t}=e,{options:n}=t.context,r=this.dragging=new Fa(e.el);r.touchScrollAllowed=!1,r.minDistance=n.selectMinDistance||0,r.autoScroller.isEnabled=n.dragScroll;let i=this.hitDragging=new Qa(this.dragging,Ii(e));i.emitter.on("pointerdown",this.handlePointerDown),i.emitter.on("dragstart",this.handleDragStart),i.emitter.on("hitupdate",this.handleHitUpdate),i.emitter.on("pointerup",this.handlePointerUp)}destroy(){this.dragging.destroy()}},Za,class extends Mi{constructor(e){super(e),this.draggingSegEl=null,this.draggingSeg=null,this.eventRange=null,this.relevantEvents=null,this.validMutation=null,this.mutatedRelevantEvents=null,this.handlePointerDown=e=>{let{component:t}=this,n=ai(this.querySegEl(e)),r=this.eventRange=n.eventRange;this.dragging.minDistance=t.context.options.eventDragMinDistance,this.dragging.setIgnoreMove(!this.component.isValidSegDownEl(e.origEvent.target)||e.isTouch&&this.component.props.eventSelection!==r.instance.instanceId)},this.handleDragStart=e=>{let{context:t}=this.component,n=this.eventRange;this.relevantEvents=xr(t.getCurrentData().eventStore,this.eventRange.instance.instanceId);let r=this.querySegEl(e);this.draggingSegEl=r,this.draggingSeg=ai(r),t.calendarApi.unselect(),t.emitter.trigger("eventResizeStart",{el:r,event:new ti(t,n.def,n.instance),jsEvent:e.origEvent,view:t.viewApi})},this.handleHitUpdate=(e,t,n)=>{let{context:r}=this.component,i=this.relevantEvents,s=this.hitDragging.initialHit,o=this.eventRange.instance,a=null,l=null,c=!1,d={affectedEvents:i,mutatedEvents:{defs:{},instances:{}},isEvent:!0};if(e){e.componentId===s.componentId&&this.isHitComboAllowed&&!this.isHitComboAllowed(s,e)||(a=function(e,t,n,r){let i=e.context.dateEnv,s=e.dateSpan.range.start,o=t.dateSpan.range.start,a=hr(s,o,i,e.largeUnit);if(n){if(i.add(r.start,a)r.start)return{endDelta:a};return null}(s,e,n.subjectEl.classList.contains("fc-event-resizer-start"),o.range))}a&&(l=$r(i,r.getCurrentData().eventUiBases,a,r),d.mutatedEvents=l,Ns(d,e.dateProfile,r)||(c=!0,a=null,l=null,d.mutatedEvents=null)),l?r.dispatch({type:"SET_EVENT_RESIZE",state:d}):r.dispatch({type:"UNSET_EVENT_RESIZE"}),c?Xe():$e(),t||(a&&qa(s,e)&&(a=null),this.validMutation=a,this.mutatedRelevantEvents=l)},this.handleDragEnd=e=>{let{context:t}=this.component,n=this.eventRange.def,r=this.eventRange.instance,i=new ti(t,n,r),s=this.relevantEvents,o=this.mutatedRelevantEvents;if(t.emitter.trigger("eventResizeStop",{el:this.draggingSegEl,event:i,jsEvent:e.origEvent,view:t.viewApi}),this.validMutation){let a=new ti(t,o.defs[n.defId],r?o.instances[r.instanceId]:null);t.dispatch({type:"MERGE_EVENTS",eventStore:o});let l={oldEvent:i,event:a,relatedEvents:ri(o,t,r),revert(){t.dispatch({type:"MERGE_EVENTS",eventStore:s})}};t.emitter.trigger("eventResize",Object.assign(Object.assign({},l),{el:this.draggingSegEl,startDelta:this.validMutation.startDelta||ft(0),endDelta:this.validMutation.endDelta||ft(0),jsEvent:e.origEvent,view:t.viewApi})),t.emitter.trigger("eventChange",l)}else t.emitter.trigger("_noEventResize");this.draggingSeg=null,this.relevantEvents=null,this.validMutation=null};let{component:t}=e,n=this.dragging=new Fa(e.el);n.pointer.selector=".fc-event-resizer",n.touchScrollAllowed=!1,n.autoScroller.isEnabled=t.context.options.dragScroll;let r=this.hitDragging=new Qa(this.dragging,Ii(e));r.emitter.on("pointerdown",this.handlePointerDown),r.emitter.on("dragstart",this.handleDragStart),r.emitter.on("hitupdate",this.handleHitUpdate),r.emitter.on("dragend",this.handleDragEnd)}destroy(){this.dragging.destroy()}querySegEl(e){return Oe(e.subjectEl,".fc-event")}}],calendarInteractions:[class{constructor(e){this.context=e,this.isRecentPointerDateSelect=!1,this.matchesCancel=!1,this.matchesEvent=!1,this.onSelect=e=>{e.jsEvent&&(this.isRecentPointerDateSelect=!0)},this.onDocumentPointerDown=e=>{let t=this.context.options.unselectCancel,n=ze(e.origEvent);this.matchesCancel=!!Oe(n,t),this.matchesEvent=!!Oe(n,Za.SELECTOR)},this.onDocumentPointerUp=e=>{let{context:t}=this,{documentPointer:n}=this,r=t.getCurrentData();if(!n.wasTouchScroll){if(r.dateSelection&&!this.isRecentPointerDateSelect){let n=t.options.unselectAuto;!n||n&&this.matchesCancel||t.calendarApi.unselect(e)}r.eventSelection&&!this.matchesEvent&&t.dispatch({type:"UNSELECT_EVENT"})}this.isRecentPointerDateSelect=!1};let t=this.documentPointer=new Pa(document);t.shouldIgnoreMove=!0,t.shouldWatchScroll=!1,t.emitter.on("pointerdown",this.onDocumentPointerDown),t.emitter.on("pointerup",this.onDocumentPointerUp),e.emitter.on("select",this.onSelect)}destroy(){this.context.emitter.off("select",this.onSelect),this.documentPointer.destroy()}}],elementDraggingImpl:Fa,optionRefiners:Xa,listenerRefiners:$a});class tl extends ls{constructor(){super(...arguments),this.headerElRef={current:null}}renderSimpleLayout(e,t){let{props:n,context:r}=this,i=[],s=no(r.options);return e&&i.push({type:"header",key:"header",isSticky:s,chunk:{elRef:this.headerElRef,tableClassName:"fc-col-header",rowContent:e}}),i.push({type:"body",key:"body",liquid:!0,chunk:{content:t}}),p(er,{elClasses:["fc-daygrid"],viewSpec:r.viewSpec},p(io,{liquid:!n.isHeightAuto&&!n.forPrint,collapsibleWidth:n.forPrint,cols:[],sections:i}))}renderHScrollLayout(e,t,n,r){let i=this.context.pluginHooks.scrollGridImpl;if(!i)throw new Error("No ScrollGrid implementation");let{props:s,context:o}=this,a=!s.forPrint&&no(o.options),l=!s.forPrint&&ro(o.options),c=[];return e&&c.push({type:"header",key:"header",isSticky:a,chunks:[{key:"main",elRef:this.headerElRef,tableClassName:"fc-col-header",rowContent:e}]}),c.push({type:"body",key:"body",liquid:!0,chunks:[{key:"main",content:t}]}),l&&c.push({type:"footer",key:"footer",isSticky:!0,chunks:[{key:"main",content:to}]}),p(er,{elClasses:["fc-daygrid"],viewSpec:o.viewSpec},p(i,{liquid:!s.isHeightAuto&&!s.forPrint,forPrint:s.forPrint,collapsibleWidth:s.forPrint,colGroups:[{cols:[{span:n,minWidth:r}]}],sections:c}))}}function nl(e,t){let n=[];for(let e=0;e{let n=(e.eventDrag?e.eventDrag.affectedInstances:null)||(e.eventResize?e.eventResize.affectedInstances:null)||{};return p(y,null,t.map(t=>{let r=t.eventRange.instance.instanceId;return p("div",{className:"fc-daygrid-event-harness",key:r,style:{visibility:n[r]?"hidden":""}},ol(t)?p(ll,Object.assign({seg:t,isDragging:!1,isSelected:r===e.eventSelection,defaultDisplayEventEnd:!1},mi(t,e.todayRange))):p(al,Object.assign({seg:t,isDragging:!1,isResizing:!1,isDateSelecting:!1,isSelected:r===e.eventSelection,defaultDisplayEventEnd:!1},mi(t,e.todayRange))))}))}})}}function ul(e){let t=[],n=[];for(let r of e)t.push(r.seg),r.isVisible||n.push(r.seg);return{allSegs:t,invisibleSegs:n}}const hl=an({week:"narrow"});class fl extends ls{constructor(){super(...arguments),this.rootElRef={current:null},this.state={dayNumberId:We()},this.handleRootEl=e=>{Qn(this.rootElRef,e),Qn(this.props.elRef,e)}}render(){let{context:e,props:t,state:n,rootElRef:r}=this,{options:i,dateEnv:s}=e,{date:o,dateProfile:a}=t;const l=t.showDayNumber&&function(e,t,n){const{start:r,end:i}=t,s=Ct(i,-1),o=n.getYear(r),a=n.getMonth(r),l=n.getYear(s),c=n.getMonth(s);return!(o===l&&a===c)&&Boolean(e.valueOf()===r.valueOf()||1===n.getDay(e)&&e.valueOf()p("div",{ref:t.innerElRef,className:"fc-daygrid-day-frame fc-scrollgrid-sync-inner",style:{minHeight:t.minHeight}},t.showWeekNumber&&p(vo,{elTag:"a",elClasses:["fc-daygrid-week-number"],elAttrs:qi(e,o,"week"),date:o,defaultFormat:hl}),!a.isDisabled&&(t.showDayNumber||ho(i)||t.forceDayTop)?p("div",{className:"fc-daygrid-day-top"},p(s,{elTag:"a",elClasses:["fc-daygrid-day-number",l&&"fc-daygrid-month-start"],elAttrs:Object.assign(Object.assign({},qi(e,o)),{id:n.dayNumberId})})):t.showDayNumber?p("div",{className:"fc-daygrid-day-top",style:{visibility:"hidden"}},p("a",{className:"fc-daygrid-day-number"}," ")):void 0,p("div",{className:"fc-daygrid-day-events",ref:t.fgContentElRef},t.fgContent,p("div",{className:"fc-daygrid-day-bottom",style:{marginTop:t.moreMarginTop}},p(dl,{allDayDate:o,singlePlacements:t.singlePlacements,moreCnt:t.moreCnt,alignmentElRef:r,alignGridTop:!t.showDayNumber,extraDateSpan:t.extraDateSpan,dateProfile:t.dateProfile,eventSelection:t.eventSelection,eventDrag:t.eventDrag,eventResize:t.eventResize,todayRange:t.todayRange}))),p("div",{className:"fc-daygrid-day-bg"},t.bgContent)))}}function gl(e){return e.dayNumberText||p(y,null," ")}function pl(e){return e.eventRange.instance.instanceId+":"+e.firstCol}function ml(e){return pl(e)+":"+e.lastCol}function vl(e,t,n,r,i,s,o){let a=new bl(t=>{let n=e[t.index].eventRange.instance.instanceId+":"+t.span.start+":"+(t.span.end-1);return i[n]||1});a.allowReslicing=!0,a.strictOrder=r,!0===t||!0===n?(a.maxCoord=s,a.hiddenConsumes=!0):"number"==typeof t?a.maxStackCnt=t:"number"==typeof n&&(a.maxStackCnt=n,a.hiddenConsumes=!0);let l=[],c=[];for(let t=0;t1,o=r.span.start===e;d+=r.levelCoord-c,c=r.levelCoord+r.thickness,s?(d+=r.thickness,o&&u.push({seg:yl(i,r.span.start,r.span.end,n),isVisible:!0,isAbsolute:!0,absoluteTop:r.levelCoord,marginTop:0})):o&&(u.push({seg:yl(i,r.span.start,r.span.end,n),isVisible:!0,isAbsolute:!1,absoluteTop:r.levelCoord,marginTop:d}),d=0)}i.push(l),s.push(u),o.push(d)}return{singleColPlacements:i,multiColPlacements:s,leftoverMargins:o}}(u,e,o),p=[],m=[];for(let e of c){f[e.firstCol].push({seg:e,isVisible:!1,isAbsolute:!0,absoluteTop:0,marginTop:0});for(let t=e.firstCol;t<=e.lastCol;t+=1)h[t].push({seg:yl(e,t,t+1,o),isVisible:!1,isAbsolute:!1,absoluteTop:0,marginTop:0})}for(let e=0;e!this.forceHidden[us(e)];for(let e=0;e{e&&this.updateSizing(!0)}}render(){let{props:e,state:t,context:n}=this,{options:r}=n,i=e.cells.length,s=rl(e.businessHourSegs,i),o=rl(e.bgEventSegs,i),a=rl(this.getHighlightSegs(),i),l=rl(this.getMirrorSegs(),i),{singleColPlacements:c,multiColPlacements:d,moreCnts:u,moreMarginTops:h}=vl(di(e.fgEventSegs,r.eventOrder),e.dayMaxEvents,e.dayMaxEventRows,r.eventOrderStrict,t.segHeights,t.maxContentHeight,e.cells),f=e.eventDrag&&e.eventDrag.affectedInstances||e.eventResize&&e.eventResize.affectedInstances||{};return p("tr",{ref:this.rootElRef,role:"row"},e.renderIntro&&e.renderIntro(),e.cells.map((t,n)=>{let r=this.renderFgSegs(n,e.forPrint?c[n]:d[n],e.todayRange,f),i=this.renderFgSegs(n,function(e,t){if(!e.length)return[];let n=function(e){let t={};for(let n of e)for(let e of n)t[e.seg.eventRange.instance.instanceId]=e.absoluteTop;return t}(t);return e.map(e=>({seg:e,isVisible:!0,isAbsolute:!0,absoluteTop:n[e.eventRange.instance.instanceId],marginTop:0}))}(l[n],d),e.todayRange,{},Boolean(e.eventDrag),Boolean(e.eventResize),!1);return p(fl,{key:t.key,elRef:this.cellElRefs.createRef(t.key),innerElRef:this.frameElRefs.createRef(t.key),dateProfile:e.dateProfile,date:t.date,showDayNumber:e.showDayNumbers,showWeekNumber:e.showWeekNumbers&&0===n,forceDayTop:e.showWeekNumbers,todayRange:e.todayRange,eventSelection:e.eventSelection,eventDrag:e.eventDrag,eventResize:e.eventResize,extraRenderProps:t.extraRenderProps,extraDataAttrs:t.extraDataAttrs,extraClassNames:t.extraClassNames,extraDateSpan:t.extraDateSpan,moreCnt:u[n],moreMarginTop:h[n],singlePlacements:c[n],fgContentElRef:this.fgElRefs.createRef(t.key),fgContent:p(y,null,p(y,null,r),p(y,null,i)),bgContent:p(y,null,this.renderFillSegs(a[n],"highlight"),this.renderFillSegs(s[n],"non-business"),this.renderFillSegs(o[n],"bg-event")),minHeight:e.cellMinHeight})}))}componentDidMount(){this.updateSizing(!0),this.context.addResizeHandler(this.handleResize)}componentDidUpdate(e,t){let n=this.props;this.updateSizing(!Cn(e,n))}componentWillUnmount(){this.context.removeResizeHandler(this.handleResize)}getHighlightSegs(){let{props:e}=this;return e.eventDrag&&e.eventDrag.segs.length?e.eventDrag.segs:e.eventResize&&e.eventResize.segs.length?e.eventResize.segs:e.dateSelectionSegs}getMirrorSegs(){let{props:e}=this;return e.eventResize&&e.eventResize.segs.length?e.eventResize.segs:[]}renderFgSegs(e,t,n,r,i,s,o){let{context:a}=this,{eventSelection:l}=this.props,{framePositions:c}=this.state,d=1===this.props.cells.length,u=i||s||o,h=[];if(c)for(let e of t){let{seg:t}=e,{instanceId:f}=t.eventRange.instance,g=e.isVisible&&!r[f],m=e.isAbsolute,v="",y="";m&&(a.isRtl?(y=0,v=c.lefts[t.lastCol]-c.lefts[t.firstCol]):(v=0,y=c.rights[t.firstCol]-c.rights[t.lastCol])),h.push(p("div",{className:"fc-daygrid-event-harness"+(m?" fc-daygrid-event-harness-abs":""),key:pl(t),ref:u?null:this.segHarnessRefs.createRef(ml(t)),style:{visibility:g?"":"hidden",marginTop:m?"":e.marginTop,top:m?e.absoluteTop:"",left:v,right:y}},ol(t)?p(ll,Object.assign({seg:t,isDragging:i,isSelected:f===l,defaultDisplayEventEnd:d},mi(t,n))):p(al,Object.assign({seg:t,isDragging:i,isResizing:s,isDateSelecting:o,isSelected:f===l,defaultDisplayEventEnd:d},mi(t,n)))))}return h}renderFillSegs(e,t){let{isRtl:n}=this.context,{todayRange:r}=this.props,{framePositions:i}=this.state,s=[];if(i)for(let o of e){let e=n?{right:0,left:i.lefts[o.lastCol]-i.lefts[o.firstCol]}:{left:0,right:i.rights[o.firstCol]-i.rights[o.lastCol]};s.push(p("div",{key:yi(o.eventRange),className:"fc-daygrid-bg-harness",style:e},"bg-event"===t?p(go,Object.assign({seg:o},mi(o,r))):mo(t)))}return p(y,{},...s)}updateSizing(e){let{props:t,state:n,frameElRefs:r}=this;if(!t.forPrint&&null!==t.clientWidth){if(e){let e=t.cells.map(e=>r.currentMap[e.key]);if(e.length){let t=this.rootElRef.current,r=new rs(t,e,!0,!1);n.framePositions&&n.framePositions.similarTo(r)||this.setState({framePositions:new rs(t,e,!0,!1)})}}const i=this.state.segHeights,s=this.querySegHeights(),o=!0===t.dayMaxEvents||!0===t.dayMaxEventRows;this.safeSetState({segHeights:Object.assign(Object.assign({},i),s),maxContentHeight:o?this.computeMaxContentHeight():null})}}querySegHeights(){let e=this.segHarnessRefs.currentMap,t={};for(let n in e){let r=Math.round(e[n].getBoundingClientRect().height);t[n]=Math.max(t[n]||0,r)}return t}computeMaxContentHeight(){let e=this.props.cells[0].key,t=this.cellElRefs.currentMap[e],n=this.fgElRefs.currentMap[e];return t.getBoundingClientRect().bottom-n.getBoundingClientRect().top}getCellEls(){let e=this.cellElRefs.currentMap;return this.props.cells.map(t=>e[t.key])}}El.addStateEquality({segHeights:Cn});class Sl extends ls{constructor(){super(...arguments),this.splitBusinessHourSegs=Gt(nl),this.splitBgEventSegs=Gt(nl),this.splitFgEventSegs=Gt(nl),this.splitDateSelectionSegs=Gt(nl),this.splitEventDrag=Gt(il),this.splitEventResize=Gt(il),this.rowRefs=new Vs}render(){let{props:e,context:t}=this,n=e.cells.length,r=this.splitBusinessHourSegs(e.businessHourSegs,n),i=this.splitBgEventSegs(e.bgEventSegs,n),s=this.splitFgEventSegs(e.fgEventSegs,n),o=this.splitDateSelectionSegs(e.dateSelectionSegs,n),a=this.splitEventDrag(e.eventDrag,n),l=this.splitEventResize(e.eventResize,n),c=n>=7&&e.clientWidth?e.clientWidth/t.options.aspectRatio/6:null;return p(Rs,{unit:"day"},(t,d)=>p(y,null,e.cells.map((t,u)=>p(El,{ref:this.rowRefs.createRef(u),key:t.length?t[0].date.toISOString():u,showDayNumbers:n>1,showWeekNumbers:e.showWeekNumbers,todayRange:d,dateProfile:e.dateProfile,cells:t,renderIntro:e.renderRowIntro,businessHourSegs:r[u],eventSelection:e.eventSelection,bgEventSegs:i[u].filter(Al),fgEventSegs:s[u],dateSelectionSegs:o[u],eventDrag:a[u],eventResize:l[u],dayMaxEvents:e.dayMaxEvents,dayMaxEventRows:e.dayMaxEventRows,clientWidth:e.clientWidth,clientHeight:e.clientHeight,cellMinHeight:c,forPrint:e.forPrint}))))}componentDidMount(){this.registerInteractiveComponent()}componentDidUpdate(){this.registerInteractiveComponent()}registerInteractiveComponent(){if(!this.rootEl){const e=this.rowRefs.currentMap[0].getCellEls()[0],t=e?e.closest(".fc-daygrid-body"):null;t&&(this.rootEl=t,this.context.registerInteractiveComponent(this,{el:t,isHitComboAllowed:this.props.isHitComboAllowed}))}}componentWillUnmount(){this.rootEl&&(this.context.unregisterInteractiveComponent(this),this.rootEl=null)}prepareHits(){this.rowPositions=new rs(this.rootEl,this.rowRefs.collect().map(e=>e.getCellEls()[0]),!1,!0),this.colPositions=new rs(this.rootEl,this.rowRefs.currentMap[0].getCellEls(),!0,!1)}queryHit(e,t){let{colPositions:n,rowPositions:r}=this,i=n.leftToIndex(e),s=r.topToIndex(t);if(null!=s&&null!=i){let e=this.props.cells[s][i];return{dateProfile:this.props.dateProfile,dateSpan:Object.assign({range:this.getCellRange(s,i),allDay:!0},e.extraDateSpan),dayEl:this.getCellEl(s,i),rect:{left:n.lefts[i],right:n.rights[i],top:r.tops[s],bottom:r.bottoms[s]},layer:0}}return null}getCellEl(e,t){return this.rowRefs.currentMap[e].getCellEls()[t]}getCellRange(e,t){let n=this.props.cells[e][t].date;return{start:n,end:wt(n,1)}}}function Al(e){return e.eventRange.def.allDay}class Dl extends ls{constructor(){super(...arguments),this.elRef={current:null},this.needsScrollReset=!1}render(){let{props:e}=this,{dayMaxEventRows:t,dayMaxEvents:n,expandRows:r}=e,i=!0===n||!0===t;i&&!r&&(i=!1,t=null,n=null);let s=["fc-daygrid-body",i?"fc-daygrid-body-balanced":"fc-daygrid-body-unbalanced",r?"":"fc-daygrid-body-natural"];return p("div",{ref:this.elRef,className:s.join(" "),style:{width:e.clientWidth,minWidth:e.tableMinWidth}},p("table",{role:"presentation",className:"fc-scrollgrid-sync-table",style:{width:e.clientWidth,minWidth:e.tableMinWidth,height:r?e.clientHeight:""}},e.colGroupNode,p("tbody",{role:"presentation"},p(Sl,{dateProfile:e.dateProfile,cells:e.cells,renderRowIntro:e.renderRowIntro,showWeekNumbers:e.showWeekNumbers,clientWidth:e.clientWidth,clientHeight:e.clientHeight,businessHourSegs:e.businessHourSegs,bgEventSegs:e.bgEventSegs,fgEventSegs:e.fgEventSegs,dateSelectionSegs:e.dateSelectionSegs,eventSelection:e.eventSelection,eventDrag:e.eventDrag,eventResize:e.eventResize,dayMaxEvents:n,dayMaxEventRows:t,forPrint:e.forPrint,isHitComboAllowed:e.isHitComboAllowed}))))}componentDidMount(){this.requestScrollReset()}componentDidUpdate(e){e.dateProfile!==this.props.dateProfile?this.requestScrollReset():this.flushScrollReset()}requestScrollReset(){this.needsScrollReset=!0,this.flushScrollReset()}flushScrollReset(){if(this.needsScrollReset&&this.props.clientWidth){const e=function(e,t){let n;t.currentRangeUnit.match(/year|month/)&&(n=e.querySelector(`[data-date="${Lt(t.currentDate)}-01"]`));n||(n=e.querySelector(`[data-date="${Wt(t.currentDate)}"]`));return n}(this.elRef.current,this.props.dateProfile);if(e){const t=e.closest(".fc-daygrid-body"),n=t.closest(".fc-scroller"),r=e.getBoundingClientRect().top-t.getBoundingClientRect().top;n.scrollTop=r?r+1:0}this.needsScrollReset=!1}}}class wl extends Is{constructor(){super(...arguments),this.forceDayIfListItem=!0}sliceRange(e,t){return t.sliceRange(e)}}class Cl extends ls{constructor(){super(...arguments),this.slicer=new wl,this.tableRef={current:null}}render(){let{props:e,context:t}=this;return p(Dl,Object.assign({ref:this.tableRef},this.slicer.sliceProps(e,e.dateProfile,e.nextDayThreshold,t,e.dayTableModel),{dateProfile:e.dateProfile,cells:e.dayTableModel.cells,colGroupNode:e.colGroupNode,tableMinWidth:e.tableMinWidth,renderRowIntro:e.renderRowIntro,dayMaxEvents:e.dayMaxEvents,dayMaxEventRows:e.dayMaxEventRows,showWeekNumbers:e.showWeekNumbers,expandRows:e.expandRows,headerAlignElRef:e.headerAlignElRef,clientWidth:e.clientWidth,clientHeight:e.clientHeight,forPrint:e.forPrint}))}}function Rl(e,t){let n=new ks(e.renderRange,t);return new Ms(n,/year|month|week/.test(e.currentRangeUnit))}class xl extends gr{buildRenderRange(e,t,n){let r=super.buildRenderRange(e,t,n),{props:i}=this;return _l({currentRange:r,snapToWeek:/^(year|month)$/.test(t),fixedWeekCount:i.fixedWeekCount,dateEnv:i.dateEnv})}}function _l(e){let t,{dateEnv:n,currentRange:r}=e,{start:i,end:s}=r;if(e.snapToWeek&&(i=n.startOfWeek(i),t=n.startOfWeek(s),t.valueOf()!==s.valueOf()&&(s=Dt(t,1))),e.fixedWeekCount){let e=n.startOfWeek(n.startOfMonth(wt(r.end,-1)));s=Dt(s,6-Math.ceil(Rt(e,s)))}return{start:i,end:s}}xe(':root{--fc-daygrid-event-dot-width:8px}.fc-daygrid-day-events:after,.fc-daygrid-day-events:before,.fc-daygrid-day-frame:after,.fc-daygrid-day-frame:before,.fc-daygrid-event-harness:after,.fc-daygrid-event-harness:before{clear:both;content:"";display:table}.fc .fc-daygrid-body{position:relative;z-index:1}.fc .fc-daygrid-day.fc-day-today{background-color:var(--fc-today-bg-color)}.fc .fc-daygrid-day-frame{min-height:100%;position:relative}.fc .fc-daygrid-day-top{display:flex;flex-direction:row-reverse}.fc .fc-day-other .fc-daygrid-day-top{opacity:.3}.fc .fc-daygrid-day-number{padding:4px;position:relative;z-index:4}.fc .fc-daygrid-month-start{font-size:1.1em;font-weight:700}.fc .fc-daygrid-day-events{margin-top:1px}.fc .fc-daygrid-body-balanced .fc-daygrid-day-events{left:0;position:absolute;right:0}.fc .fc-daygrid-body-unbalanced .fc-daygrid-day-events{min-height:2em;position:relative}.fc .fc-daygrid-body-natural .fc-daygrid-day-events{margin-bottom:1em}.fc .fc-daygrid-event-harness{position:relative}.fc .fc-daygrid-event-harness-abs{left:0;position:absolute;right:0;top:0}.fc .fc-daygrid-bg-harness{bottom:0;position:absolute;top:0}.fc .fc-daygrid-day-bg .fc-non-business{z-index:1}.fc .fc-daygrid-day-bg .fc-bg-event{z-index:2}.fc .fc-daygrid-day-bg .fc-highlight{z-index:3}.fc .fc-daygrid-event{margin-top:1px;z-index:6}.fc .fc-daygrid-event.fc-event-mirror{z-index:7}.fc .fc-daygrid-day-bottom{font-size:.85em;margin:0 2px}.fc .fc-daygrid-day-bottom:after,.fc .fc-daygrid-day-bottom:before{clear:both;content:"";display:table}.fc .fc-daygrid-more-link{border-radius:3px;cursor:pointer;line-height:1;margin-top:1px;max-width:100%;overflow:hidden;padding:2px;position:relative;white-space:nowrap;z-index:4}.fc .fc-daygrid-more-link:hover{background-color:rgba(0,0,0,.1)}.fc .fc-daygrid-week-number{background-color:var(--fc-neutral-bg-color);color:var(--fc-neutral-text-color);min-width:1.5em;padding:2px;position:absolute;text-align:center;top:0;z-index:5}.fc .fc-more-popover .fc-popover-body{min-width:220px;padding:10px}.fc-direction-ltr .fc-daygrid-event.fc-event-start,.fc-direction-rtl .fc-daygrid-event.fc-event-end{margin-left:2px}.fc-direction-ltr .fc-daygrid-event.fc-event-end,.fc-direction-rtl .fc-daygrid-event.fc-event-start{margin-right:2px}.fc-direction-ltr .fc-daygrid-more-link{float:left}.fc-direction-ltr .fc-daygrid-week-number{border-radius:0 0 3px 0;left:0}.fc-direction-rtl .fc-daygrid-more-link{float:right}.fc-direction-rtl .fc-daygrid-week-number{border-radius:0 0 0 3px;right:0}.fc-liquid-hack .fc-daygrid-day-frame{position:static}.fc-daygrid-event{border-radius:3px;font-size:var(--fc-small-font-size);position:relative;white-space:nowrap}.fc-daygrid-block-event .fc-event-time{font-weight:700}.fc-daygrid-block-event .fc-event-time,.fc-daygrid-block-event .fc-event-title{padding:1px}.fc-daygrid-dot-event{align-items:center;display:flex;padding:2px 0}.fc-daygrid-dot-event .fc-event-title{flex-grow:1;flex-shrink:1;font-weight:700;min-width:0;overflow:hidden}.fc-daygrid-dot-event.fc-event-mirror,.fc-daygrid-dot-event:hover{background:rgba(0,0,0,.1)}.fc-daygrid-dot-event.fc-event-selected:before{bottom:-10px;top:-10px}.fc-daygrid-event-dot{border:calc(var(--fc-daygrid-event-dot-width)/2) solid var(--fc-event-border-color);border-radius:calc(var(--fc-daygrid-event-dot-width)/2);box-sizing:content-box;height:0;margin:0 4px;width:0}.fc-direction-ltr .fc-daygrid-event .fc-event-time{margin-right:3px}.fc-direction-rtl .fc-daygrid-event .fc-event-time{margin-left:3px}');var Tl=Po({name:"@fullcalendar/daygrid",initialView:"dayGridMonth",views:{dayGrid:{component:class extends tl{constructor(){super(...arguments),this.buildDayTableModel=Gt(Rl),this.headerRef={current:null},this.tableRef={current:null}}render(){let{options:e,dateProfileGenerator:t}=this.context,{props:n}=this,r=this.buildDayTableModel(n.dateProfile,t),i=e.dayHeaders&&p(_s,{ref:this.headerRef,dateProfile:n.dateProfile,dates:r.headerDates,datesRepDistinctDays:1===r.rowCnt}),s=t=>p(Cl,{ref:this.tableRef,dateProfile:n.dateProfile,dayTableModel:r,businessHours:n.businessHours,dateSelection:n.dateSelection,eventStore:n.eventStore,eventUiBases:n.eventUiBases,eventSelection:n.eventSelection,eventDrag:n.eventDrag,eventResize:n.eventResize,nextDayThreshold:e.nextDayThreshold,colGroupNode:t.tableColGroupNode,tableMinWidth:t.tableMinWidth,dayMaxEvents:e.dayMaxEvents,dayMaxEventRows:e.dayMaxEventRows,showWeekNumbers:e.weekNumbers,expandRows:!n.isHeightAuto,headerAlignElRef:this.headerElRef,clientWidth:t.clientWidth,clientHeight:t.clientHeight,forPrint:n.forPrint});return e.dayMinWidth?this.renderHScrollLayout(i,s,r.colCnt,e.dayMinWidth):this.renderSimpleLayout(i,s)}},dateProfileGeneratorClass:xl},dayGridDay:{type:"dayGrid",duration:{days:1}},dayGridWeek:{type:"dayGrid",duration:{weeks:1}},dayGridMonth:{type:"dayGrid",duration:{months:1},fixedWeekCount:!0},dayGridYear:{type:"dayGrid",duration:{years:1}}}});class kl extends Wi{getKeyInfo(){return{allDay:{},timed:{}}}getKeysForDateSpan(e){return e.allDay?["allDay"]:["timed"]}getKeysForEventDef(e){return e.allDay?si(e)?["timed","allDay"]:["allDay"]:["timed"]}}const Ml=an({hour:"numeric",minute:"2-digit",omitZeroMinute:!0,meridiem:"short"});function Il(e){let t=["fc-timegrid-slot","fc-timegrid-slot-label",e.isLabeled?"fc-scrollgrid-shrink":"fc-timegrid-slot-minor"];return p(Ln.Consumer,null,n=>{if(!e.isLabeled)return p("td",{className:t.join(" "),"data-time":e.isoTimeStr});let{dateEnv:r,options:i,viewApi:s}=n,o=null==i.slotLabelFormat?Ml:Array.isArray(i.slotLabelFormat)?an(i.slotLabelFormat[0]):an(i.slotLabelFormat),a={level:0,time:e.time,date:r.toDate(e.date),view:s,text:r.format(e.date,o)};return p(Jn,{elTag:"td",elClasses:t,elAttrs:{"data-time":e.isoTimeStr},renderProps:a,generatorName:"slotLabelContent",customGenerator:i.slotLabelContent,defaultGenerator:Ol,classNameGenerator:i.slotLabelClassNames,didMount:i.slotLabelDidMount,willUnmount:i.slotLabelWillUnmount},e=>p("div",{className:"fc-timegrid-slot-label-frame fc-scrollgrid-shrink-frame"},p(e,{elTag:"div",elClasses:["fc-timegrid-slot-label-cushion","fc-scrollgrid-shrink-cushion"]})))})}function Ol(e){return e.text}class Nl extends Gn{render(){return this.props.slatMetas.map(e=>p("tr",{key:e.key},p(Il,Object.assign({},e))))}}const Pl=an({week:"short"});class Hl extends ls{constructor(){super(...arguments),this.allDaySplitter=new kl,this.headerElRef={current:null},this.rootElRef={current:null},this.scrollerElRef={current:null},this.state={slatCoords:null},this.handleScrollTopRequest=e=>{let t=this.scrollerElRef.current;t&&(t.scrollTop=e)},this.renderHeadAxis=(e,t="")=>{let{options:n}=this.context,{dateProfile:r}=this.props,i=r.renderRange,s=1===xt(i.start,i.end)?qi(this.context,i.start,"week"):{};return n.weekNumbers&&"day"===e?p(vo,{elTag:"th",elClasses:["fc-timegrid-axis","fc-scrollgrid-shrink"],elAttrs:{"aria-hidden":!0},date:i.start,defaultFormat:Pl},e=>p("div",{className:["fc-timegrid-axis-frame","fc-scrollgrid-shrink-frame","fc-timegrid-axis-frame-liquid"].join(" "),style:{height:t}},p(e,{elTag:"a",elClasses:["fc-timegrid-axis-cushion","fc-scrollgrid-shrink-cushion","fc-scrollgrid-sync-inner"],elAttrs:s}))):p("th",{"aria-hidden":!0,className:"fc-timegrid-axis"},p("div",{className:"fc-timegrid-axis-frame",style:{height:t}}))},this.renderTableRowAxis=e=>{let{options:t,viewApi:n}=this.context,r={text:t.allDayText,view:n};return p(Jn,{elTag:"td",elClasses:["fc-timegrid-axis","fc-scrollgrid-shrink"],elAttrs:{"aria-hidden":!0},renderProps:r,generatorName:"allDayContent",customGenerator:t.allDayContent,defaultGenerator:Bl,classNameGenerator:t.allDayClassNames,didMount:t.allDayDidMount,willUnmount:t.allDayWillUnmount},t=>p("div",{className:["fc-timegrid-axis-frame","fc-scrollgrid-shrink-frame",null==e?" fc-timegrid-axis-frame-liquid":""].join(" "),style:{height:e}},p(t,{elTag:"span",elClasses:["fc-timegrid-axis-cushion","fc-scrollgrid-shrink-cushion","fc-scrollgrid-sync-inner"]})))},this.handleSlatCoords=e=>{this.setState({slatCoords:e})}}renderSimpleLayout(e,t,n){let{context:r,props:i}=this,s=[],o=no(r.options);return e&&s.push({type:"header",key:"header",isSticky:o,chunk:{elRef:this.headerElRef,tableClassName:"fc-col-header",rowContent:e}}),t&&(s.push({type:"body",key:"all-day",chunk:{content:t}}),s.push({type:"body",key:"all-day-divider",outerContent:p("tr",{role:"presentation",className:"fc-scrollgrid-section"},p("td",{className:"fc-timegrid-divider "+r.theme.getClass("tableCellShaded")}))})),s.push({type:"body",key:"body",liquid:!0,expandRows:Boolean(r.options.expandRows),chunk:{scrollerElRef:this.scrollerElRef,content:n}}),p(er,{elRef:this.rootElRef,elClasses:["fc-timegrid"],viewSpec:r.viewSpec},p(io,{liquid:!i.isHeightAuto&&!i.forPrint,collapsibleWidth:i.forPrint,cols:[{width:"shrink"}],sections:s}))}renderHScrollLayout(e,t,n,r,i,s,o){let a=this.context.pluginHooks.scrollGridImpl;if(!a)throw new Error("No ScrollGrid implementation");let{context:l,props:c}=this,d=!c.forPrint&&no(l.options),u=!c.forPrint&&ro(l.options),h=[];e&&h.push({type:"header",key:"header",isSticky:d,syncRowHeights:!0,chunks:[{key:"axis",rowContent:e=>p("tr",{role:"presentation"},this.renderHeadAxis("day",e.rowSyncHeights[0]))},{key:"cols",elRef:this.headerElRef,tableClassName:"fc-col-header",rowContent:e}]}),t&&(h.push({type:"body",key:"all-day",syncRowHeights:!0,chunks:[{key:"axis",rowContent:e=>p("tr",{role:"presentation"},this.renderTableRowAxis(e.rowSyncHeights[0]))},{key:"cols",content:t}]}),h.push({key:"all-day-divider",type:"body",outerContent:p("tr",{role:"presentation",className:"fc-scrollgrid-section"},p("td",{colSpan:2,className:"fc-timegrid-divider "+l.theme.getClass("tableCellShaded")}))}));let f=l.options.nowIndicator;return h.push({type:"body",key:"body",liquid:!0,expandRows:Boolean(l.options.expandRows),chunks:[{key:"axis",content:e=>p("div",{className:"fc-timegrid-axis-chunk"},p("table",{"aria-hidden":!0,style:{height:e.expandRows?e.clientHeight:""}},e.tableColGroupNode,p("tbody",null,p(Nl,{slatMetas:s}))),p("div",{className:"fc-timegrid-now-indicator-container"},p(Rs,{unit:f?"minute":"day"},e=>{let t=f&&o&&o.safeComputeTop(e);return"number"==typeof t?p(lo,{elClasses:["fc-timegrid-now-indicator-arrow"],elStyle:{top:t},isAxis:!0,date:e}):null})))},{key:"cols",scrollerElRef:this.scrollerElRef,content:n}]}),u&&h.push({key:"footer",type:"footer",isSticky:!0,chunks:[{key:"axis",content:to},{key:"cols",content:to}]}),p(er,{elRef:this.rootElRef,elClasses:["fc-timegrid"],viewSpec:l.viewSpec},p(a,{liquid:!c.isHeightAuto&&!c.forPrint,forPrint:c.forPrint,collapsibleWidth:!1,colGroups:[{width:"shrink",cols:[{width:"shrink"}]},{cols:[{span:r,minWidth:i}]}],sections:h}))}getAllDayMaxEventProps(){let{dayMaxEvents:e,dayMaxEventRows:t}=this.context.options;return!0!==e&&!0!==t||(e=void 0,t=5),{dayMaxEvents:e,dayMaxEventRows:t}}}function Bl(e){return e.text}class jl{constructor(e,t,n){this.positions=e,this.dateProfile=t,this.slotDuration=n}safeComputeTop(e){let{dateProfile:t}=this;if(lr(t.currentRange,e)){let n=Mt(e),r=e.valueOf()-n.valueOf();if(r>=yt(t.slotMinTime)&&r{let o={time:i.time,date:t.dateEnv.toDate(i.date),view:t.viewApi};return p("tr",{key:i.key,ref:r.createRef(i.key)},e.axis&&p(Il,Object.assign({},i)),p(Jn,{elTag:"td",elClasses:["fc-timegrid-slot","fc-timegrid-slot-lane",!i.isLabeled&&"fc-timegrid-slot-minor"],elAttrs:{"data-time":i.isoTimeStr},renderProps:o,generatorName:"slotLaneContent",customGenerator:n.slotLaneContent,classNameGenerator:n.slotLaneClassNames,didMount:n.slotLaneDidMount,willUnmount:n.slotLaneWillUnmount}))}))}}class Ul extends Gn{constructor(){super(...arguments),this.rootElRef={current:null},this.slatElRefs=new Vs}render(){let{props:e,context:t}=this;return p("div",{ref:this.rootElRef,className:"fc-timegrid-slots"},p("table",{"aria-hidden":!0,className:t.theme.getClass("table"),style:{minWidth:e.tableMinWidth,width:e.clientWidth,height:e.minHeight}},e.tableColGroupNode,p(zl,{slatElRefs:this.slatElRefs,axis:e.axis,slatMetas:e.slatMetas})))}componentDidMount(){this.updateSizing()}componentDidUpdate(){this.updateSizing()}componentWillUnmount(){this.props.onCoords&&this.props.onCoords(null)}updateSizing(){let{context:e,props:t}=this;if(t.onCoords&&null!==t.clientWidth){this.rootElRef.current.offsetHeight&&t.onCoords(new jl(new rs(this.rootElRef.current,(n=this.slatElRefs.currentMap,t.slatMetas.map(e=>n[e.key])),!1,!0),this.props.dateProfile,e.options.slotDuration))}var n}}function Wl(e,t){let n,r=[];for(n=0;nec(e.hiddenSegs,e),defaultGenerator:Vl,forceTimed:!0},e=>p(e,{elTag:"div",elClasses:["fc-timegrid-more-link-inner","fc-sticky"]}))}}function Vl(e){return e.shortText}function Gl(e,t,n){let r=new cs;null!=t&&(r.strictOrder=t),null!=n&&(r.maxStackCnt=n);let i=hs(r.addSegs(e)),s=function(e){const{entriesByLevel:t}=e,n=Zl((e,t)=>e+":"+t,(r,i)=>{let s=Ql(function(e,t,n){let{levelCoords:r,entriesByLevel:i}=e,s=i[t][n],o=r[t]+s.thickness,a=r.length,l=t;for(;lus(e),(e,r,i)=>{let s,{nextLevelNodes:o,thickness:a}=e,l=a+i,c=a/l,d=[];if(o.length)for(let e of o)if(void 0===s){let t=n(e,r,l);s=t[0],d.push(t[1])}else{let t=n(e,s,0);d.push(t[1])}else s=t;let u=(s-r)*c;return[s-u,Object.assign(Object.assign({},e),{thickness:u,nextLevelNodes:d})]});return e.map(e=>n(e,0,0)[1])}(s,1),{segRects:function(e){let t=[];const n=Zl((e,t,n)=>us(e),(e,n,i)=>{let s=Object.assign(Object.assign({},e),{levelCoord:n,stackDepth:i,stackForward:0});return t.push(s),s.stackForward=r(e.nextLevelNodes,n+e.thickness,i+1)+1});function r(e,t,r){let i=0;for(let s of e)i=Math.max(n(s,t,r),i);return i}return r(e,0,0),t}(s),hiddenGroups:i}}function Ql(e,t){if(!e)return[[],0];let{level:n,lateralStart:r,lateralEnd:i}=e,s=r,o=[];for(;s{let i=e(...r);return i in n?n[i]:n[i]=t(...r)}}function Xl(e,t,n=null,r=0){let i=[];if(n)for(let s=0;sp("div",{className:"fc-timegrid-col-frame"},p("div",{className:"fc-timegrid-col-bg"},this.renderFillSegs(e.businessHourSegs,"non-business"),this.renderFillSegs(e.bgEventSegs,"bg-event"),this.renderFillSegs(e.dateSelectionSegs,"highlight")),p("div",{className:"fc-timegrid-col-events"},this.renderFgSegs(o,s,!1,!1,!1)),p("div",{className:"fc-timegrid-col-events"},this.renderFgSegs(i,{},Boolean(e.eventDrag),Boolean(e.eventResize),Boolean(r),"mirror")),p("div",{className:"fc-timegrid-now-indicator-container"},this.renderNowIndicator(e.nowIndicatorSegs)),ho(n)&&p(t,{elTag:"div",elClasses:["fc-timegrid-col-misc"]})))}renderFgSegs(e,t,n,r,i,s){let{props:o}=this;return o.forPrint?ec(e,o):this.renderPositionedFgSegs(e,t,n,r,i,s)}renderPositionedFgSegs(e,t,n,r,i,s){let{eventMaxStack:o,eventShortHeight:a,eventOrderStrict:l,eventMinHeight:c}=this.context.options,{date:d,slatCoords:u,eventSelection:h,todayRange:f,nowDate:g}=this.props,m=n||r||i,v=Xl(e,d,u,c),{segPlacements:b,hiddenGroups:E}=function(e,t,n,r){let i=[],s=[];for(let n=0;n{let{seg:o,rect:l}=e,c=o.eventRange.instance.instanceId,d=m||Boolean(!t[c]&&l),u=tc(l&&l.span),v=!m&&l?this.computeSegHStyle(l):{left:0,right:0},y=Boolean(l)&&l.stackForward>0,b=Boolean(l)&&l.span.end-l.span.start{let c=tc(e.span),d=(u=e.entries,h=t,u.map(e=>h[e.index]));var u,h;return p(Fl,{key:Ut(wo(d)),hiddenSegs:d,top:c.top,bottom:c.bottom,extraDateSpan:n,dateProfile:r,todayRange:i,nowDate:s,eventSelection:o,eventDrag:a,eventResize:l})}))}renderFillSegs(e,t){let{props:n,context:r}=this,i=Xl(e,n.date,n.slatCoords,r.options.eventMinHeight).map((r,i)=>{let s=e[i];return p("div",{key:yi(s.eventRange),className:"fc-timegrid-bg-harness",style:tc(r)},"bg-event"===t?p(go,Object.assign({seg:s},mi(s,n.todayRange,n.nowDate))):mo(t))});return p(y,null,i)}renderNowIndicator(e){let{slatCoords:t,date:n}=this.props;return t?e.map((e,r)=>p(lo,{key:r,elClasses:["fc-timegrid-now-indicator-line"],elStyle:{top:t.computeDateTop(e.start,n)},isAxis:!1,date:n})):null}computeSegHStyle(e){let t,n,{isRtl:r,options:i}=this.context,s=i.slotEventOverlap,o=e.levelCoord,a=e.levelCoord+e.thickness;s&&(a=Math.min(1,o+2*(a-o))),r?(t=1-a,n=o):(t=o,n=1-a);let l={zIndex:e.stackDepth+1,left:100*t+"%",right:100*n+"%"};return s&&!e.stackForward&&(l[r?"marginLeft":"marginRight"]=20),l}}function ec(e,{todayRange:t,nowDate:n,eventSelection:r,eventDrag:i,eventResize:s}){let o=(i?i.affectedInstances:null)||(s?s.affectedInstances:null)||{};return p(y,null,e.map(e=>{let i=e.eventRange.instance.instanceId;return p("div",{key:i,style:{visibility:o[i]?"hidden":""}},p(Jl,Object.assign({seg:e,isDragging:!1,isResizing:!1,isDateSelecting:!1,isSelected:i===r,isShort:!1},mi(e,t,n))))}))}function tc(e){return e?{top:e.start,bottom:-e.end}:{top:"",bottom:""}}class nc extends Gn{constructor(){super(...arguments),this.splitFgEventSegs=Gt(Wl),this.splitBgEventSegs=Gt(Wl),this.splitBusinessHourSegs=Gt(Wl),this.splitNowIndicatorSegs=Gt(Wl),this.splitDateSelectionSegs=Gt(Wl),this.splitEventDrag=Gt(Ll),this.splitEventResize=Gt(Ll),this.rootElRef={current:null},this.cellElRefs=new Vs}render(){let{props:e,context:t}=this,n=t.options.nowIndicator&&e.slatCoords&&e.slatCoords.safeComputeTop(e.nowDate),r=e.cells.length,i=this.splitFgEventSegs(e.fgEventSegs,r),s=this.splitBgEventSegs(e.bgEventSegs,r),o=this.splitBusinessHourSegs(e.businessHourSegs,r),a=this.splitNowIndicatorSegs(e.nowIndicatorSegs,r),l=this.splitDateSelectionSegs(e.dateSelectionSegs,r),c=this.splitEventDrag(e.eventDrag,r),d=this.splitEventResize(e.eventResize,r);return p("div",{className:"fc-timegrid-cols",ref:this.rootElRef},p("table",{role:"presentation",style:{minWidth:e.tableMinWidth,width:e.clientWidth}},e.tableColGroupNode,p("tbody",{role:"presentation"},p("tr",{role:"row"},e.axis&&p("td",{"aria-hidden":!0,className:"fc-timegrid-col fc-timegrid-axis"},p("div",{className:"fc-timegrid-col-frame"},p("div",{className:"fc-timegrid-now-indicator-container"},"number"==typeof n&&p(lo,{elClasses:["fc-timegrid-now-indicator-arrow"],elStyle:{top:n},isAxis:!0,date:e.nowDate})))),e.cells.map((t,n)=>p(Kl,{key:t.key,elRef:this.cellElRefs.createRef(t.key),dateProfile:e.dateProfile,date:t.date,nowDate:e.nowDate,todayRange:e.todayRange,extraRenderProps:t.extraRenderProps,extraDataAttrs:t.extraDataAttrs,extraClassNames:t.extraClassNames,extraDateSpan:t.extraDateSpan,fgEventSegs:i[n],bgEventSegs:s[n],businessHourSegs:o[n],nowIndicatorSegs:a[n],dateSelectionSegs:l[n],eventDrag:c[n],eventResize:d[n],slatCoords:e.slatCoords,eventSelection:e.eventSelection,forPrint:e.forPrint}))))))}componentDidMount(){this.updateCoords()}componentDidUpdate(){this.updateCoords()}updateCoords(){let{props:e}=this;var t;e.onColCoords&&null!==e.clientWidth&&e.onColCoords(new rs(this.rootElRef.current,(t=this.cellElRefs.currentMap,e.cells.map(e=>t[e.key])),!0,!1))}}class rc extends ls{constructor(){super(...arguments),this.processSlotOptions=Gt(ic),this.state={slatCoords:null},this.handleRootEl=e=>{e?this.context.registerInteractiveComponent(this,{el:e,isHitComboAllowed:this.props.isHitComboAllowed}):this.context.unregisterInteractiveComponent(this)},this.handleScrollRequest=e=>{let{onScrollTopRequest:t}=this.props,{slatCoords:n}=this.state;if(t&&n){if(e.time){let r=n.computeTimeTop(e.time);r=Math.ceil(r),r&&(r+=1),t(r)}return!0}return!1},this.handleColCoords=e=>{this.colCoords=e},this.handleSlatCoords=e=>{this.setState({slatCoords:e}),this.props.onSlatCoords&&this.props.onSlatCoords(e)}}render(){let{props:e,state:t}=this;return p("div",{className:"fc-timegrid-body",ref:this.handleRootEl,style:{width:e.clientWidth,minWidth:e.tableMinWidth}},p(Ul,{axis:e.axis,dateProfile:e.dateProfile,slatMetas:e.slatMetas,clientWidth:e.clientWidth,minHeight:e.expandRows?e.clientHeight:"",tableMinWidth:e.tableMinWidth,tableColGroupNode:e.axis?e.tableColGroupNode:null,onCoords:this.handleSlatCoords}),p(nc,{cells:e.cells,axis:e.axis,dateProfile:e.dateProfile,businessHourSegs:e.businessHourSegs,bgEventSegs:e.bgEventSegs,fgEventSegs:e.fgEventSegs,dateSelectionSegs:e.dateSelectionSegs,eventSelection:e.eventSelection,eventDrag:e.eventDrag,eventResize:e.eventResize,todayRange:e.todayRange,nowDate:e.nowDate,nowIndicatorSegs:e.nowIndicatorSegs,clientWidth:e.clientWidth,tableMinWidth:e.tableMinWidth,tableColGroupNode:e.tableColGroupNode,slatCoords:t.slatCoords,onColCoords:this.handleColCoords,forPrint:e.forPrint}))}componentDidMount(){this.scrollResponder=this.context.createScrollResponder(this.handleScrollRequest)}componentDidUpdate(e){this.scrollResponder.update(e.dateProfile!==this.props.dateProfile)}componentWillUnmount(){this.scrollResponder.detach()}queryHit(e,t){let{dateEnv:n,options:r}=this.context,{colCoords:i}=this,{dateProfile:s}=this.props,{slatCoords:o}=this.state,{snapDuration:a,snapsPerSlot:l}=this.processSlotOptions(this.props.slotDuration,r.snapDuration),c=i.leftToIndex(e),d=o.positions.topToIndex(t);if(null!=c&&null!=d){let e=this.props.cells[c],r=o.positions.tops[d],u=o.positions.getHeight(d),h=(t-r)/u,f=d*l+Math.floor(h*l),g=this.props.cells[c].date,p=pt(s.slotMinTime,mt(a,f)),m=n.add(g,p),v=n.add(m,a);return{dateProfile:s,dateSpan:Object.assign({range:{start:m,end:v},allDay:!1},e.extraDateSpan),dayEl:i.els[c],rect:{left:i.lefts[c],right:i.rights[c],top:r,bottom:r+u},layer:0}}return null}}function ic(e,t){let n=t||e,r=bt(e,n);return null===r&&(n=e,r=1),{snapDuration:n,snapsPerSlot:r}}class sc extends Is{sliceRange(e,t){let n=[];for(let r=0;rp(rc,Object.assign({ref:this.timeColsRef},this.slicer.sliceProps(e,n,null,t,o),{forPrint:e.forPrint,axis:e.axis,dateProfile:n,slatMetas:e.slatMetas,slotDuration:e.slotDuration,cells:r.cells[0],tableColGroupNode:e.tableColGroupNode,tableMinWidth:e.tableMinWidth,clientWidth:e.clientWidth,clientHeight:e.clientHeight,expandRows:e.expandRows,nowDate:a,nowIndicatorSegs:i&&this.slicer.sliceNowDate(a,n,s,t,o),todayRange:l,onScrollTopRequest:e.onScrollTopRequest,onSlatCoords:e.onSlatCoords})))}}function ac(e,t,n){let r=[];for(let i of e.headerDates)r.push({start:n.add(i,t.slotMinTime),end:n.add(i,t.slotMaxTime)});return r}const lc=[{hours:1},{minutes:30},{minutes:15},{seconds:30},{seconds:15}];function cc(e,t,n,r,i){let s=new Date(0),o=e,a=ft(0),l=n||function(e){let t,n,r;for(t=lc.length-1;t>=0;t-=1)if(n=ft(lc[t]),r=bt(n,e),null!==r&&r>1)return n;return e}(r),c=[];for(;yt(o)table,.fc .fc-timegrid-slots{position:relative;z-index:1}.fc .fc-timegrid-slot{border-bottom:0;height:1.5em}.fc .fc-timegrid-slot:empty:before{content:"\\00a0"}.fc .fc-timegrid-slot-minor{border-top-style:dotted}.fc .fc-timegrid-slot-label-cushion{display:inline-block;white-space:nowrap}.fc .fc-timegrid-slot-label{vertical-align:middle}.fc .fc-timegrid-axis-cushion,.fc .fc-timegrid-slot-label-cushion{padding:0 4px}.fc .fc-timegrid-axis-frame-liquid{height:100%}.fc .fc-timegrid-axis-frame{align-items:center;display:flex;justify-content:flex-end;overflow:hidden}.fc .fc-timegrid-axis-cushion{flex-shrink:0;max-width:60px}.fc-direction-ltr .fc-timegrid-slot-label-frame{text-align:right}.fc-direction-rtl .fc-timegrid-slot-label-frame{text-align:left}.fc-liquid-hack .fc-timegrid-axis-frame-liquid{bottom:0;height:auto;left:0;position:absolute;right:0;top:0}.fc .fc-timegrid-col.fc-day-today{background-color:var(--fc-today-bg-color)}.fc .fc-timegrid-col-frame{min-height:100%;position:relative}.fc-media-screen.fc-liquid-hack .fc-timegrid-col-frame{bottom:0;height:auto;left:0;position:absolute;right:0;top:0}.fc-media-screen .fc-timegrid-cols{bottom:0;left:0;position:absolute;right:0;top:0}.fc-media-screen .fc-timegrid-cols>table{height:100%}.fc-media-screen .fc-timegrid-col-bg,.fc-media-screen .fc-timegrid-col-events,.fc-media-screen .fc-timegrid-now-indicator-container{left:0;position:absolute;right:0;top:0}.fc .fc-timegrid-col-bg{z-index:2}.fc .fc-timegrid-col-bg .fc-non-business{z-index:1}.fc .fc-timegrid-col-bg .fc-bg-event{z-index:2}.fc .fc-timegrid-col-bg .fc-highlight{z-index:3}.fc .fc-timegrid-bg-harness{left:0;position:absolute;right:0}.fc .fc-timegrid-col-events{z-index:3}.fc .fc-timegrid-now-indicator-container{bottom:0;overflow:hidden}.fc-direction-ltr .fc-timegrid-col-events{margin:0 2.5% 0 2px}.fc-direction-rtl .fc-timegrid-col-events{margin:0 2px 0 2.5%}.fc-timegrid-event-harness{position:absolute}.fc-timegrid-event-harness>.fc-timegrid-event{bottom:0;left:0;position:absolute;right:0;top:0}.fc-timegrid-event-harness-inset .fc-timegrid-event,.fc-timegrid-event.fc-event-mirror,.fc-timegrid-more-link{box-shadow:0 0 0 1px var(--fc-page-bg-color)}.fc-timegrid-event,.fc-timegrid-more-link{border-radius:3px;font-size:var(--fc-small-font-size)}.fc-timegrid-event{margin-bottom:1px}.fc-timegrid-event .fc-event-main{padding:1px 1px 0}.fc-timegrid-event .fc-event-time{font-size:var(--fc-small-font-size);margin-bottom:1px;white-space:nowrap}.fc-timegrid-event-short .fc-event-main-frame{flex-direction:row;overflow:hidden}.fc-timegrid-event-short .fc-event-time:after{content:"\\00a0-\\00a0"}.fc-timegrid-event-short .fc-event-title{font-size:var(--fc-small-font-size)}.fc-timegrid-more-link{background:var(--fc-more-link-bg-color);color:var(--fc-more-link-text-color);cursor:pointer;margin-bottom:1px;position:absolute;z-index:9999}.fc-timegrid-more-link-inner{padding:3px 2px;top:0}.fc-direction-ltr .fc-timegrid-more-link{right:0}.fc-direction-rtl .fc-timegrid-more-link{left:0}.fc .fc-timegrid-now-indicator-arrow,.fc .fc-timegrid-now-indicator-line{pointer-events:none}.fc .fc-timegrid-now-indicator-line{border-color:var(--fc-now-indicator-color);border-style:solid;border-width:1px 0 0;left:0;position:absolute;right:0;z-index:4}.fc .fc-timegrid-now-indicator-arrow{border-color:var(--fc-now-indicator-color);border-style:solid;margin-top:-5px;position:absolute;z-index:4}.fc-direction-ltr .fc-timegrid-now-indicator-arrow{border-bottom-color:transparent;border-top-color:transparent;border-width:5px 0 5px 6px;left:0}.fc-direction-rtl .fc-timegrid-now-indicator-arrow{border-bottom-color:transparent;border-top-color:transparent;border-width:5px 6px 5px 0;right:0}');var uc=Po({name:"@fullcalendar/timegrid",initialView:"timeGridWeek",optionRefiners:{allDaySlot:Boolean},views:{timeGrid:{component:class extends Hl{constructor(){super(...arguments),this.buildTimeColsModel=Gt(dc),this.buildSlatMetas=Gt(cc)}render(){let{options:e,dateEnv:t,dateProfileGenerator:n}=this.context,{props:r}=this,{dateProfile:i}=r,s=this.buildTimeColsModel(i,n),o=this.allDaySplitter.splitProps(r),a=this.buildSlatMetas(i.slotMinTime,i.slotMaxTime,e.slotLabelInterval,e.slotDuration,t),{dayMinWidth:l}=e,c=!l,d=l,u=e.dayHeaders&&p(_s,{dates:s.headerDates,dateProfile:i,datesRepDistinctDays:!0,renderIntro:c?this.renderHeadAxis:null}),h=!1!==e.allDaySlot&&(t=>p(Cl,Object.assign({},o.allDay,{dateProfile:i,dayTableModel:s,nextDayThreshold:e.nextDayThreshold,tableMinWidth:t.tableMinWidth,colGroupNode:t.tableColGroupNode,renderRowIntro:c?this.renderTableRowAxis:null,showWeekNumbers:!1,expandRows:!1,headerAlignElRef:this.headerElRef,clientWidth:t.clientWidth,clientHeight:t.clientHeight,forPrint:r.forPrint},this.getAllDayMaxEventProps()))),f=t=>p(oc,Object.assign({},o.timed,{dayTableModel:s,dateProfile:i,axis:c,slotDuration:e.slotDuration,slatMetas:a,forPrint:r.forPrint,tableColGroupNode:t.tableColGroupNode,tableMinWidth:t.tableMinWidth,clientWidth:t.clientWidth,clientHeight:t.clientHeight,onSlatCoords:this.handleSlatCoords,expandRows:t.expandRows,onScrollTopRequest:this.handleScrollTopRequest}));return d?this.renderHScrollLayout(u,h,f,s.colCnt,l,a,this.state.slatCoords):this.renderSimpleLayout(u,h,f)}},usesMinMaxTime:!0,allDaySlot:!0,slotDuration:"00:30:00",slotEventOverlap:!0},timeGridDay:{type:"timeGrid",duration:{days:1}},timeGridWeek:{type:"timeGrid",duration:{weeks:1}}}});class hc extends Gn{constructor(){super(...arguments),this.state={textId:We()}}render(){let{theme:e,dateEnv:t,options:n,viewApi:r}=this.context,{cellId:i,dayDate:s,todayRange:o}=this.props,{textId:a}=this.state,l=Fi(s,o),c=n.listDayFormat?t.format(s,n.listDayFormat):"",d=n.listDaySideFormat?t.format(s,n.listDaySideFormat):"",u=Object.assign({date:t.toDate(s),view:r,textId:a,text:c,sideText:d,navLinkAttrs:qi(this.context,s),sideNavLinkAttrs:qi(this.context,s,"day",!1)},l);return p(Jn,{elTag:"tr",elClasses:["fc-list-day",...Vi(l,e)],elAttrs:{"data-date":Wt(s)},renderProps:u,generatorName:"dayHeaderContent",customGenerator:n.dayHeaderContent,defaultGenerator:fc,classNameGenerator:n.dayHeaderClassNames,didMount:n.dayHeaderDidMount,willUnmount:n.dayHeaderWillUnmount},t=>p("th",{scope:"colgroup",colSpan:3,id:i,"aria-labelledby":a},p(t,{elTag:"div",elClasses:["fc-list-day-cushion",e.getClass("tableCellShaded")]})))}}function fc(e){return p(y,null,e.text&&p("a",Object.assign({id:e.textId,className:"fc-list-day-text"},e.navLinkAttrs),e.text),e.sideText&&p("a",Object.assign({"aria-hidden":!0,className:"fc-list-day-side-text"},e.sideNavLinkAttrs),e.sideText))}const gc=an({hour:"numeric",minute:"2-digit",meridiem:"short"});class pc extends Gn{render(){let{props:e,context:t}=this,{options:n}=t,{seg:r,timeHeaderId:i,eventHeaderId:s,dateHeaderId:o}=e,a=n.eventTimeFormat||gc;return p(so,Object.assign({},e,{elTag:"tr",elClasses:["fc-list-event",r.eventRange.def.url&&"fc-event-forced-url"],defaultGenerator:()=>function(e,t){let n=bi(e,t);return p("a",Object.assign({},n),e.eventRange.def.title)}(r,t),seg:r,timeText:"",disableDragging:!0,disableResizing:!0}),(e,n)=>p(y,null,function(e,t,n,r,i){let{options:s}=n;if(!1!==s.displayEventTime){let o,a=e.eventRange.def,l=e.eventRange.instance,c=!1;if(a.allDay?c=!0:ur(e.eventRange.range)?e.isStart?o=pi(e,t,n,null,null,l.range.start,e.end):e.isEnd?o=pi(e,t,n,null,null,e.start,l.range.end):c=!0:o=pi(e,t,n),c){let e={text:n.options.allDayText,view:n.viewApi};return p(Jn,{elTag:"td",elClasses:["fc-list-event-time"],elAttrs:{headers:`${r} ${i}`},renderProps:e,generatorName:"allDayContent",customGenerator:s.allDayContent,defaultGenerator:mc,classNameGenerator:s.allDayClassNames,didMount:s.allDayDidMount,willUnmount:s.allDayWillUnmount})}return p("td",{className:"fc-list-event-time"},o)}return null}(r,a,t,i,o),p("td",{"aria-hidden":!0,className:"fc-list-event-graphic"},p("span",{className:"fc-list-event-dot",style:{borderColor:n.borderColor||n.backgroundColor}})),p(e,{elTag:"td",elClasses:["fc-list-event-title"],elAttrs:{headers:`${s} ${o}`}})))}}function mc(e){return e.text}function vc(e){return e.text}function yc(e){let t=Mt(e.renderRange.start),n=e.renderRange.end,r=[],i=[];for(;t*{border-left:0;border-right:0}.fc .fc-list-sticky .fc-list-day>*{background:var(--fc-page-bg-color);position:sticky;top:0}.fc .fc-list-table thead{left:-10000px;position:absolute}.fc .fc-list-table tbody>tr:first-child th{border-top:0}.fc .fc-list-table th{padding:0}.fc .fc-list-day-cushion,.fc .fc-list-table td{padding:8px 14px}.fc .fc-list-day-cushion:after{clear:both;content:"";display:table}.fc-theme-standard .fc-list-day-cushion{background-color:var(--fc-neutral-bg-color)}.fc-direction-ltr .fc-list-day-text,.fc-direction-rtl .fc-list-day-side-text{float:left}.fc-direction-ltr .fc-list-day-side-text,.fc-direction-rtl .fc-list-day-text{float:right}.fc-direction-ltr .fc-list-table .fc-list-event-graphic{padding-right:0}.fc-direction-rtl .fc-list-table .fc-list-event-graphic{padding-left:0}.fc .fc-list-event.fc-event-forced-url{cursor:pointer}.fc .fc-list-event:hover td{background-color:var(--fc-list-event-hover-bg-color)}.fc .fc-list-event-graphic,.fc .fc-list-event-time{white-space:nowrap;width:1px}.fc .fc-list-event-dot{border:calc(var(--fc-list-event-dot-width)/2) solid var(--fc-event-border-color);border-radius:calc(var(--fc-list-event-dot-width)/2);box-sizing:content-box;display:inline-block;height:0;width:0}.fc .fc-list-event-title a{color:inherit;text-decoration:none}.fc .fc-list-event.fc-event-forced-url:hover a{text-decoration:underline}');function bc(e){return!1===e?null:an(e)}var Ec=Po({name:"@fullcalendar/list",optionRefiners:{listDayFormat:bc,listDaySideFormat:bc,noEventsClassNames:yn,noEventsContent:yn,noEventsDidMount:yn,noEventsWillUnmount:yn},views:{list:{component:class extends ls{constructor(){super(...arguments),this.computeDateVars=Gt(yc),this.eventStoreToSegs=Gt(this._eventStoreToSegs),this.state={timeHeaderId:We(),eventHeaderId:We(),dateHeaderIdRoot:We()},this.setRootEl=e=>{e?this.context.registerInteractiveComponent(this,{el:e}):this.context.unregisterInteractiveComponent(this)}}render(){let{props:e,context:t}=this,{dayDates:n,dayRanges:r}=this.computeDateVars(e.dateProfile),i=this.eventStoreToSegs(e.eventStore,e.eventUiBases,r);return p(er,{elRef:this.setRootEl,elClasses:["fc-list",t.theme.getClass("table"),!1!==t.options.stickyHeaderDates?"fc-list-sticky":""],viewSpec:t.viewSpec},p(Fs,{liquid:!e.isHeightAuto,overflowX:e.isHeightAuto?"visible":"hidden",overflowY:e.isHeightAuto?"visible":"auto"},i.length>0?this.renderSegList(i,n):this.renderEmptyMessage()))}renderEmptyMessage(){let{options:e,viewApi:t}=this.context,n={text:e.noEventsText,view:t};return p(Jn,{elTag:"div",elClasses:["fc-list-empty"],renderProps:n,generatorName:"noEventsContent",customGenerator:e.noEventsContent,defaultGenerator:vc,classNameGenerator:e.noEventsClassNames,didMount:e.noEventsDidMount,willUnmount:e.noEventsWillUnmount},e=>p(e,{elTag:"div",elClasses:["fc-list-empty-cushion"]}))}renderSegList(e,t){let{theme:n,options:r}=this.context,{timeHeaderId:i,eventHeaderId:s,dateHeaderIdRoot:o}=this.state,a=function(e){let t,n,r=[];for(t=0;t{let c=[];for(let n=0;n{e&&this.updateSize()}}render(){const{context:e,props:t,state:n}=this,{options:r}=e,{clientWidth:i,clientHeight:s}=n,o=n.monthHPadding||0,a=Math.min(null!=i?Math.floor(i/(r.multiMonthMinWidth+o)):1,r.multiMonthMaxColumns)||1,l=100/a+"%",c=null==i?null:i/a-o,d=null!=i&&1===a,u=this.splitDateProfileByMonth(e.dateProfileGenerator,t.dateProfile,e.dateEnv,!d&&r.fixedWeekCount,r.showNonCurrentDates),h=this.buildMonthFormat(r.multiMonthTitleFormat,u),f=["fc-multimonth",d?"fc-multimonth-singlecol":"fc-multimonth-multicol",null!=c&&c<400?"fc-multimonth-compact":"",t.isHeightAuto?"":"fc-scroller"];return p(er,{elRef:this.scrollElRef,elClasses:f,viewSpec:e.viewSpec},u.map((e,n)=>{const r=Lt(e.currentRange.start);return p(Sc,Object.assign({},t,{key:r,isoDateStr:r,elRef:0===n?this.firstMonthElRef:void 0,titleFormat:h,dateProfile:e,width:l,tableWidth:c,clientWidth:i,clientHeight:s}))}))}componentDidMount(){this.updateSize(),this.context.addResizeHandler(this.handleSizing),this.requestScrollReset()}componentDidUpdate(e){Cn(e,this.props)||this.handleSizing(!1),e.dateProfile!==this.props.dateProfile?this.requestScrollReset():this.flushScrollReset()}componentWillUnmount(){this.context.removeResizeHandler(this.handleSizing)}updateSize(){const e=this.scrollElRef.current,t=this.firstMonthElRef.current;e&&this.setState({clientWidth:e.clientWidth,clientHeight:e.clientHeight}),t&&e&&null==this.state.monthHPadding&&this.setState({monthHPadding:e.clientWidth-t.firstChild.offsetWidth})}requestScrollReset(){this.needsScrollReset=!0,this.flushScrollReset()}flushScrollReset(){if(this.needsScrollReset&&null!=this.state.monthHPadding){const{currentDate:e}=this.props.dateProfile,t=this.scrollElRef.current,n=t.querySelector(`[data-date="${Lt(e)}"]`);t.scrollTop=n.getBoundingClientRect().top-this.firstMonthElRef.current.getBoundingClientRect().top,this.needsScrollReset=!1}}shouldComponentUpdate(){return!0}},dateProfileGeneratorClass:xl,multiMonthMinWidth:350,multiMonthMaxColumns:3},multiMonthYear:{type:"multiMonth",duration:{years:1},fixedWeekCount:!0,showNonCurrentDates:!1}}});return ca.push(el,Tl,uc,Ec,_c),e.Calendar=class extends Ni{constructor(e,t={}){super(),this.isRendering=!1,this.isRendered=!1,this.currentClassNames=[],this.customContentRenderId=0,this.handleAction=e=>{switch(e.type){case"SET_EVENT_DRAG":case"SET_EVENT_RESIZE":this.renderRunner.tryDrain()}},this.handleData=e=>{this.currentData=e,this.renderRunner.request(e.calendarOptions.rerenderDelay)},this.handleRenderRequest=()=>{if(this.isRendering){this.isRendered=!0;let{currentData:e}=this;jn(()=>{U(p(ki,{options:e.calendarOptions,theme:e.theme,emitter:e.emitter},(t,n,r,i)=>(this.setClassNames(t),this.setHeight(n),p($n.Provider,{value:this.customContentRenderId},p(_a,Object.assign({isHeightAuto:r,forPrint:i},e))))),this.el)})}else this.isRendered&&(this.isRendered=!1,U(null,this.el),this.setClassNames([]),this.setHeight(""))},function(e){e.isConnected&&e.getRootNode&&_e(e.getRootNode())}(e),this.el=e,this.renderRunner=new Me(this.handleRenderRequest),new ha({optionOverrides:t,calendarApi:this,onAction:this.handleAction,onData:this.handleData})}render(){let e=this.isRendering;e?this.customContentRenderId+=1:this.isRendering=!0,this.renderRunner.request(),e&&this.updateSize()}destroy(){this.isRendering&&(this.isRendering=!1,this.renderRunner.request())}updateSize(){jn(()=>{super.updateSize()})}batchRendering(e){this.renderRunner.pause("batchRendering"),e(),this.renderRunner.resume("batchRendering")}pauseRendering(){this.renderRunner.pause("pauseRendering")}resumeRendering(){this.renderRunner.resume("pauseRendering",!0)}resetOptions(e,t){this.currentDataManager.resetOptions(e,t)}setClassNames(e){if(!St(e,this.currentClassNames)){let{classList:t}=this.el;for(let e of this.currentClassNames)t.remove(e);for(let n of e)t.add(n);this.currentClassNames=e}}setHeight(e){je(this.el,"height",e)}},e.Draggable=class{constructor(e,t={}){this.handlePointerDown=e=>{let{dragging:t}=this,{minDistance:n,longPressDelay:r}=this.settings;t.minDistance=null!=n?n:e.isTouch?0:cn.eventDragMinDistance,t.delay=e.isTouch?null!=r?r:cn.longPressDelay:0},this.handleDragStart=e=>{e.isTouch&&this.dragging.delay&&e.subjectEl.classList.contains("fc-event")&&this.dragging.mirror.getMirrorEl().classList.add("fc-event-selected")},this.settings=t;let n=this.dragging=new Fa(e);n.touchScrollAllowed=!1,null!=t.itemSelector&&(n.pointer.selector=t.itemSelector),null!=t.appendTo&&(n.mirror.parentNode=t.appendTo),n.emitter.on("pointerdown",this.handlePointerDown),n.emitter.on("dragstart",this.handleDragStart),new Ja(n,t.eventData)}destroy(){this.dragging.destroy()}},e.Internal=xo,e.JsonRequestError=Ri,e.Preact=_o,e.ThirdPartyDraggable=class{constructor(e,t){let n=document;e===document||e instanceof Element?(n=e,t=t||{}):t=e||{};let r=this.dragging=new Ka(n);"string"==typeof t.itemSelector?r.pointer.selector=t.itemSelector:n===document&&(r.pointer.selector="[data-event]"),"string"==typeof t.mirrorSelector&&(r.mirrorSelector=t.mirrorSelector),new Ja(r,t.eventData).hitDragging.disablePointCheck=!0}destroy(){this.dragging.destroy()}},e.createPlugin=Po,e.formatDate=function(e,t={}){let n=Ma(t),r=an(t),i=n.createMarkerMeta(e);return i?n.format(i.marker,r,{forcedTzo:i.forcedTzo}):""},e.formatRange=function(e,t,n){let r=Ma("object"==typeof n&&n?n:{}),i=an(n),s=r.createMarkerMeta(e),o=r.createMarkerMeta(t);return s&&o?r.formatRange(s.marker,o.marker,i,{forcedStartTzo:s.forcedTzo,forcedEndTzo:o.forcedTzo,isEndExclusive:n.isEndExclusive,defaultSeparator:cn.defaultRangeSeparator}):""},e.globalLocales=To,e.globalPlugins=ca,e.sliceEvents=function(e,t){return ii(e.eventStore,e.eventUiBases,e.dateProfile.activeRange,t?e.nextDayThreshold:null).fg},e.version="6.1.15",Object.defineProperty(e,"__esModule",{value:!0}),e}({}); \ No newline at end of file diff --git a/static/js/request-guard.js b/static/js/request-guard.js new file mode 100644 index 0000000..f568bf9 --- /dev/null +++ b/static/js/request-guard.js @@ -0,0 +1,163 @@ +// static/js/request-guard.js +// 前端请求安全工具:防抖、节流、重复提交防护 +(function() { + 'use strict'; + + // ==================== 防抖函数 ==================== + /** + * 防抖函数 - 在事件触发n秒后再执行,n秒内再次触发则重新计时 + * @param {Function} func - 要执行的函数 + * @param {number} wait - 等待时间(毫秒) + * @param {boolean} immediate - 是否立即执行 + */ + window.debounce = function(func, wait, immediate) { + var timeout; + return function() { + var context = this; + var args = arguments; + clearTimeout(timeout); + timeout = setTimeout(function() { + func.apply(context, args); + }, wait); + }; + }; + + // ==================== 节流函数 ==================== + /** + * 节流函数 - 在规定时间内只执行一次 + * @param {Function} func - 要执行的函数 + * @param {number} wait - 时间间隔(毫秒) + */ + window.throttle = function(func, wait) { + var timeout; + var previous = 0; + return function() { + var context = this; + var args = arguments; + var now = Date.now(); + var remaining = wait - (now - previous); + + if (remaining <= 0 || remaining > wait) { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + previous = now; + func.apply(context, args); + } else if (!timeout) { + timeout = setTimeout(function() { + previous = Date.now(); + timeout = null; + func.apply(context, args); + }, remaining); + } + }; + }; + + // ==================== 请求锁(防止重复提交) ==================== + var RequestLock = { + locks: {}, + + /** + * 锁定指定key的请求 + * @param {string} key - 锁的标识 + * @param {number} duration - 锁定时长(毫秒),默认3秒 + * @returns {boolean} - 是否成功锁定 + */ + lock: function(key, duration) { + duration = duration || 3000; + if (this.locks[key]) { + return false; // 已存在锁 + } + this.locks[key] = true; + setTimeout(function() { + delete RequestLock.locks[key]; + }, duration); + return true; + }, + + /** + * 检查是否存在锁 + * @param {string} key - 锁的标识 + * @returns {boolean} + */ + isLocked: function(key) { + return !!this.locks[key]; + }, + + /** + * 解锁 + * @param {string} key - 锁的标识 + */ + unlock: function(key) { + delete this.locks[key]; + }, + + /** + * 带锁执行请求 + * @param {string} key - 锁的标识 + * @param {Function} requestFn - 请求函数(可选,传null则只做锁检查) + * @param {number} duration - 锁定时长 + * @returns {boolean} - 是否执行了请求 + */ + executeWithLock: function(key, requestFn, duration) { + if (this.isLocked(key)) { + if (typeof $toast !== 'undefined') { + $toast.warning('操作太频繁,请稍后再试'); + } + return false; + } + this.lock(key, duration); + if (typeof requestFn === 'function') { + requestFn(); + } + return true; + } + }; + window.RequestLock = RequestLock; + + // ==================== 请求ID生成器(防重复提交) ==================== + var RequestID = { + cache: {}, + + /** + * 生成请求ID + * @param {string} userId - 用户ID + * @param {string} action - 操作类型 + * @returns {string} + */ + generate: function(userId, action) { + return userId + '_' + action + '_' + Date.now(); + }, + + /** + * 记录请求ID(用于检测重复) + * @param {string} id - 请求ID + * @param {number} ttl - 有效期(毫秒) + * @returns {boolean} - 是否是新请求 + */ + record: function(id, ttl) { + ttl = ttl || 5000; // 默认5秒内不允许重复 + if (this.cache[id]) { + return false; + } + this.cache[id] = true; + var self = this; + setTimeout(function() { + delete self.cache[id]; + }, ttl); + return true; + }, + + /** + * 检查请求是否重复 + * @param {string} id - 请求ID + * @returns {boolean} + */ + isDuplicate: function(id) { + return !!this.cache[id]; + } + }; + window.RequestID = RequestID; + +})(); diff --git a/static/js/toast.js b/static/js/toast.js new file mode 100644 index 0000000..9d0f436 --- /dev/null +++ b/static/js/toast.js @@ -0,0 +1,241 @@ +// static/js/toast.js +// 美观的Toast消息提示组件(替代原生alert) +(function() { + 'use strict'; + + // Toast样式 + const style = document.createElement('style'); + style.textContent = ` + .toast-container { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 10000; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + pointer-events: none; + } + .toast { + padding: 12px 24px; + border-radius: 8px; + font-size: 14px; + color: #fff; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + animation: toastIn 0.3s ease; + pointer-events: auto; + max-width: 400px; + text-align: center; + line-height: 1.5; + } + .toast.success { + background: linear-gradient(135deg, #52c41a, #389e0d); + } + .toast.error { + background: linear-gradient(135deg, #ff4d4f, #cf1322); + } + .toast.warning { + background: linear-gradient(135deg, #faad14, #d48806); + } + .toast.info { + background: linear-gradient(135deg, #1890ff, #096dd9); + } + .toast.toast-out { + animation: toastOut 0.3s ease forwards; + } + @keyframes toastIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + @keyframes toastOut { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-20px); + } + } + /* 确认对话框样式 */ + .toast-confirm-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.5); + z-index: 10001; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.2s ease; + } + .toast-confirm-overlay.fade-out { + animation: fadeOut 0.2s ease forwards; + } + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + @keyframes fadeOut { + from { opacity: 1; } + to { opacity: 0; } + } + .toast-confirm-box { + background: #fff; + border-radius: 12px; + padding: 24px; + min-width: 280px; + max-width: 320px; + box-shadow: 0 8px 32px rgba(0,0,0,0.2); + animation: scaleIn 0.2s ease; + } + .toast-confirm-box.scale-out { + animation: scaleOut 0.2s ease forwards; + } + @keyframes scaleIn { + from { transform: scale(0.9); opacity: 0; } + to { transform: scale(1); opacity: 1; } + } + @keyframes scaleOut { + from { transform: scale(1); opacity: 1; } + to { transform: scale(0.9); opacity: 0; } + } + .toast-confirm-message { + font-size: 15px; + color: #1e293b; + text-align: center; + margin-bottom: 20px; + line-height: 1.5; + } + .toast-confirm-buttons { + display: flex; + gap: 12px; + } + .toast-confirm-btn { + flex: 1; + padding: 10px 16px; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + border: none; + transition: all 0.2s; + } + .toast-confirm-btn.cancel { + background: #f1f5f9; + color: #64748b; + } + .toast-confirm-btn.cancel:hover { + background: #e2e8f0; + } + .toast-confirm-btn.ok { + background: linear-gradient(135deg, #ff4d4f, #cf1322); + color: #fff; + } + .toast-confirm-btn.ok:hover { + opacity: 0.9; + } + `; + document.head.appendChild(style); + + // Toast容器 + let container = null; + function getContainer() { + if (!container) { + container = document.createElement('div'); + container.className = 'toast-container'; + document.body.appendChild(container); + } + return container; + } + + // 显示Toast + function showToast(message, type, duration) { + type = type || 'info'; + duration = duration || 3000; + + const toast = document.createElement('div'); + toast.className = 'toast ' + type; + toast.textContent = message; + + const c = getContainer(); + c.appendChild(toast); + + // 自动移除 + setTimeout(function() { + toast.classList.add('toast-out'); + setTimeout(function() { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }, 300); + }, duration); + + return toast; + } + + // 确认对话框 + function showConfirm(message) { + return new Promise(function(resolve) { + const overlay = document.createElement('div'); + overlay.className = 'toast-confirm-overlay'; + overlay.innerHTML = ` +
+
${message}
+
+ + +
+
+ `; + document.body.appendChild(overlay); + + const close = function(result) { + overlay.classList.add('fade-out'); + const box = overlay.querySelector('.toast-confirm-box'); + if (box) box.classList.add('scale-out'); + setTimeout(function() { + if (overlay.parentNode) { + overlay.parentNode.removeChild(overlay); + } + resolve(result); + }, 200); + }; + + overlay.querySelector('.cancel').addEventListener('click', function() { + close(false); + }); + overlay.querySelector('.ok').addEventListener('click', function() { + close(true); + }); + overlay.addEventListener('click', function(e) { + if (e.target === overlay) { + close(false); + } + }); + }); + } + + // 便捷方法 + window.$toast = { + success: function(msg, duration) { return showToast(msg, 'success', duration); }, + error: function(msg, duration) { return showToast(msg, 'error', duration); }, + warning: function(msg, duration) { return showToast(msg, 'warning', duration); }, + info: function(msg, duration) { return showToast(msg, 'info', duration); }, + show: function(msg, type, duration) { return showToast(msg, type, duration); }, + confirm: function(msg) { return showConfirm(msg); } + }; + + // 兼容Vue.prototype.$message风格 + window.$message = window.$toast; + +})(); diff --git a/static/js/web-components.js b/static/js/web-components.js new file mode 100644 index 0000000..82c3f62 --- /dev/null +++ b/static/js/web-components.js @@ -0,0 +1,115 @@ +// static/js/web-components.js +// Web工作台公共组件(头部、底部) + +// 渲染工作台头部 +function renderWebHeader(activeNav) { + const logoPath = (typeof SITE_CONFIG !== 'undefined') ? SITE_CONFIG.path : '/static/images/logo.png'; + const siteName = (typeof SITE_CONFIG !== 'undefined') ? SITE_CONFIG.name : '简记'; + + const navItems = [ + { path: '/web/calendar', label: '日程', id: 'calendar' }, + { path: '/web/growth', label: '成长中心', id: 'growth' }, + { path: '/web/profile', label: '个人中心', id: 'profile' } + ]; + + let navHtml = ''; + navItems.forEach(item => { + const isActive = item.id === activeNav; + navHtml += `${item.label}`; + }); + + const nickname = localStorage.getItem('user_nickname') || '用户'; + const avatar = localStorage.getItem('user_avatar'); + const avatarHtml = avatar + ? `` + : nickname.charAt(0).toUpperCase(); + + return ` +
+
+ + ${siteName} + ${siteName} + + +
+ +
+ `; +} + +// 渲染工作台底部 +function renderWebFooter() { + return ` + + `; +} + +// 退出登录 +function handleWebLogout() { + if (typeof $toast !== 'undefined' && $toast.confirm) { + $toast.confirm('确定退出登录?').then(function(confirmed) { + if (confirmed) { + doLogout(); + } + }); + } else { + if (confirm('确定退出登录?')) { + doLogout(); + } + } +} + +function doLogout() { + localStorage.clear(); + window.location.href = '/login'; +} + +// 检查登录状态 +function checkWebLogin() { + const token = localStorage.getItem('user_token'); + if (!token) { + window.location.href = '/login'; + return false; + } + // 检查token是否过期 + const expiresAt = localStorage.getItem('user_token_expires'); + if (expiresAt && Date.now() > parseInt(expiresAt)) { + localStorage.clear(); + window.location.href = '/login'; + return false; + } + return true; +} + +// 初始化Web页面 +function initWebPage(activeNav) { + if (!checkWebLogin()) return; + + // 插入头部 + const headerContainer = document.getElementById('webHeader'); + if (headerContainer) { + headerContainer.innerHTML = renderWebHeader(activeNav); + } + + // 插入底部 + const footerContainer = document.getElementById('webFooter'); + if (footerContainer) { + footerContainer.innerHTML = renderWebFooter(); + } +} diff --git a/utils/aes.go b/utils/aes.go new file mode 100644 index 0000000..7ac59af --- /dev/null +++ b/utils/aes.go @@ -0,0 +1,87 @@ +// AES加解密:请求体加密 +package utils + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "simple-memo/global" +) + +// 注意:这里不再直接初始化,等到配置加载后再赋值 +var AESKey []byte +var AESIV []byte + +// InitAES 初始化AES密钥(必须在配置加载后调用) +func InitAES() { + AESKey = []byte(global.VP.GetString("aes.key")) // 32位 + AESIV = []byte(global.VP.GetString("aes.iv")) // 16位 +} + +// PKCS7Padding 填充(修复完成) +func PKCS7Padding(ciphertext []byte, blockSize int) []byte { + padding := blockSize - len(ciphertext)%blockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(ciphertext, padText...) // 这里加 ... 即可修复 +} + +// AESEncrypt 加密 +func AESEncrypt(origData []byte) (string, error) { + block, err := aes.NewCipher(AESKey) + if err != nil { + return "", err + } + blockSize := block.BlockSize() + origData = PKCS7Padding(origData, blockSize) + blockMode := cipher.NewCBCEncrypter(block, AESIV) + encrypted := make([]byte, len(origData)) + blockMode.CryptBlocks(encrypted, origData) + return base64.StdEncoding.EncodeToString(encrypted), nil +} + +// AESDecrypt 解密 +func AESDecrypt(encrypted string) ([]byte, error) { + block, err := aes.NewCipher(AESKey) + if err != nil { + return nil, err + } + decoded, err := base64.StdEncoding.DecodeString(encrypted) + if err != nil { + return nil, err + } + blockMode := cipher.NewCBCDecrypter(block, AESIV) + orig := make([]byte, len(decoded)) + blockMode.CryptBlocks(orig, decoded) + + // ========= 这里换成安全版去填充 ========= + orig = PKCS7UnPaddingSafe(orig) + return orig, nil +} + +// PKCS7UnPadding 去填充(原版保留,不删) +func PKCS7UnPadding(origData []byte) []byte { + length := len(origData) + if length == 0 { + return []byte{} // 空数据直接返回,不崩溃 + } + unPadding := int(origData[length-1]) + return origData[:length-unPadding] +} + +// PKCS7UnPaddingSafe 安全去填充,永不 panic +func PKCS7UnPaddingSafe(origData []byte) []byte { + length := len(origData) + if length == 0 { + return []byte{} + } + + unPadding := int(origData[length-1]) + + // ========= 安全校验:防止 slice 越界崩溃 ========= + if unPadding < 1 || unPadding > length { + return origData + } + + return origData[:length-unPadding] +} diff --git a/utils/context.go b/utils/context.go new file mode 100644 index 0000000..021eaa2 --- /dev/null +++ b/utils/context.go @@ -0,0 +1,16 @@ +// 链路追踪上下文 key 定义 +package utils + +// contextKey 私有类型防止外部冲突 +type contextKey string + +const ( + // RequestIdKey 请求ID + RequestIdKey contextKey = "request_id" + // RequestRouteKey 请求路由 + RequestRouteKey contextKey = "request_route" + // ClientIPKey 客户端IP + ClientIPKey contextKey = "client_ip" + // UserIDKey 用户ID + UserIDKey contextKey = "user_id" +) diff --git a/utils/date.go b/utils/date.go new file mode 100644 index 0000000..5eabb13 --- /dev/null +++ b/utils/date.go @@ -0,0 +1,55 @@ +// utils/date.go +// 日期辅助函数 +package utils + +import ( + "time" +) + +// GetCurrentYear 获取当前年份 +func GetCurrentYear() int { + return time.Now().Year() +} + +// GetCurrentMonth 获取当前月份 +func GetCurrentMonth() int { + return int(time.Now().Month()) +} + +// GetMonthLastDay 获取某年某月的最后一天 +func GetMonthLastDay(year, month int) (yearOut, lastDay int) { + // 使用time.Date构建该月第一天的日期 + firstDay := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.Local) + // 添加一个月,然后减去一天得到最后一天 + lastDayDate := firstDay.AddDate(0, 1, -1) + return lastDayDate.Year(), lastDayDate.Day() +} + +// FormatDate 格式化日期为字符串 +func FormatDate(t time.Time) string { + return t.Format("2006-01-02") +} + +// FormatDateTime 格式化日期时间为字符串 +func FormatDateTime(t time.Time) string { + return t.Format("2006-01-02 15:04:05") +} + +// DurUntilTomorrowZero 计算当前时间到明天零点的间隔 +func DurUntilTomorrowZero() time.Duration { + now := time.Now() + tomorrowZero := now.Truncate(24*time.Hour).AddDate(0, 0, 1) + return tomorrowZero.Sub(now) +} + +// GetSecondsUntilTomorrowZero 获取当前时间到明天零点之间的秒数 +func GetSecondsUntilTomorrowZero() int64 { + now := time.Now() + // 今天零点 + todayZero := now.Truncate(24 * time.Hour) + // 明天零点 + tomorrowZero := todayZero.AddDate(0, 0, 1) + diff := tomorrowZero.Sub(now) + // 转秒 + return int64(time.Duration(diff.Seconds())) +} diff --git a/utils/email.go b/utils/email.go new file mode 100644 index 0000000..14ded9d --- /dev/null +++ b/utils/email.go @@ -0,0 +1,183 @@ +// 邮件发送工具 +package utils + +import ( + "crypto/tls" + "fmt" + "go.uber.org/zap" + "net/smtp" + "simple-memo/global" + "time" +) + +// SendEmail 发送邮件 (阿里企业邮箱标准实现) +func SendEmail(to, subject, body string) error { + from := global.VP.GetString("email.from") + smtpHost := global.VP.GetString("email.smtp_host") + smtpPort := global.VP.GetInt("email.smtp_port") + smtpUser := global.VP.GetString("email.smtp_user") + smtpPassword := global.VP.GetString("email.smtp_password") + + if smtpUser == "" || smtpPassword == "" { + return fmt.Errorf("邮件配置未完善") + } + + auth := smtp.PlainAuth("", smtpUser, smtpPassword, smtpHost) + + msg := []byte(fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s", + from, to, subject, body)) + + addr := fmt.Sprintf("%s:%d", smtpHost, smtpPort) + + var client *smtp.Client + var err error + + switch smtpPort { + case 465: + tlsConfig := &tls.Config{ + ServerName: smtpHost, + InsecureSkipVerify: true, + } + + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + global.Logger.Error("TLS连接失败", zap.String("host", smtpHost), zap.Int("port", smtpPort), zap.Error(err)) + return fmt.Errorf("TLS连接失败: %v", err) + } + defer conn.Close() + + client, err = smtp.NewClient(conn, smtpHost) + if err != nil { + global.Logger.Error("创建SMTP客户端失败", zap.Error(err)) + return fmt.Errorf("创建SMTP客户端失败: %v", err) + } + defer client.Quit() + + if err = client.Auth(auth); err != nil { + global.Logger.Error("SMTP认证失败", zap.Error(err)) + return fmt.Errorf("SMTP认证失败: %v", err) + } + + if err = client.Mail(smtpUser); err != nil { + global.Logger.Error("设置发件人失败", zap.Error(err)) + return fmt.Errorf("设置发件人失败: %v", err) + } + + if err = client.Rcpt(to); err != nil { + global.Logger.Error("设置收件人失败", zap.String("to", to), zap.Error(err)) + return fmt.Errorf("设置收件人失败: %v", err) + } + + w, err := client.Data() + if err != nil { + global.Logger.Error("获取数据写入器失败", zap.Error(err)) + return fmt.Errorf("获取数据写入器失败: %v", err) + } + + _, err = w.Write(msg) + if err != nil { + w.Close() + global.Logger.Error("写入邮件内容失败", zap.Error(err)) + return fmt.Errorf("写入邮件内容失败: %v", err) + } + + err = w.Close() + if err != nil { + global.Logger.Error("关闭写入器失败", zap.Error(err)) + return fmt.Errorf("关闭写入器失败: %v", err) + } + + global.Logger.Info("邮件发送成功", zap.String("to", to), zap.String("subject", subject)) + return nil + + case 587: + client, err = smtp.Dial(addr) + if err != nil { + global.Logger.Error("连接SMTP服务器失败", zap.String("addr", addr), zap.Error(err)) + return fmt.Errorf("连接SMTP服务器失败: %v", err) + } + defer client.Close() + + if err = client.StartTLS(&tls.Config{ServerName: smtpHost}); err != nil { + global.Logger.Error("启动TLS失败", zap.Error(err)) + return fmt.Errorf("启动TLS失败: %v", err) + } + + if err = client.Auth(auth); err != nil { + global.Logger.Error("SMTP认证失败", zap.Error(err)) + return fmt.Errorf("SMTP认证失败: %v", err) + } + + if err = client.Mail(smtpUser); err != nil { + global.Logger.Error("设置发件人失败", zap.Error(err)) + return fmt.Errorf("设置发件人失败: %v", err) + } + + if err = client.Rcpt(to); err != nil { + global.Logger.Error("设置收件人失败", zap.String("to", to), zap.Error(err)) + return fmt.Errorf("设置收件人失败: %v", err) + } + + w, err := client.Data() + if err != nil { + global.Logger.Error("获取数据写入器失败", zap.Error(err)) + return fmt.Errorf("获取数据写入器失败: %v", err) + } + + _, err = w.Write(msg) + if err != nil { + w.Close() + global.Logger.Error("写入邮件内容失败", zap.Error(err)) + return fmt.Errorf("写入邮件内容失败: %v", err) + } + + err = w.Close() + if err != nil { + global.Logger.Error("关闭写入器失败", zap.Error(err)) + return fmt.Errorf("关闭写入器失败: %v", err) + } + + global.Logger.Info("邮件发送成功", zap.String("to", to), zap.String("subject", subject)) + return nil + + default: + err = smtp.SendMail(addr, auth, smtpUser, []string{to}, msg) + if err != nil { + global.Logger.Error("SMTP发送失败", zap.String("addr", addr), zap.Error(err)) + return fmt.Errorf("SMTP发送失败: %v", err) + } + global.Logger.Info("邮件发送成功", zap.String("to", to), zap.String("subject", subject)) + return nil + } +} + +// SendEmailCode 发送邮箱验证码 +func SendEmailCode(to, code string) error { + subject := "简记memo - 邮箱验证" + + body := fmt.Sprintf(`邮箱验证
简记memo

您好,

您正在进行邮箱验证操作,以下是您的验证码:

%s

验证码有效期为5分钟,请在有效期内完成验证。

如果这不是您的操作,请忽略此邮件。

简记memo团队

`, code) + + // 使用重试机制发送邮件 + var lastErr error + for attempt := 1; attempt <= 3; attempt++ { + err := SendEmail(to, subject, body) + if err == nil { + global.Logger.Info("邮件发送成功", zap.String("to", to), zap.String("subject", subject)) + return nil + } + + lastErr = err + global.Logger.Warn("邮件发送失败,准备重试", + zap.String("to", to), + zap.Int("attempt", attempt), + zap.Int("maxRetries", 3), + zap.Error(err)) + + // 等待一段时间后重试(指数退避) + if attempt < 3 { + time.Sleep(time.Duration(attempt) * time.Second) + } + } + + return fmt.Errorf("邮件发送失败,已重试3次: %v", lastErr) +} diff --git a/utils/jwt.go b/utils/jwt.go new file mode 100644 index 0000000..0d0a8a3 --- /dev/null +++ b/utils/jwt.go @@ -0,0 +1,83 @@ +// JWT登录鉴权 +package utils + +import ( + "simple-memo/global" + "time" + + "github.com/golang-jwt/jwt/v4" +) + +// Claims 自定义载荷 +type Claims struct { + UserID uint // 用户ID + jwt.RegisteredClaims +} + +// GenerateToken 生成Token +func GenerateToken(userID uint) (string, error) { + claims := Claims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(global.VP.GetInt("jwt.expire")) * time.Second)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + // 签发 + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(global.VP.GetString("jwt.key"))) +} + +// GenerateTokenWithExpire 生成Token并返回过期时间 +func GenerateTokenWithExpire(userID uint) (string, int64, error) { + expireSeconds := global.VP.GetInt("jwt.expire") + if expireSeconds == 0 { + expireSeconds = 7200 // 默认2小时 + } + expiresAt := time.Now().Add(time.Duration(expireSeconds) * time.Second).UnixMilli() + + claims := Claims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.UnixMilli(expiresAt)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenStr, err := token.SignedString([]byte(global.VP.GetString("jwt.key"))) + return tokenStr, expiresAt, err +} + +// GetTokenExpireSeconds 获取配置的token过期秒数 +func GetTokenExpireSeconds() int64 { + expireSeconds := global.VP.GetInt("jwt.expire") + if expireSeconds == 0 { + return 7200 // 默认2小时 + } + return int64(expireSeconds) +} + +// GenerateRandomString 生成随机字符串 +func GenerateRandomString(length int) string { + const charset = "0123456789" + b := make([]byte, length) + for i := range b { + b[i] = charset[global.Rand.Intn(len(charset))] + } + return string(b) +} + +// ParseToken 解析Token +func ParseToken(token string) (*Claims, error) { + t, err := jwt.ParseWithClaims(token, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(global.VP.GetString("jwt.key")), nil + }) + if err != nil { + return nil, err + } + // 校验 + if claims, ok := t.Claims.(*Claims); ok && t.Valid { + return claims, nil + } + return nil, err +} diff --git a/utils/response.go b/utils/response.go new file mode 100644 index 0000000..820b97b --- /dev/null +++ b/utils/response.go @@ -0,0 +1,117 @@ +// 统一返回格式 + 自动加密 +package utils + +import ( + "encoding/json" + "go.uber.org/zap" + "net/http" + "simple-memo/global" + + "github.com/gin-gonic/gin" +) + +// Response 统一返回结构体 +type Response struct { + Code int `json:"code"` // 200成功 + Msg string `json:"msg"` // 提示信息 + Data interface{} `json:"data,omitempty"` +} + +// LogContextFields 从 context 中提取链路日志字段 +func LogContextFields(c *gin.Context) []zap.Field { + fields := []zap.Field{} + if c == nil { + return fields + } + + ctx := c.Request.Context() + + // 优先从标准 context 读取 + if v := ctx.Value(RequestIdKey); v != nil { + fields = append(fields, zap.String("request_id", v.(string))) + } else if v, ok := c.Get("request_id"); ok { + fields = append(fields, zap.String("request_id", v.(string))) + } + + if v := ctx.Value(RequestRouteKey); v != nil { + fields = append(fields, zap.String("route", v.(string))) + } else if v, ok := c.Get("route"); ok { + fields = append(fields, zap.String("route", v.(string))) + } + + if v := ctx.Value(ClientIPKey); v != nil { + fields = append(fields, zap.String("client_ip", v.(string))) + } else if v, ok := c.Get("client_ip"); ok { + fields = append(fields, zap.String("client_ip", v.(string))) + } + + if uid := c.GetUint("userID"); uid > 0 { + fields = append(fields, zap.Uint("user_id", uid)) + } + + return fields +} + +// Result 统一返回(根据请求路径决定是否加密) +func Result(c *gin.Context, code int, msg string, data interface{}) { + // 添加缓存控制头,防止浏览器缓存 + c.Header("Cache-Control", "no-cache, no-store, must-revalidate") + c.Header("Pragma", "no-cache") + c.Header("Expires", "0") + + resp := Response{Code: code, Msg: msg, Data: data} + + // 先序列化成JSON + jsonStr, _ := json.Marshal(resp) + + // ====================== 日志:打印【要加密的明文】 ====================== + baseFields := LogContextFields(c) + global.Logger.Info("【返回原始明文】", + append(baseFields, + zap.String("path", c.Request.URL.Path), + zap.String("raw_data", string(jsonStr)), + )..., + ) + + // 判断是否为网页端接口(/api/web 路径) + isWebAPI := len(c.Request.URL.Path) >= 8 && c.Request.URL.Path[:8] == "/api/web" + + if isWebAPI { + // 网页端:直接返回JSON,不加密 + c.JSON(http.StatusOK, resp) + } else { + // 小程序端:加密返回 + encrypted, err := AESEncrypt(jsonStr) + if err != nil { + global.Logger.Error("加密失败", + append(baseFields, zap.Error(err))..., + ) + c.JSON(http.StatusOK, Response{Code: 500, Msg: "加密错误"}) + return + } + + // ====================== 日志:打印【加密后返回给前端的串】 ====================== + global.Logger.Info("【返回加密数据】", + append(baseFields, + zap.String("encrypt_data", encrypted), + )..., + ) + + c.JSON(http.StatusOK, gin.H{"data": encrypted}) + } +} + +// Ok 成功 +func Ok(c *gin.Context, data interface{}) { + Result(c, 200, "success", data) +} + +// Fail 失败 +func Fail(c *gin.Context, msg string, code ...int) { + + needCode := 400 + if len(code) > 0 { + needCode = code[0] + } + Result(c, needCode, msg, nil) +} diff --git a/utils/sensitive.go b/utils/sensitive.go new file mode 100644 index 0000000..e981e28 --- /dev/null +++ b/utils/sensitive.go @@ -0,0 +1,110 @@ +// utils/sensitive.go +// 敏感词过滤工具 +package utils + +import ( + "embed" + "strings" + "sync" + + "github.com/importcjj/sensitive" +) + +//go:embed sensitive_words.txt +var sensitiveWordsFS embed.FS + +var ( + filter *sensitive.Filter + filterOnce sync.Once +) + +// GetSensitiveFilter 获取敏感词过滤器单例 +func GetSensitiveFilter() *sensitive.Filter { + filterOnce.Do(func() { + filter = sensitive.New() + loadDefaultWords() + }) + return filter +} + +// loadDefaultWords 加载默认敏感词库 +func loadDefaultWords() { + // 从嵌入的文件系统读取 + data, err := sensitiveWordsFS.ReadFile("sensitive_words.txt") + if err != nil { + // 如果文件不存在,使用内置的基础词库 + loadBuiltinWords() + return + } + + words := strings.Split(string(data), "\n") + for _, word := range words { + word = strings.TrimSpace(word) + if word != "" && !strings.HasPrefix(word, "#") { + filter.AddWord(word) + } + } +} + +// loadBuiltinWords 加载内置基础敏感词 +func loadBuiltinWords() { + // 基硎敏感词列表 + builtinWords := []string{ + // 政治相关 + "习近平", "江泽民", "胡锦涛", "温家宝", "李克强", + "法轮功", "六四", "天安门", "台独", "藏独", "疆独", + // 色情相关 + "色情", "黄色", "裸体", "做爱", "性交", "强奸", "乱伦", + // 暴力相关 + "杀人", "自杀", "爆炸", "恐怖", "炸弹", "袭击", + // 赌博相关 + "赌博", "赌钱", "博彩", "六合彩", + // 毒品相关 + "毒品", "吸毒", "贩毒", "冰毒", "海洛因", + // 侮辱性词汇 + "傻逼", "操你", "妈的", "王八蛋", "畜生", + } + + for _, word := range builtinWords { + filter.AddWord(word) + } +} + +// FilterSensitive 过滤敏感词,替换为 * +func FilterSensitive(text string) string { + f := GetSensitiveFilter() + return f.Replace(text, '*') +} + +// ContainsSensitive 检查是否包含敏感词 +func ContainsSensitive(text string) bool { + f := GetSensitiveFilter() + has, _ := f.FindIn(text) + return has +} + +// FindAllSensitive 查找所有敏感词 +func FindAllSensitive(text string) []string { + f := GetSensitiveFilter() + return f.FindAll(text) +} + +// ValidateContent 验证内容,返回是否通过和第一个敏感词 +func ValidateContent(text string) (bool, string) { + f := GetSensitiveFilter() + return f.Validate(text) +} + +// AddSensitiveWord 动态添加敏感词 +func AddSensitiveWord(word string) { + f := GetSensitiveFilter() + f.AddWord(word) +} + +// AddSensitiveWords 批量添加敏感词 +func AddSensitiveWords(words []string) { + f := GetSensitiveFilter() + for _, word := range words { + f.AddWord(word) + } +} diff --git a/utils/sensitive_words.txt b/utils/sensitive_words.txt new file mode 100644 index 0000000..3516e25 --- /dev/null +++ b/utils/sensitive_words.txt @@ -0,0 +1,77 @@ +# 敏感词库 +# 每行一个敏感词,# 开头的行为注释 +# 政治敏感词 +习近平 +江泽民 +胡锦涛 +温家宝 +李克强 +法轮功 +六四 +天安门 +台独 +藏独 +疆独 +反共 +反党 + +# 色情相关 +色情 +黄色 +裸体 +做爱 +性交 +强奸 +乱伦 +嫖娼 +卖淫 + AV +av +淫秽 + +# 暴力相关 +杀人 +自杀 +爆炸 +恐怖 +炸弹 +袭击 +砍人 +刺杀 + +# 赌博相关 +赌博 +赌钱 +博彩 +六合彩 +赌场 +赌球 + +# 毒品相关 +毒品 +吸毒 +贩毒 +冰毒 +海洛因 +大麻 +摇头丸 + +# 侮辱性词汇 +傻逼 +操你 +妈的 +王八蛋 +畜生 +滚蛋 +去死 +草泥马 +他妈的 +他妈 + +# 广告相关 +代开发票 +办证 +刻章 +枪支 +迷药 +催情 diff --git a/utils/user_code.go b/utils/user_code.go new file mode 100644 index 0000000..c70e92c --- /dev/null +++ b/utils/user_code.go @@ -0,0 +1,37 @@ +package utils + +import ( + "math/rand" + "simple-memo/global" + "simple-memo/models" + "time" +) + +const userCodeChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +func GenerateUserCode() string { + for { + code := generateRandomCode(8) + if isUserCodeUnique(code) { + return code + } + } +} + +func generateRandomCode(length int) string { + result := make([]byte, length) + for i := range result { + result[i] = userCodeChars[rand.Intn(len(userCodeChars))] + } + return string(result) +} + +func isUserCodeUnique(code string) bool { + var count int64 + global.DB.Model(&models.User{}).Where("user_code = ?", code).Count(&count) + return count == 0 +} diff --git a/utils/wechat.go b/utils/wechat.go new file mode 100644 index 0000000..7bd9f85 --- /dev/null +++ b/utils/wechat.go @@ -0,0 +1,120 @@ +// 微信登录工具 +package utils + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "simple-memo/global" +) + +// WXLoginResp 微信登录响应 +type WXLoginResp struct { + OpenID string `json:"openid"` + SessionKey string `json:"session_key"` + UnionID string `json:"unionid"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + +// WXWebLoginResp 微信网页授权响应 +type WXWebLoginResp struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + OpenID string `json:"openid"` + Scope string `json:"scope"` + UnionID string `json:"unionid"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + +// WXUserInfo 微信用户信息 +type WXUserInfo struct { + OpenID string `json:"openid"` + Nickname string `json:"nickname"` + Sex int `json:"sex"` + Province string `json:"province"` + City string `json:"city"` + Country string `json:"country"` + HeadImgURL string `json:"headimgurl"` + Privilege []string `json:"privilege"` + UnionID string `json:"unionid"` +} + +// MiniProgramCode2Session 小程序登录:获取openid和session_key +func MiniProgramCode2Session(code string) (WXLoginResp, error) { + appID := global.VP.GetString("wechat.app_id") + appSecret := global.VP.GetString("wechat.app_secret") + + url := fmt.Sprintf("https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", + appID, appSecret, code) + + return requestWXAPI(url) +} + +// WebCode2Session 网页授权:获取access_token和openid +func WebCode2Session(code string) (WXWebLoginResp, error) { + appID := global.VP.GetString("wechat.app_id") + appSecret := global.VP.GetString("wechat.app_secret") + + url := fmt.Sprintf("https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code", + appID, appSecret, code) + + resp, err := http.Get(url) + if err != nil { + return WXWebLoginResp{}, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return WXWebLoginResp{}, err + } + + var wxResp WXWebLoginResp + if err = json.Unmarshal(data, &wxResp); err != nil { + return WXWebLoginResp{}, err + } + + if wxResp.ErrCode != 0 { + return WXWebLoginResp{}, fmt.Errorf("微信授权失败:%s", wxResp.ErrMsg) + } + + return wxResp, nil +} + +// GetWebAuthURL 获取微信网页授权URL +func GetWebAuthURL(state string) string { + appID := global.VP.GetString("wechat.app_id") + redirectURI := global.VP.GetString("wechat.web_redirect_uri") + + return fmt.Sprintf("https://open.weixin.qq.com/connect/qrconnect?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_login&state=%s#wechat_redirect", + appID, redirectURI, state) +} + +// requestWXAPI 请求微信API通用方法 +func requestWXAPI(url string) (WXLoginResp, error) { + resp, err := http.Get(url) + if err != nil { + return WXLoginResp{}, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return WXLoginResp{}, err + } + + var wxResp WXLoginResp + if err = json.Unmarshal(data, &wxResp); err != nil { + return WXLoginResp{}, err + } + + if wxResp.ErrCode != 0 { + return WXLoginResp{}, fmt.Errorf("微信登录失败:%s", wxResp.ErrMsg) + } + + return wxResp, nil +}