Compare commits

1 Commits
Author SHA1 Message Date
sunct 3c3bf53ae4 docs(website): 添加简记memo产品原型和UI设计规范文档
- 新增「简记memo」一体化小程序产品原型设计文档
- 新增简记memo完整版UI视觉设计规范和界面细节
- 添加IDEA项目配置文件.gitignore
- 创建404页面HTML文件,包含响应式布局和错误提示
- 添加关于页面HTML文件,展示品牌介绍和团队信息
- 实现AES加解密工具函数,支持请求体加密
- 添加用户协议页面基础框架
2026-07-31 14:12:32 +08:00
116 changed files with 21304 additions and 3 deletions
+8
View File
@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="com.codeverse.userSettings.MarscodeWorkspaceAppSettingsState">
<option name="progress" value="1.0" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxBlameSettings">
<option name="version" value="2" />
</component>
</project>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="migrated" value="true" />
<option name="pristineConfig" value="false" />
<option name="userId" value="-5e4c327f:187eae3199f:-8000" />
<option name="version" value="6.16.2" />
</MTProjectMetadataState>
</option>
<option name="titleBarState">
<MTProjectTitleBarConfigState>
<option name="overrideColor" value="false" />
</MTProjectTitleBarConfigState>
</option>
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/simple-memo.iml" filepath="$PROJECT_DIR$/.idea/simple-memo.iml" />
</modules>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+3
View File
@@ -0,0 +1,3 @@
{
"topic_20260729-054121_491ef2923de04c48": 1785303681311
}
@@ -0,0 +1,3 @@
{
"topic_20260729-054121_491ef2923de04c48": "auto"
}
+3
View File
@@ -0,0 +1,3 @@
{
"topic_20260729-054121_491ef2923de04c48": "新的会话"
}
-3
View File
@@ -1,3 +0,0 @@
# simple-memo
simple-memo
+507
View File
@@ -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,
}
}
+398
View File
@@ -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
}
+539
View File
@@ -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,
})
}
+95
View File
@@ -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, "删除成功")
}
+122
View File
@@ -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)
}
+66
View File
@@ -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, "验证码错误")
}
}
+161
View File
@@ -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)
}
+149
View File
@@ -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)
}
+206
View File
@@ -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)
}
+1311
View File
File diff suppressed because it is too large Load Diff
+411
View File
@@ -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,
})
}
+266
View File
@@ -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,
})
}
+436
View File
@@ -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,
})
}
+982
View File
@@ -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(&currentUser, 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(&currentUser, 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, "微信绑定成功")
}
+92
View File
@@ -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)
}
+20
View File
@@ -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
+19
View File
@@ -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 "编译完成!"
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+48
View File
@@ -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)
}
+48
View File
@@ -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 <memo_noreply@miaoall.cn>"
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
+48
View File
@@ -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 <memo_noreply@miaoall.cn>"
ai:
provider: "spark"
api_password: "OjAdJyOqCVmVYUFyaQJq:GQZNWpiVJTytmiSWuaTq"
base_url: "https://spark-api-open.xf-yun.com/v1"
model: "lite"
max_tokens: 1024
timeout: 30
+37
View File
@@ -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 // 赋值给全局
}
+58
View File
@@ -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))
}
+25
View File
@@ -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())
}
+50
View File
@@ -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
}
+24
View File
@@ -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
}
+28
View File
@@ -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
}
@@ -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
- priority1高/2中/3低)
- repeatnone/day/week/month
- donetrue/false
- subtasks(数组:[{title,done}]
- dateyyyy-MM-dd
- createTime
- updateTime
## 2. bills(账单)
- _id
- openid
- typeincome/expend
- money(数字,正数)
- cate(分类)
- icon(图标)
- color(颜色)
- note(备注)
- dateyyyy-MM-dd
- createTime
## 3. userConfig(用户配置)
- _id
- openid
- themelight/dark/auto
- budget(月预算)
- autoPostpone(自动顺延)
- showAnimation(动画)
- customCate(自定义分类数组)
- lastBackup(最后备份时间)
---
# 五、核心业务逻辑(完整闭环)
1. **任务自动顺延**
每天0点,未完成、非重复、非已完成 → 复制到今日,标记顺延
2. **重复任务生成**
每日/每周/每月自动生成下一条任务,不修改原任务
3. **预算计算**
月支出总和对比预算,超支变红、进度条>100%
4. **数据权限**
云数据库权限只允许 openid 等于自身读写,绝对安全
5. **统计口径**
按自然月统计,不跨月混淆
6. **操作无刷新**
所有增删改查本地状态同步 + 云更新,不白屏、不重载
---
# 六、交互体验(简约但高级)
- 所有按钮有反馈
- 完成任务有轻微动效(不花哨)
- 列表滑动流畅
- 键盘自动收起
- 加载状态弱提示
- 错误提示轻量不打扰
- 无任何广告、无推送、无外链
---
# 七、产品亮点(真正「简约不简单」)
- 界面极简,功能专业
- 待办 + 记账深度融合,不割裂
- 重复任务、子任务、顺延、预算、图表、导出全有
- 单人可开发、可维护、可上线
- 无第三方依赖、无封闭资源、全自主
- 个人主体可过审
- 自用质感极强,可长期使用
---
如果你需要,我可以**下一步直接输出:**
1. 全套**小程序页面目录结构**
2. 每个页面的**WXML + WXSS + JS 骨架代码**
3. **云数据库创建脚本**
4. **全局工具函数(请求、日期、格式化、统计)**
5. **可直接复制使用的配色+字体+样式规范**
要我继续把**可直接开发的完整技术原型**一次性给全吗?
+217
View File
@@ -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 / 20px600
- 正文:16px400
- 小字:14px400
- 辅助文字:12px400
- 无衬线、无花字体、极度干净
## 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 代码**吗?
+339
View File
@@ -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 保持一致
**设计理念**: "限制 = 伤害,激励 = 成长"
+670
View File
@@ -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`
+238
View File
@@ -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月*
+1109
View File
File diff suppressed because it is too large Load Diff
+160
View File
@@ -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 统一样式
需要我直接把**可马上开发的完整技术原型**全部给你吗?
+25
View File
@@ -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()))
}
+65
View File
@@ -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
)
+594
View File
@@ -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=
+79
View File
@@ -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()
}
}
+81
View File
@@ -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()
}
}
+34
View File
@@ -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()
}
}
+88
View File
@@ -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 生成请求IDbase36 紧凑字符串)
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),
)
}
}
+18
View File
@@ -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"`
}
+14
View File
@@ -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"`
}
+13
View File
@@ -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"`
}
+19
View File
@@ -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"`
}
+16
View File
@@ -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"`
}
+19
View File
@@ -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"
}
+23
View File
@@ -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"
}
+19
View File
@@ -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"
}
+22
View File
@@ -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"`
}
+16
View File
@@ -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"`
}
+10
View File
@@ -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"`
}
+12
View File
@@ -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"`
}
+21
View File
@@ -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"`
}
+107
View File
@@ -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 智能分类
}
}
}
+84
View File
@@ -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)
}
}
}
+28
View File
@@ -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"))
}
+28
View File
@@ -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"))
})
}
+24
View File
@@ -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)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

File diff suppressed because it is too large Load Diff
+301
View File
@@ -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%;
}
}
+153
View File
@@ -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;
}
+344
View File
@@ -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;
}
}
+174
View File
@@ -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;
}
+238
View File
@@ -0,0 +1,238 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - 页面走丢了</title>
<link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
overflow: hidden;
}
.container {
text-align: center;
padding: 40px 20px;
max-width: 600px;
width: 100%;
}
.error-code {
font-size: 120px;
font-weight: 800;
background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 50%, #8b5cf6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
margin-bottom: 20px;
position: relative;
display: inline-block;
}
.error-code::after {
content: '404';
position: absolute;
left: 4px;
top: 4px;
background: linear-gradient(135deg, rgba(79, 158, 222, 0.2) 0%, rgba(139, 92, 246, 0.2) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
z-index: -1;
}
.memo-icon {
font-size: 80px;
margin-bottom: 24px;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.title {
font-size: 28px;
font-weight: 600;
color: #1e293b;
margin-bottom: 12px;
}
.subtitle {
font-size: 16px;
color: #64748b;
margin-bottom: 32px;
line-height: 1.6;
}
.actions {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 12px 28px;
border-radius: 8px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%);
color: #fff;
border: none;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(79, 158, 222, 0.3);
}
.btn-secondary {
background: #fff;
color: #475569;
border: 1px solid #e2e8f0;
}
.btn-secondary:hover {
background: #f8fafc;
border-color: #cbd5e1;
}
.fun-fact {
margin-top: 40px;
padding: 16px 24px;
background: rgba(255, 255, 255, 0.7);
border-radius: 12px;
font-size: 14px;
color: #64748b;
backdrop-filter: blur(10px);
}
.fun-fact strong {
color: #4F9EDE;
}
/* 装饰元素 */
.decoration {
position: fixed;
pointer-events: none;
opacity: 0.1;
}
.decoration-1 {
top: 10%;
left: 10%;
font-size: 60px;
animation: spin 20s linear infinite;
}
.decoration-2 {
top: 20%;
right: 15%;
font-size: 40px;
animation: spin 15s linear infinite reverse;
}
.decoration-3 {
bottom: 20%;
left: 15%;
font-size: 50px;
animation: spin 25s linear infinite;
}
.decoration-4 {
bottom: 15%;
right: 10%;
font-size: 35px;
animation: spin 18s linear infinite reverse;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 移动端适配 */
@media (max-width: 768px) {
.error-code {
font-size: 80px;
}
.memo-icon {
font-size: 60px;
}
.title {
font-size: 22px;
}
.subtitle {
font-size: 14px;
}
.actions {
flex-direction: column;
align-items: center;
}
.btn {
width: 100%;
max-width: 280px;
justify-content: center;
}
}
</style>
</head>
<body>
<!-- 装饰元素 -->
<div class="decoration decoration-1">📝</div>
<div class="decoration decoration-2"></div>
<div class="decoration decoration-3">🎯</div>
<div class="decoration decoration-4">💡</div>
<div class="container">
<div class="memo-icon">📝</div>
<div class="error-code">404</div>
<h1 class="title">哎呀,页面走丢了!</h1>
<p class="subtitle">
这个页面可能被风吹走了,或者被外星人带走了...<br>
别担心,我们可以带你回到安全的地方!
</p>
<div class="actions">
<a href="/" class="btn btn-primary">
<svg style="width: 18px; height: 18px; fill: none; stroke: currentColor;" viewBox="0 0 24 24">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
返回首页
</a>
<a href="/web/calendar" class="btn btn-secondary">
<svg style="width: 18px; height: 18px; fill: none; stroke: currentColor;" viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
去日程
</a>
</div>
<div class="fun-fact">
💡 <strong>冷知识:</strong>404 错误代码源于 1990 年的 CERN,当时办公室在 4 楼 404 房间!
</div>
</div>
<script>
// 简单的鼠标跟随效果
document.addEventListener('mousemove', function(e) {
const decorations = document.querySelectorAll('.decoration');
const x = e.clientX / window.innerWidth;
const y = e.clientY / window.innerHeight;
decorations.forEach((dec, index) => {
const speed = (index + 1) * 10;
const xOffset = (x - 0.5) * speed;
const yOffset = (y - 0.5) * speed;
dec.style.transform = `translate(${xOffset}px, ${yOffset}px)`;
});
});
// 键盘快捷键
document.addEventListener('keydown', function(e) {
if (e.key === 'h' || e.key === 'H') {
window.location.href = '/';
}
});
</script>
</body>
</html>
+148
View File
@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>关于我们 - 简记memo</title>
<link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: #f8fafc; }
</style>
</head>
<body>
<div id="app">
<!-- 公共头部 -->
<div id="commonHeader"></div>
<!-- 页面内容 -->
<main style="padding: 84px 24px 40px; min-height: calc(100vh - 140px);">
<div style="max-width: 1000px; margin: 0 auto;">
<!-- 品牌介绍 -->
<section style="text-align: center; margin-bottom: 48px;">
<img id="brandLogo" src="/static/images/logo.png" alt="logo" style="width: 80px; height: 80px; border-radius: 20px; margin: 0 auto 20px; display: block;" onerror="this.style.display='none'; document.getElementById('fallbackLogo').style.display='flex';">
<div id="fallbackLogo" style="width: 80px; height: 80px; background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border-radius: 20px; display: none; align-items: center; justify-content: center; margin: 0 auto 20px;">
<span style="font-size: 32px;">📝</span>
</div>
<h1 style="font-size: 28px; font-weight: 700; color: #1e293b; margin-bottom: 12px;" id="brandName">简记memo</h1>
<p style="font-size: 15px; color: #64748b; max-width: 500px; margin: 0 auto;" id="brandDesc">集待办任务、账单记录、心情日记于一体的个人事务管理工具</p>
</section>
<!-- 关于我们 -->
<section style="background: #fff; border-radius: 16px; padding: 24px; margin-bottom: 20px;">
<h2 style="font-size: 18px; font-weight: 600; color: #1e293b; margin-bottom: 20px; display: flex; align-items: center;">
<span style="width: 4px; height: 18px; background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border-radius: 2px; margin-right: 10px;"></span>
关于我们
</h2>
<div style="line-height: 1.8;">
<p style="font-size: 14px; color: #475569; margin-bottom: 16px;">
简记memo是一款致力于帮助用户更好地管理个人事务的工具。我们相信,良好的记录习惯能够让生活更加有序,让每一天都变得更有意义。
</p>
<p style="font-size: 14px; color: #475569; margin-bottom: 16px;">
我们的愿景是打造一个简洁、优雅、实用的记录平台,让用户能够轻松记录待办任务、心情变化和收支情况,同时通过成长体系激励用户养成良好的习惯。
</p>
<p style="font-size: 14px; color: #475569;">
团队成员来自不同领域,我们怀着对产品的热爱和对用户体验的追求,不断优化和完善产品,希望能够为用户带来更好的使用体验。
</p>
</div>
</section>
<!-- 联系我们 -->
<section style="background: #fff; border-radius: 16px; padding: 24px; margin-bottom: 20px;">
<h2 style="font-size: 18px; font-weight: 600; color: #1e293b; margin-bottom: 20px; display: flex; align-items: center;">
<span style="width: 4px; height: 18px; background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border-radius: 2px; margin-right: 10px;"></span>
联系我们
</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px;">
<div style="display: flex; align-items: center; gap: 12px;">
<div style="width: 40px; height: 40px; background: rgba(79, 158, 222, 0.1); border-radius: 10px; display: flex; align-items: center; justify-content: center;">
<svg style="width: 20px; height: 20px; fill: none; stroke: #4F9EDE;" viewBox="0 0 24 24">
<path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<div>
<div style="font-size: 14px; color: #64748b;">邮箱</div>
<div style="font-size: 15px; color: #1e293b;" id="contactEmail">support@jianji-memo.com</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<div style="width: 40px; height: 40px; background: rgba(34, 197, 94, 0.1); border-radius: 10px; display: flex; align-items: center; justify-content: center;">
<svg style="width: 20px; height: 20px; fill: none; stroke: #22c55e;" viewBox="0 0 24 24">
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.837.403c4.801 0 8.692-3.287 8.692-7.342 0-4.054-3.891-7.34-8.692-7.34z"/>
</svg>
</div>
<div>
<div style="font-size: 14px; color: #64748b;">微信公众号</div>
<div style="font-size: 15px; color: #1e293b;" id="contactWechat">简记memo</div>
</div>
</div>
</div>
</section>
<!-- 团队介绍 -->
<section style="background: #fff; border-radius: 16px; padding: 24px;">
<h2 style="font-size: 18px; font-weight: 600; color: #1e293b; margin-bottom: 20px; display: flex; align-items: center;">
<span style="width: 4px; height: 18px; background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border-radius: 2px; margin-right: 10px;"></span>
团队成员
</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 20px;">
<div style="text-align: center;">
<div style="width: 64px; height: 64px; background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 12px;">
<span style="font-size: 24px;">👨‍💻</span>
</div>
<div style="font-size: 14px; font-weight: 600; color: #1e293b;">产品经理</div>
<div style="font-size: 12px; color: #64748b;">负责产品规划和设计</div>
</div>
<div style="text-align: center;">
<div style="width: 64px; height: 64px; background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 12px;">
<span style="font-size: 24px;">👩‍💻</span>
</div>
<div style="font-size: 14px; font-weight: 600; color: #1e293b;">前端开发</div>
<div style="font-size: 12px; color: #64748b;">负责界面开发</div>
</div>
<div style="text-align: center;">
<div style="width: 64px; height: 64px; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 12px;">
<span style="font-size: 24px;">👨‍🔧</span>
</div>
<div style="font-size: 14px; font-weight: 600; color: #1e293b;">后端开发</div>
<div style="font-size: 12px; color: #64748b;">负责服务端开发</div>
</div>
<div style="text-align: center;">
<div style="width: 64px; height: 64px; background: linear-gradient(135deg, #ec4899 0%, #db2777 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 12px;">
<span style="font-size: 24px;">🎨</span>
</div>
<div style="font-size: 14px; font-weight: 600; color: #1e293b;">UI设计师</div>
<div style="font-size: 12px; color: #64748b;">负责界面设计</div>
</div>
</div>
</section>
</div>
</main>
<!-- 公共底部 -->
<div id="commonFooter"></div>
</div>
<script src="https://unpkg.com/vue@2.6.14/dist/vue.min.js"></script>
<script src="https://unpkg.com/element-ui@2.15.14/lib/index.js"></script>
<script src="/static/js/config.js"></script>
<script src="/static/js/common-components.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('commonHeader').innerHTML = renderCommonHeader('/about');
document.getElementById('commonFooter').innerHTML = renderCommonFooter();
initCommonComponents();
if (typeof SITE_CONFIG !== 'undefined') {
document.getElementById('brandName').textContent = SITE_CONFIG.name || '简记memo';
document.getElementById('brandDesc').textContent = SITE_CONFIG.description || '集待办任务、账单记录、心情日记于一体的个人事务管理工具';
document.getElementById('contactEmail').textContent = SITE_CONFIG.email || 'support@jianji-memo.com';
if (SITE_CONFIG.wechat) {
document.getElementById('contactWechat').textContent = SITE_CONFIG.wechat.name || '简记memo';
}
}
});
</script>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>用户协议 - 简记memo</title>
<link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: #f1f5f9; }
</style>
</head>
<body>
<div id="app">
<!-- 公共头部 -->
<div id="commonHeader"></div>
<!-- 页面内容 -->
<main style="padding: 84px 24px 40px; min-height: calc(100vh - 140px);">
<div style="max-width: 1000px; margin: 0 auto;">
<div style="background: #fff; border-radius: 16px; padding: 40px;">
<h1 style="font-size: 28px; font-weight: 700; color: #1e293b; text-align: center; margin-bottom: 12px;">简记memo用户协议</h1>
<p style="font-size: 14px; color: #64748b; text-align: center; margin-bottom: 32px; padding-bottom: 20px; border-bottom: 1px dashed rgba(79, 158, 222, 0.2);">生效日期:2025年04月24日</p>
<div style="margin-bottom: 28px;">
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">1. 协议主体与范围</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">1.1 本协议是您(以下简称"用户")与简记memo服务(以下简称"本服务")个人运营者(以下简称"运营者")之间关于使用本服务所订立的契约,运营者为个人独立开发者,无所属企业/公司主体。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">1.2 您通过网站访问、使用本服务的行为,即视为您已阅读、理解并同意本协议全部条款及《隐私政策》;若您不同意本协议或《隐私政策》,应立即停止使用本服务。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">1.3 本协议适用于您使用本服务的所有行为,包括但不限于账号注册登录、数据录入、存储、编辑等核心功能。</p>
</div>
<div style="margin-bottom: 28px;">
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">2. 服务内容与范围</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">2.1 本服务为用户提供免费的备忘录、待办事项、账单记录服务,核心功能包括文字内容录入、服务器存储、编辑、删除、数据同步等,所有功能仅基于用户身份识别提供。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">2.2 运营者有权根据技术发展、用户需求、法律法规调整等因素,不定期优化、调整或暂停部分/全部服务,重大调整将通过服务内公告提前7日告知用户。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">2.3 本服务为免费服务,运营者不承诺提供商业级别的服务稳定性,仅保障基础功能的正常可用。</p>
</div>
<div style="margin-bottom: 28px;">
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">3. 用户权利与义务</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">3.1 权利:您有权按照本协议约定使用本服务核心功能;有权查看、编辑、删除自身存储在服务器的所有数据;有权向运营者反馈功能问题或优化建议;在遵守本协议的前提下,运营者保障您正常使用本服务的基础权益。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">3.2 义务:您保证使用本服务的行为符合《中华人民共和国网络安全法》《互联网信息服务管理办法》等法律法规及公序良俗;不得利用本服务存储、传播违法、违规、侵权、色情、暴力等内容;应对自身录入的内容及使用行为承担全部法律责任;不得对本服务进行反向工程、篡改、破解、爬虫抓取等损害服务正常运行的操作。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">3.3 您理解并同意,用户身份信息仅用于身份识别,运营者不会通过该信息获取您的其他账号信息。</p>
</div>
<div style="margin-bottom: 28px;">
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">4. 服务的暂停与终止</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">4.1 运营者有权在以下情形暂停/终止服务:(1)您违反本协议约定且未在通知期限内纠正;(2)法律法规、相关平台规则要求暂停/终止;(3)不可抗力(如服务器故障、网络中断);(4)运营者自主停止运营(提前30日在服务内公告)。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">4.2 服务暂停期间,运营者将尽力恢复服务,但不承诺恢复时限;服务终止后,您存储在服务器的所有数据将保留7日,逾期将永久删除,运营者不承担数据找回、备份或赔偿责任。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">4.3 您可主动申请终止服务(联系运营者删除账号及所有数据),申请生效后,运营者将在15个工作日内完成数据清理。</p>
</div>
<div style="margin-bottom: 28px;">
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">5. 免责声明</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">5.1 本服务仅提供基础数据存储与管理功能,运营者不对您存储内容的完整性、准确性、安全性做绝对担保;因网络故障、服务器维护、平台规则调整、您的设备问题等非运营方可控因素导致的数据丢失、功能异常,运营者不承担赔偿责任。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">5.2 本服务为个人开发的免费服务,运营者无义务提供7×24小时技术支持,仅根据实际情况在合理期限内解答用户咨询(通过预留邮箱/公众号)。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">5.3 因您违反法律法规或本协议约定,导致第三方索赔、行政机关处罚的,由您自行承担全部责任,运营者不承担连带责任。</p>
</div>
<div style="margin-bottom: 28px;">
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">6. 协议的变更</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">6.1 运营者可根据法律法规调整、业务优化需要修改本协议,修改后的协议将在服务内公示,公示满7日后自动生效。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">6.2 若您不同意修改后的协议,应立即停止使用本服务并可申请删除账号数据;继续使用本服务的,视为您同意修改后的协议。</p>
</div>
<div style="margin-bottom: 28px;">
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">7. 法律适用与争议解决</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">7.1 本协议适用中华人民共和国法律(不含港澳台地区法律)。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">7.2 因本协议产生的争议,双方应友好协商解决;协商不成的,任何一方可向运营者所在地有管辖权的人民法院提起诉讼。</p>
</div>
<div>
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">8. 联系与其他</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;" id="agreementContact">8.1 若您对本协议有任何疑问,可通过以下方式联系运营者:联系邮箱:memo@miaoall.cn;微信公众号:孙三苗(sunsanmiao)。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">8.2 本协议的条款如被有权机关认定为无效或不可执行,不影响其余条款的效力。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">8.3 本协议与《隐私政策》共同构成您与运营者之间的完整约定,两者冲突时,以《隐私政策》中关于个人信息保护的内容为准。</p>
</div>
</div>
</div>
</main>
<!-- 公共底部 -->
<div id="commonFooter"></div>
</div>
<script src="https://unpkg.com/vue@2.6.14/dist/vue.min.js"></script>
<script src="https://unpkg.com/element-ui@2.15.14/lib/index.js"></script>
<script src="/static/js/config.js"></script>
<script src="/static/js/common-components.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('commonHeader').innerHTML = renderCommonHeader('/agreement');
document.getElementById('commonFooter').innerHTML = renderCommonFooter();
initCommonComponents();
if (typeof SITE_CONFIG !== 'undefined') {
const email = SITE_CONFIG.email || 'memo@miaoall.cn';
const wechatName = SITE_CONFIG.wechat?.name || '孙三苗';
const wechatAccount = SITE_CONFIG.wechat?.account || 'sunsanmiao';
document.getElementById('agreementContact').textContent = `8.1 若您对本协议有任何疑问,可通过以下方式联系运营者:联系邮箱:${email};微信公众号:${wechatName}${wechatAccount})。`;
}
});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+429
View File
@@ -0,0 +1,429 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>成长规则 - 简记memo</title>
<link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: #f8fafc; }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
@keyframes glow {
0%, 100% { box-shadow: 0 0 5px currentColor; }
50% { box-shadow: 0 0 20px currentColor; }
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
.animate-fadeInUp { animation: fadeInUp 0.6s ease forwards; }
.animate-pulse { animation: pulse 2s ease-in-out infinite; }
.animate-glow { animation: glow 2s ease-in-out infinite; }
.animate-slideIn { animation: slideIn 0.4s ease forwards; }
.delay-1 { animation-delay: 0.1s; }
.delay-2 { animation-delay: 0.2s; }
.delay-3 { animation-delay: 0.3s; }
.delay-4 { animation-delay: 0.4s; }
.delay-5 { animation-delay: 0.5s; }
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0,0,0,0.08);
}
.rarity-primary { color: #9CA3AF; }
.rarity-secondary { color: #10B981; }
.rarity-advanced { color: #3B82F6; }
.rarity-rare { color: #8B5CF6; }
.rarity-epic { color: #F59E0B; }
.rarity-legendary { color: #EF4444; }
.bg-rarity-primary { background: rgba(156, 163, 175, 0.1); }
.bg-rarity-secondary { background: rgba(16, 185, 129, 0.1); }
.bg-rarity-advanced { background: rgba(59, 130, 246, 0.1); }
.bg-rarity-rare { background: rgba(139, 92, 246, 0.1); }
.bg-rarity-epic { background: rgba(245, 158, 11, 0.1); }
.bg-rarity-legendary { background: rgba(239, 68, 68, 0.1); }
</style>
</head>
<body>
<div id="app">
<div id="commonHeader"></div>
<main style="padding: 84px 24px 40px; min-height: calc(100vh - 140px);">
<div style="max-width: 1000px; margin: 0 auto;">
<div style="text-align: center; margin-bottom: 48px;" class="animate-fadeInUp">
<div style="width: 80px; height: 80px; background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border-radius: 20px; display: flex; align-items: center; justify-content: center; margin: 0 auto 20px; box-shadow: 0 8px 24px rgba(79, 158, 222, 0.3);">
<span style="font-size: 40px;">📊</span>
</div>
<h1 style="font-size: 32px; font-weight: 700; color: #1e293b; margin-bottom: 12px;">成长规则</h1>
<p style="font-size: 16px; color: #64748b; max-width: 500px; margin: 0 auto;">了解如何获取经验,提升等级,解锁成就与特权</p>
</div>
<section id="levelSystem" class="animate-fadeInUp delay-1" style="background: #fff; border-radius: 8px; padding: 28px; margin-bottom: 20px;">
<div style="display: flex; align-items: center; margin-bottom: 20px;">
<div style="width: 40px; height: 40px; background: rgba(79, 158, 222, 0.1); border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-right: 12px;">
<span style="font-size: 20px;">📈</span>
</div>
<div>
<h2 style="font-size: 18px; font-weight: 600; color: #1e293b;">等级体系</h2>
<p style="font-size: 13px; color: #64748b;">从Lv.1到Lv.10,等级越高,称号越拉风</p>
</div>
</div>
<p style="font-size: 14px; color: #475569; line-height: 1.6; margin-bottom: 24px;">
成长等级从Lv.1到Lv.10,通过累计经验值提升等级。<strong style="color: #4F9EDE;">所有核心功能完全免费开放</strong>,不受等级限制!等级特权只是额外的荣誉展示和增值服务。
</p>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px;">
<div v-for="(level, index) in levels" :key="level.level"
class="card-hover"
style="padding: 16px; background: #f8fafc; border-radius: 8px; transition: all 0.3s; cursor: pointer; position: relative; overflow: hidden;"
:style="{ animationDelay: `${0.1 * index}s` }">
<div style="position: absolute; top: 0; right: 0; width: 60px; height: 60px; opacity: 0.1;">{{ level.icon }}</div>
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
<div style="width: 32px; height: 32px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700; color: #fff;"
:style="{ background: `linear-gradient(135deg, ${level.color} 0%, ${adjustColor(level.color, -20)} 100%)` }">
Lv.{{ level.level }}
</div>
<span style="font-size: 14px; font-weight: 600; color: #1e293b;">{{ level.title }}</span>
</div>
<div style="font-size: 12px; color: #64748b; margin-bottom: 8px;">需要 {{ level.exp.toLocaleString() }} EXP</div>
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
<span v-for="privilege in level.privileges" :key="privilege"
style="padding: 3px 8px; background: rgba(79, 158, 222, 0.08); border-radius: 4px; font-size: 11px; color: #4F9EDE;">
{{ privilege }}
</span>
</div>
</div>
</div>
</section>
<section id="expRules" class="animate-fadeInUp delay-2" style="background: #fff; border-radius: 8px; padding: 28px; margin-bottom: 20px;">
<div style="display: flex; align-items: center; margin-bottom: 20px;">
<div style="width: 40px; height: 40px; background: rgba(245, 158, 11, 0.1); border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-right: 12px;">
<span style="font-size: 20px;"></span>
</div>
<div>
<h2 style="font-size: 18px; font-weight: 600; color: #1e293b;">经验获取</h2>
<p style="font-size: 13px; color: #64748b;">完成任务、记账、记录心情都能获得经验</p>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; margin-bottom: 20px;">
<div v-for="(rule, index) in expRules" :key="rule.name"
class="card-hover"
style="padding: 20px; background: #f8fafc; border-radius: 8px; transition: all 0.3s; animation: fadeInUp 0.5s ease forwards;"
:style="{ animationDelay: `${0.15 * index}s` }">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<div style="width: 44px; height: 44px; background: rgba(79, 158, 222, 0.1); border-radius: 10px; display: flex; align-items: center; justify-content: center;">
<span style="font-size: 22px;">{{ rule.icon }}</span>
</div>
<div>
<div style="font-size: 15px; font-weight: 600; color: #1e293b;">{{ rule.name }}</div>
<div style="font-size: 12px; color: #64748b;">{{ rule.desc }}</div>
</div>
</div>
<div style="padding: 10px 12px; background: rgba(79, 158, 222, 0.05); border-radius: 6px; margin-bottom: 8px;">
<div style="font-size: 14px; font-weight: 600; color: #4F9EDE;">{{ rule.exp }}</div>
</div>
<p style="font-size: 12px; color: #64748b; line-height: 1.5;">{{ rule.detail }}</p>
</div>
</div>
<div style="padding: 16px; background: linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(245, 158, 11, 0.05) 100%); border-radius: 8px;">
<div style="display: flex; align-items: center; gap: 10px;">
<svg style="width: 20px; height: 20px; fill: #f59e0b;" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
<span style="font-size: 14px; font-weight: 500; color: #d97706;">每日经验上限:120 EXP</span>
</div>
</div>
</section>
<section id="streakBonus" class="animate-fadeInUp delay-3" style="background: #fff; border-radius: 8px; padding: 28px; margin-bottom: 20px;">
<div style="display: flex; align-items: center; margin-bottom: 20px;">
<div style="width: 40px; height: 40px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-right: 12px;">
<span style="font-size: 20px;">🔥</span>
</div>
<div>
<h2 style="font-size: 18px; font-weight: 600; color: #1e293b;">连续打卡加成</h2>
<p style="font-size: 13px; color: #64748b;">连续记录天数越多,加成越高</p>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 20px;">
<div v-for="(rule, index) in streakBonusRules" :key="rule.range"
style="padding: 16px; background: #f8fafc; border-radius: 8px; text-align: center; animation: fadeInUp 0.5s ease forwards;"
:style="{ animationDelay: `${0.1 * index}s` }">
<div style="font-size: 16px; font-weight: 700; margin-bottom: 4px;" :style="{ color: rule.color }">
{{ rule.bonus }} EXP/天
</div>
<div style="font-size: 12px; color: #64748b;">{{ rule.range }}</div>
</div>
</div>
<div style="padding: 16px; background: #f0fdf4; border-radius: 8px; border-left: 4px solid #22c55e;">
<p style="font-size: 13px; color: #166534; line-height: 1.5;">
<strong>💡 温馨提示:</strong>断签后连续天数不会完全清零,而是根据断签前的连续天数保留一部分:1-7天清零,8-30天保留3天,31-90天保留7天,91天以上保留15天。
</p>
</div>
</section>
<section id="protection" class="animate-fadeInUp delay-4" style="background: #fff; border-radius: 8px; padding: 28px; margin-bottom: 20px;">
<div style="display: flex; align-items: center; margin-bottom: 20px;">
<div style="width: 40px; height: 40px; background: rgba(59, 130, 246, 0.1); border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-right: 12px;">
<span style="font-size: 20px;">🛡️</span>
</div>
<div>
<h2 style="font-size: 18px; font-weight: 600; color: #1e293b;">新用户保护期</h2>
<p style="font-size: 13px; color: #64748b;">帮助新用户快速成长</p>
</div>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 16px; align-items: center;">
<div style="flex: 1; min-width: 200px; padding: 20px; background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(59, 130, 246, 0.05) 100%); border-radius: 8px;">
<div style="font-size: 32px; font-weight: 700; color: #3B82F6; margin-bottom: 4px;">50%</div>
<div style="font-size: 13px; color: #475569;">保护期内额外经验加成</div>
</div>
<div style="flex: 2; min-width: 280px;">
<p style="font-size: 14px; color: #475569; line-height: 1.6;">
新用户前7天享受保护期加成!保护期内每次获取经验都会额外获得50%的经验加成,帮助你快速成长。
</p>
<p style="font-size: 13px; color: #64748b; margin-top: 8px;">
保护期结束后,连续天数中断时,只会减少部分天数,不会完全重置。
</p>
</div>
</div>
</section>
<section id="achievements" class="animate-fadeInUp delay-5" style="background: #fff; border-radius: 8px; padding: 28px; margin-bottom: 20px;">
<div style="display: flex; align-items: center; margin-bottom: 20px;">
<div style="width: 40px; height: 40px; background: rgba(139, 92, 246, 0.1); border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-right: 12px;">
<span style="font-size: 20px;">🎖️</span>
</div>
<div>
<h2 style="font-size: 18px; font-weight: 600; color: #1e293b;">成就系统</h2>
<p style="font-size: 13px; color: #64748b;">完成特定目标可解锁成就,获得额外经验奖励</p>
</div>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap;">
<button v-for="cat in achievementCategories" :key="cat.key"
@click="activeCategory = cat.key"
style="padding: 8px 16px; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s; border: none;"
:style="activeCategory === cat.key
? 'background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); color: #fff;'
: 'background: #f1f5f9; color: #64748b;'">
<span>{{ cat.icon }}</span> {{ cat.name }}
</button>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap;">
<div v-for="(rarity, key) in rarityConfigs" :key="key"
style="display: flex; align-items: center; gap: 6px;">
<div style="width: 12px; height: 12px; border-radius: 4px;" :style="{ background: rarity.color }"></div>
<span style="font-size: 12px; color: #64748b;">{{ rarity.name }}</span>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px;">
<div v-for="(achievement, index) in filteredAchievements" :key="achievement.id"
class="card-hover"
style="padding: 16px; border-radius: 8px; transition: all 0.3s; animation: fadeInUp 0.5s ease forwards;"
:class="`bg-rarity-${achievement.rarity}`"
:style="{ animationDelay: `${0.05 * index}s` }">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
<div style="width: 40px; height: 40px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 18px; background: rgba(255,255,255,0.8);">
{{ achievement.icon || '🏆' }}
</div>
<div :class="`rarity-${achievement.rarity}`" style="font-size: 11px; font-weight: 500;">
{{ achievement.rarityName || achievement.rarity }}
</div>
</div>
<h4 style="font-size: 14px; font-weight: 600; color: #1e293b; margin-bottom: 4px;">{{ achievement.name }}</h4>
<p style="font-size: 12px; color: #64748b; margin-bottom: 8px; line-height: 1.4;">{{ achievement.description || achievement.desc || '' }}</p>
<div style="display: flex; align-items: center; justify-content: space-between;">
<span style="font-size: 13px; font-weight: 600; color: #4F9EDE;">+{{ achievement.expReward || achievement.exp || 0 }} EXP</span>
<span v-if="achievement.target" style="font-size: 11px; color: #94a3b8;">目标: {{ achievement.target }}</span>
</div>
</div>
</div>
</section>
<section id="tips" class="animate-fadeInUp delay-1" style="background: linear-gradient(135deg, rgba(79, 158, 222, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%); border-radius: 8px; padding: 28px; margin-bottom: 20px;">
<div style="display: flex; align-items: center; margin-bottom: 20px;">
<div style="width: 40px; height: 40px; background: rgba(255,255,255,0.8); border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-right: 12px;">
<span style="font-size: 20px;">💡</span>
</div>
<div>
<h2 style="font-size: 18px; font-weight: 600; color: #1e293b;">快速升级秘籍</h2>
<p style="font-size: 13px; color: #64748b;">掌握这些技巧,升级更快</p>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;">
<div v-for="(tip, index) in growthTips" :key="index"
style="display: flex; align-items: flex-start; gap: 10px; padding: 14px; background: rgba(255,255,255,0.6); border-radius: 8px; animation: fadeInUp 0.5s ease forwards;"
:style="{ animationDelay: `${0.1 * index}s` }">
<div style="width: 24px; height: 24px; background: rgba(79, 158, 222, 0.1); border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: #4F9EDE; flex-shrink: 0;">
{{ index + 1 }}
</div>
<p style="font-size: 13px; color: #475569; line-height: 1.5;">{{ tip }}</p>
</div>
</div>
</section>
<section id="faq" class="animate-fadeInUp delay-2" style="background: #fff; border-radius: 8px; padding: 28px;">
<div style="display: flex; align-items: center; margin-bottom: 20px;">
<div style="width: 40px; height: 40px; background: rgba(100, 116, 139, 0.1); border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-right: 12px;">
<span style="font-size: 20px;"></span>
</div>
<div>
<h2 style="font-size: 18px; font-weight: 600; color: #1e293b;">常见问题</h2>
<p style="font-size: 13px; color: #64748b;">解答你的疑惑</p>
</div>
</div>
<div class="faq-list">
<div v-for="(item, index) in faqList" :key="index"
class="faq-item"
style="border-bottom: 1px solid #f1f5f9; padding: 16px 0; animation: fadeInUp 0.5s ease forwards;"
:style="{ animationDelay: `${0.1 * index}s` }">
<div style="display: flex; align-items: center; gap: 10px; cursor: pointer;" @click="toggleFaq(index)">
<div style="width: 24px; height: 24px; background: rgba(79, 158, 222, 0.1); border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: #4F9EDE; flex-shrink: 0;">
Q
</div>
<span style="font-size: 14px; font-weight: 500; color: #1e293b;">{{ item.Q }}</span>
<svg style="width: 16px; height: 16px; fill: none; stroke: #94a3b8; margin-left: auto; transition: transform 0.2s;"
:style="{ transform: faqOpen === index ? 'rotate(180deg)' : 'rotate(0)' }" viewBox="0 0 24 24">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
<div v-show="faqOpen === index"
style="margin-top: 12px; padding-left: 34px; animation: slideIn 0.3s ease;">
<p style="font-size: 14px; color: #64748b; line-height: 1.6;">{{ item.A }}</p>
</div>
</div>
</div>
</section>
</div>
</main>
<div id="commonFooter"></div>
</div>
<script src="https://unpkg.com/vue@2.6.14/dist/vue.min.js"></script>
<script src="https://unpkg.com/element-ui@2.15.14/lib/index.js"></script>
<script src="/static/js/config.js"></script>
<script src="/static/js/toast.js"></script>
<script src="/static/js/auth.js"></script>
<script src="/static/js/common-components.js"></script>
<script>
var app = new Vue({
el: '#app',
data: {
levels: [],
expRules: [],
streakBonusRules: [],
achievementCategories: [],
achievements: [],
rarityConfigs: {},
growthTips: [],
faqList: [],
activeCategory: "all",
faqOpen: -1,
loading: true
},
computed: {
filteredAchievements: function() {
if (this.activeCategory === "all") {
return this.achievements;
}
return this.achievements.filter(item => item.category === this.activeCategory);
}
},
methods: {
adjustColor: function(hex, amount) {
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.min(255, Math.max(0, (num >> 16) + amount));
const g = Math.min(255, Math.max(0, ((num >> 8) & 0x00FF) + amount));
const b = Math.min(255, Math.max(0, (num & 0x0000FF) + amount));
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
},
toggleFaq: function(index) {
this.faqOpen = this.faqOpen === index ? -1 : index;
},
loadRulesFromAPI: function() {
fetch('/api/web/growth/rules')
.then(res => res.json())
.then(data => {
if (data.code === 200) {
const sections = data.data.sections;
sections.forEach(section => {
switch (section.type) {
case 'level_system':
this.levels = section.levels || [];
break;
case 'exp_rules':
this.expRules = section.rules || [];
break;
case 'streak_bonus':
this.streakBonusRules = section.rules || [];
break;
case 'achievement_system':
this.achievementCategories = [{ key: "all", name: "全部", icon: "📦" }].concat(section.categories || []);
this.achievements = section.achievements || [];
// 构建 rarityConfigs - 使用固定的稀有度配置
this.rarityConfigs = {
"初级": { name: "初级", color: "#9CA3AF" },
"中级": { name: "中级", color: "#22C55E" },
"高级": { name: "高级", color: "#3B82F6" },
"稀有": { name: "稀有", color: "#8B5CF6" },
"史诗": { name: "史诗", color: "#F59E0B" },
"传说": { name: "传说", color: "#EF4444" }
};
break;
case 'tips':
this.growthTips = section.tips || [];
break;
case 'faq':
this.faqList = section.questions || [];
break;
}
});
this.loading = false;
}
})
.catch(err => {
console.error('加载规则失败:', err);
this.loading = false;
});
}
},
mounted: function() {
// 使用公共头部底部
document.getElementById('commonHeader').innerHTML = renderCommonHeader('/growth-rules');
document.getElementById('commonFooter').innerHTML = renderCommonFooter();
initCommonComponents();
// 从接口加载数据
this.loadRulesFromAPI();
}
});
</script>
</body>
</html>
+257
View File
@@ -0,0 +1,257 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>成长中心 - 简记memo</title>
<link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: #f8fafc; }
.nav-link.active { background: rgba(79, 158, 222, 0.1); color: #4F9EDE; }
.achievement-card { transition: all 0.2s; }
.achievement-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.level-item { transition: all 0.15s; }
.level-item:hover { background: #f1f5f9; }
</style>
</head>
<body>
<div id="app">
<!-- 公共头部 -->
<div id="webHeader"></div>
<!-- 页面内容 -->
<div style="margin-top: 56px; min-height: calc(100vh - 56px - 40px); padding: 24px;">
<div style="max-width: 1200px; margin: 0 auto;">
<!-- 左右布局 -->
<div style="display: grid; grid-template-columns: 360px 1fr; gap: 24px;">
<!-- 左侧:等级信息 -->
<div style="display: flex; flex-direction: column; gap: 16px;">
<!-- 等级卡片 -->
<div style="background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border-radius: 12px; padding: 32px 24px; color: #fff; text-align: center;">
<div style="width: 100px; height: 100px; background: rgba(255,255,255,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px; position: relative;">
<div style="font-size: 36px; font-weight: 700;" id="userLevel">Lv.1</div>
<div style="position: absolute; bottom: -6px; right: -6px; width: 32px; height: 32px; background: #fbbf24; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 14px;"></div>
</div>
<h2 style="font-size: 20px; font-weight: 600; margin-bottom: 4px;">成长等级</h2>
<p style="font-size: 13px; opacity: 0.9; margin-bottom: 20px;" id="expInfo">0 / 100 经验</p>
<div style="background: rgba(255,255,255,0.2); border-radius: 4px; height: 8px; overflow: hidden;">
<div id="expBar" style="height: 100%; background: #fff; border-radius: 4px; transition: width 0.5s; width: 0%;"></div>
</div>
<p style="font-size: 12px; opacity: 0.85; margin-top: 8px;" id="expRemain">还需 100 经验升级</p>
</div>
<!-- 统计数据 -->
<div style="background: #fff; border-radius: 12px; padding: 20px;">
<h3 style="font-size: 14px; font-weight: 600; color: #1e293b; margin-bottom: 16px;">数据统计</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<div style="text-align: center; padding: 16px; background: #f8fafc; border-radius: 8px;">
<div style="font-size: 24px; font-weight: 700; color: #4F9EDE;" id="statTotalExp">0</div>
<div style="font-size: 12px; color: #64748b; margin-top: 4px;">累计经验</div>
</div>
<div style="text-align: center; padding: 16px; background: #f8fafc; border-radius: 8px;">
<div style="font-size: 24px; font-weight: 700; color: #f59e0b;" id="statAchievements">0</div>
<div style="font-size: 12px; color: #64748b; margin-top: 4px;">获得成就</div>
</div>
<div style="text-align: center; padding: 16px; background: #f8fafc; border-radius: 8px;">
<div style="font-size: 24px; font-weight: 700; color: #22c55e;" id="statDays">0</div>
<div style="font-size: 12px; color: #64748b; margin-top: 4px;">连续记录</div>
</div>
<div style="text-align: center; padding: 16px; background: #f8fafc; border-radius: 8px;">
<div style="font-size: 24px; font-weight: 700; color: #8b5cf6;" id="statTasks">0</div>
<div style="font-size: 12px; color: #64748b; margin-top: 4px;">完成任务</div>
</div>
</div>
</div>
<!-- 等级列表 -->
<div style="background: #fff; border-radius: 12px; padding: 20px; flex: 1;">
<h3 style="font-size: 14px; font-weight: 600; color: #1e293b; margin-bottom: 16px;">等级列表</h3>
<div id="levelList" style="display: flex; flex-direction: column; gap: 8px; max-height: 400px; overflow-y: auto;">
<!-- 动态生成 -->
</div>
</div>
</div>
<!-- 右侧:成就展示 -->
<div style="background: #fff; border-radius: 12px; padding: 24px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px;">
<h3 style="font-size: 18px; font-weight: 600; color: #1e293b;">我的成就</h3>
<div style="display: flex; align-items: center; gap: 12px;">
<select id="achievementFilter" onchange="filterAchievements()" style="padding: 6px 12px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 13px; color: #475569; background: #fff; cursor: pointer;">
<option value="all">全部</option>
<option value="unlocked">已获取</option>
<option value="locked">未获取</option>
</select>
<span style="font-size: 13px; color: #4F9EDE;" id="achievementProgress">0 / 8</span>
</div>
</div>
<div id="achievementsGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px;">
<!-- 动态渲染 -->
</div>
</div>
</div>
</div>
</div>
<!-- 公共底部 -->
<div id="webFooter"></div>
</div>
<script src="https://unpkg.com/vue@2.6.14/dist/vue.min.js"></script>
<script src="https://unpkg.com/element-ui@2.15.14/lib/index.js"></script>
<script src="/static/js/config.js"></script>
<script src="/static/js/toast.js"></script>
<script src="/static/js/auth.js"></script>
<script src="/static/js/web-components.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 初始化公共头部底部
initWebPage('growth');
// 加载成长信息
loadGrowthInfo();
});
function loadGrowthInfo() {
const token = localStorage.getItem('user_token');
if (!token) return;
fetch('/api/web/growth/info', {
headers: { 'token': token }
})
.then(res => res.json())
.then(data => {
if (data.code === 200) {
const g = data.data;
// 等级信息(使用后端返回数据)
document.getElementById('userLevel').textContent = 'Lv.' + g.level;
document.getElementById('expInfo').textContent = g.levelProgress + '%';
document.getElementById('expRemain').textContent = '还需 ' + g.nextLevelExp + ' 经验升级';
document.getElementById('expBar').style.width = Math.min(g.levelProgress, 100) + '%';
// 统计数据
document.getElementById('statTotalExp').textContent = g.experience || 0;
document.getElementById('statAchievements').textContent = g.achievements?.unlocked || 0;
document.getElementById('statDays').textContent = g.streakDays || 0;
document.getElementById('statTasks').textContent = g.todayProgress?.tasksCompleted || 0;
// 成就进度
const totalAch = g.achievements?.total || 0;
const unlockedAch = g.achievements?.unlocked || 0;
document.getElementById('achievementProgress').textContent = unlockedAch + ' / ' + totalAch;
// 渲染成就列表
renderAchievements(g.achievements?.list || [], totalAch);
// 渲染等级列表(使用后端数据)
renderLevelListFromAPI(g.level);
}
})
.catch(err => console.error('加载成长信息失败:', err));
}
// 渲染成就列表
// 存储成就数据用于过滤
let allAchievementsData = [];
let unlockedAchievementIds = [];
function renderAchievements(unlockedList, total) {
const container = document.getElementById('achievementsGrid');
const token = localStorage.getItem('user_token');
// 先获取所有成就定义
fetch('/api/web/growth/rules', { headers: { 'token': token } })
.then(res => res.json())
.then(data => {
if (data.code === 200) {
const sections = data.data.sections;
const achSection = sections.find(s => s.type === 'achievement_system');
if (achSection && achSection.achievements) {
allAchievementsData = achSection.achievements;
unlockedAchievementIds = unlockedList.map(a => a.id || a.achievement_id);
renderAchievementList('all');
}
}
});
}
function filterAchievements() {
const filter = document.getElementById('achievementFilter').value;
renderAchievementList(filter);
}
function renderAchievementList(filter) {
const container = document.getElementById('achievementsGrid');
let html = '';
let filteredAchievements = allAchievementsData;
if (filter === 'unlocked') {
filteredAchievements = allAchievementsData.filter(ach => unlockedAchievementIds.includes(ach.id));
} else if (filter === 'locked') {
filteredAchievements = allAchievementsData.filter(ach => !unlockedAchievementIds.includes(ach.id));
}
filteredAchievements.forEach(ach => {
const isUnlocked = unlockedAchievementIds.includes(ach.id);
const color = ach.rarityColor || '#9CA3AF';
const icon = ach.icon || '🏆';
const name = ach.name || '未知成就';
const desc = ach.description || ach.desc || '';
const rarityName = ach.rarityName || '';
html += `
<div class="achievement-card" style="text-align: center; padding: 20px; border-radius: 12px; background: ${isUnlocked ? 'linear-gradient(135deg, rgba(79,158,222,0.08) 0%, rgba(79,158,222,0.02) 100%)' : '#f8fafc'}; ${isUnlocked ? '' : 'opacity: 0.6;'}">
<div style="width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; margin: 0 auto 12px; background: ${isUnlocked ? color : '#e2e8f0'}20;">
<span style="font-size: 24px;">${isUnlocked ? icon : '🔒'}</span>
</div>
<h4 style="font-size: 14px; font-weight: 600; color: ${isUnlocked ? color : '#1e293b'}; margin-bottom: 4px;">${name}</h4>
${desc ? '<p style="font-size: 12px; color: #64748b; margin-bottom: 4px;">' + desc + '</p>' : ''}
${rarityName ? '<span style="display: inline-block; padding: 2px 8px; background: ' + color + '15; color: ' + color + '; font-size: 11px; border-radius: 4px;">' + rarityName + '</span>' : ''}
${isUnlocked ? '<span style="display: inline-block; margin-top: 6px; padding: 2px 8px; background: #22c55e15; color: #22c55e; font-size: 11px; border-radius: 4px;">✓ 已解锁</span>' : ''}
</div>
`;
});
if (filteredAchievements.length === 0) {
html = '<div style="text-align: center; padding: 40px; color: #94a3b8; grid-column: 1/-1;"><span style="font-size: 48px; display: block; margin-bottom: 12px;">🏆</span><p>' + (filter === 'unlocked' ? '暂无已解锁成就' : filter === 'locked' ? '所有成就已解锁!' : '暂无成就数据') + '</p></div>';
}
container.innerHTML = html;
}
function renderLevelListFromAPI(currentLevel) {
const token = localStorage.getItem('user_token');
fetch('/api/web/growth/rules', { headers: { 'token': token } })
.then(res => res.json())
.then(data => {
if (data.code === 200) {
const sections = data.data.sections;
const levelSection = sections.find(s => s.type === 'level_system');
if (levelSection && levelSection.levels) {
const container = document.getElementById('levelList');
let html = '';
levelSection.levels.forEach(item => {
const isCurrent = item.level === currentLevel;
html += `
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; ${isCurrent ? 'background: linear-gradient(135deg, rgba(79,158,222,0.1) 0%, rgba(79,158,222,0.05) 100%); border: 1px solid rgba(79,158,222,0.2);' : 'background: #f8fafc;'}">
<div style="width: 36px; height: 36px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700; background: ${isCurrent ? 'linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%)' : '#e2e8f0'}; color: ${isCurrent ? '#fff' : '#64748b'};">Lv.${item.level}</div>
<div style="flex: 1;">
<div style="font-size: 13px; font-weight: 600; color: ${isCurrent ? '#4F9EDE' : '#1e293b'};">${item.icon} ${item.title}</div>
<div style="font-size: 12px; color: #64748b;">${item.exp} 经验</div>
</div>
${isCurrent ? '<span style="font-size: 11px; color: #4F9EDE; background: rgba(79,158,222,0.1); padding: 2px 8px; border-radius: 4px;">当前</span>' : ''}
</div>
`;
});
container.innerHTML = html;
}
}
});
}
</script>
</body>
</html>
+437
View File
@@ -0,0 +1,437 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>简记memo - 让记录更简单</title>
<meta name="description" content="集待办任务、账单记录、心情日记于一体的个人事务管理工具">
<link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: #f8fafc; }
.nav-link.active { background: rgba(79, 158, 222, 0.1); color: #4F9EDE; }
/* 头部滚动效果增强 */
.header-scrolled {
box-shadow: 0 4px 20px rgba(0,0,0,0.08) !important;
}
/* Hero Section 增强 */
.hero-section {
position: relative;
overflow: hidden;
}
.hero-section::before {
content: '';
position: absolute;
top: -50%;
right: -10%;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(79, 158, 222, 0.08) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
}
.hero-section::after {
content: '';
position: absolute;
bottom: -30%;
left: -5%;
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(139, 92, 246, 0.06) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(79, 158, 222, 0.1);
border-radius: 20px;
margin-bottom: 20px;
border: 1px solid rgba(79, 158, 222, 0.15);
animation: fadeInUp 0.6s ease forwards;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.hero-title-anim {
animation: fadeInUp 0.6s ease 0.1s forwards;
opacity: 0;
}
.hero-desc-anim {
animation: fadeInUp 0.6s ease 0.2s forwards;
opacity: 0;
}
.hero-btn-anim {
animation: fadeInUp 0.6s ease 0.3s forwards;
opacity: 0;
}
.hero-card-anim {
animation: fadeInUp 0.6s ease 0.4s forwards;
opacity: 0;
}
.feature-card {
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.feature-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0,0,0,0.06);
}
@media (max-width: 768px) {
.hero-section {
padding: 32px 16px 20px !important;
min-height: calc(100vh - 56px) !important;
padding-top: calc(56px + 32px) !important;
}
.hero-section > div {
max-width: 100% !important;
}
.hero-section > div > div {
grid-template-columns: 1fr !important;
gap: 24px !important;
text-align: center;
}
.hero-section h1 {
font-size: 32px !important;
line-height: 1.3 !important;
margin-bottom: 12px !important;
}
.hero-section p {
font-size: 15px !important;
line-height: 1.6 !important;
margin-bottom: 20px !important;
}
.hero-section .feature-card {
padding: 16px !important;
}
.hero-section > div > div > div:last-child {
order: -1;
}
.hero-section > div > div > div:last-child > div:first-child {
width: 100% !important;
height: auto !important;
padding: 16px !important;
}
.hero-section > div > div > div:last-child > div:first-child > div {
width: 100% !important;
height: auto !important;
min-height: 180px;
}
.hero-section .btn-group {
justify-content: center !important;
}
.hero-section .btn-group a {
padding: 14px 24px !important;
font-size: 15px !important;
}
#features {
padding: 40px 16px !important;
}
.features-grid {
grid-template-columns: repeat(2, 1fr) !important;
gap: 16px !important;
}
.feature-card {
padding: 20px !important;
}
.feature-card h3 {
font-size: 15px !important;
}
.feature-card p {
font-size: 13px !important;
}
#about {
padding: 40px 16px !important;
}
.about-content {
flex-direction: column !important;
gap: 24px !important;
text-align: center;
}
.about-content > div {
width: 100% !important;
}
#footer {
padding: 30px 16px !important;
text-align: center;
}
.footer-links {
flex-direction: column !important;
gap: 12px !important;
margin-bottom: 16px !important;
}
}
@media (max-width: 480px) {
.hero-section h1 {
font-size: 28px !important;
}
.hero-section p {
font-size: 14px !important;
}
.features-grid {
grid-template-columns: 1fr !important;
}
.feature-card {
padding: 16px !important;
}
.hero-section .btn-group {
flex-direction: column !important;
}
.hero-section .btn-group a {
width: 100% !important;
}
}
</style>
</head>
<body>
<div id="app">
<!-- 公共头部 -->
<div id="commonHeader"></div>
<!-- Hero Section -->
<section class="hero-section" style="min-height: calc(100vh - 64px); display: flex; align-items: center; padding: 80px 24px 40px; background: linear-gradient(135deg, #e0f2fe 0%, #f0f9ff 50%, #faf5ff 100%);">
<div style="max-width: 1000px; margin: 0 auto; width: 100%;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: center; position: relative; z-index: 1;">
<div>
<div class="hero-badge">
<span style="width: 8px; height: 8px; background: #4F9EDE; border-radius: 50%; display: inline-block;"></span>
<span style="font-size: 13px; color: #4F9EDE; font-weight: 500;">全新版本上线</span>
</div>
<h1 class="hero-title-anim" style="font-size: 42px; font-weight: 700; color: #1e293b; line-height: 1.2; margin-bottom: 16px;" id="siteName">简记memo</h1>
<p class="hero-desc-anim" style="font-size: 16px; color: #64748b; line-height: 1.7; margin-bottom: 28px;" id="siteDesc">集待办任务、账单记录、心情日记于一体的个人事务管理工具。简洁优雅的界面设计,让记录成为一种享受。</p>
<div class="btn-group hero-btn-anim" style="display: flex; gap: 12px; flex-wrap: wrap;">
<a href="/login" style="display: inline-flex; align-items: center; justify-content: center; padding: 12px 28px; background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border-radius: 8px; color: #fff; font-size: 14px; font-weight: 600; text-decoration: none; box-shadow: 0 4px 12px rgba(79, 158, 222, 0.3); transition: all 0.25s ease;">
立即体验
</a>
<a href="#features" style="display: inline-flex; align-items: center; justify-content: center; padding: 12px 28px; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; color: #475569; font-size: 14px; font-weight: 500; text-decoration: none; transition: all 0.25s ease;">
了解更多
</a>
</div>
</div>
<div class="hero-card-anim" style="position: relative;">
<div style="width: 380px; height: 380px; background: linear-gradient(135deg, rgba(79, 158, 222, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%); border-radius: 16px; display: flex; align-items: center; justify-content: center;">
<div style="width: 320px; height: 320px; background: #fff; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.06); padding: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px;">
<span style="font-size: 15px; font-weight: 600; color: #1e293b;">今日待办</span>
<span style="font-size: 11px; color: #94a3b8;" id="currentDate">5月13日</span>
</div>
<div style="space-y: 10px;">
<div style="display: flex; align-items: center; gap: 10px; padding: 10px; background: #f8fafc; border-radius: 6px;">
<div style="width: 20px; height: 20px; border-radius: 50%; border: 2px solid #4F9EDE; display: flex; align-items: center; justify-content: center;">
<span style="color: #4F9EDE; font-size: 10px;"></span>
</div>
<span style="font-size: 13px; color: #1e293b; text-decoration: line-through; opacity: 0.6;">完成项目文档</span>
</div>
<div style="display: flex; align-items: center; gap: 10px; padding: 10px; background: #fef3c7; border-radius: 6px;">
<div style="width: 20px; height: 20px; border-radius: 50%; border: 2px solid #f59e0b; display: flex; align-items: center; justify-content: center;">
</div>
<span style="font-size: 13px; color: #1e293b;">参加会议</span>
<span style="margin-left: auto; padding: 3px 6px; background: rgba(245, 158, 11, 0.2); border-radius: 4px; font-size: 10px; color: #d97706;">重要</span>
</div>
<div style="display: flex; align-items: center; gap: 10px; padding: 10px; background: #f8fafc; border-radius: 6px;">
<div style="width: 20px; height: 20px; border-radius: 50%; border: 2px solid #cbd5e1; display: flex; align-items: center; justify-content: center;">
</div>
<span style="font-size: 13px; color: #64748b;">整理文件</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section id="features" style="padding: 60px 24px; background: #fff;">
<div style="max-width: 1000px; margin: 0 auto;">
<div style="text-align: center; margin-bottom: 48px;">
<span style="font-size: 13px; color: #4F9EDE; font-weight: 500;">功能特性</span>
<h2 style="font-size: 28px; font-weight: 700; color: #1e293b; margin-top: 8px; margin-bottom: 12px;" id="featureTitle">为什么选择简记memo</h2>
<p style="font-size: 15px; color: #64748b; max-width: 500px; margin: 0 auto;">简洁优雅的界面设计,强大实用的功能,帮助您更好地管理生活</p>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px;">
<div class="feature-card" style="background: #f8fafc; border-radius: 8px; padding: 24px; transition: all 0.2s;">
<div style="width: 48px; height: 48px; background: linear-gradient(135deg, rgba(79, 158, 222, 0.1) 0%, rgba(79, 158, 222, 0.05) 100%); border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<span style="font-size: 24px;">📝</span>
</div>
<h3 style="font-size: 16px; font-weight: 600; color: #1e293b; margin-bottom: 8px;">待办管理</h3>
<p style="font-size: 13px; color: #64748b; line-height: 1.5;">轻松创建和管理待办任务,设置优先级和截止日期,让您的日程井井有条。</p>
</div>
<div class="feature-card" style="background: #fef3c7; border-radius: 8px; padding: 24px; transition: all 0.2s;">
<div style="width: 48px; height: 48px; background: rgba(245, 158, 11, 0.15); border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<span style="font-size: 24px;">😊</span>
</div>
<h3 style="font-size: 16px; font-weight: 600; color: #1e293b; margin-bottom: 8px;">心情日记</h3>
<p style="font-size: 13px; color: #64748b; line-height: 1.5;">记录每一天的心情变化,用文字和表情记录生活的点点滴滴。</p>
</div>
<div class="feature-card" style="background: #dcfce7; border-radius: 8px; padding: 24px; transition: all 0.2s;">
<div style="width: 48px; height: 48px; background: rgba(34, 197, 94, 0.15); border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<span style="font-size: 24px;">💰</span>
</div>
<h3 style="font-size: 16px; font-weight: 600; color: #1e293b; margin-bottom: 8px;">账单记录</h3>
<p style="font-size: 13px; color: #64748b; line-height: 1.5;">便捷记录收支情况,分类统计一目了然,让理财变得简单。</p>
</div>
<div class="feature-card" style="background: #e0e7ff; border-radius: 8px; padding: 24px; transition: all 0.2s;">
<div style="width: 48px; height: 48px; background: rgba(99, 102, 241, 0.15); border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-bottom: 16px;">
<span style="font-size: 24px;">🏆</span>
</div>
<h3 style="font-size: 16px; font-weight: 600; color: #1e293b; margin-bottom: 8px;">成长体系</h3>
<p style="font-size: 13px; color: #64748b; line-height: 1.5;">完成任务获得经验值,提升等级解锁成就,让记录更有成就感。</p>
</div>
</div>
</div>
</section>
<!-- How It Works -->
<section style="padding: 60px 24px; background: #f8fafc;">
<div style="max-width: 1000px; margin: 0 auto;">
<div style="text-align: center; margin-bottom: 48px;">
<span style="font-size: 13px; color: #4F9EDE; font-weight: 500;">使用流程</span>
<h2 style="font-size: 28px; font-weight: 700; color: #1e293b; margin-top: 8px;">简单三步开始记录</h2>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 32px;">
<div style="text-align: center;">
<div style="width: 72px; height: 72px; background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px;">
<span style="font-size: 32px;">1️⃣</span>
</div>
<h3 style="font-size: 16px; font-weight: 600; color: #1e293b; margin-bottom: 8px;">注册账号</h3>
<p style="font-size: 13px; color: #64748b;">使用邮箱快速注册,开启您的记录之旅</p>
</div>
<div style="text-align: center;">
<div style="width: 72px; height: 72px; background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px;">
<span style="font-size: 32px;">2️⃣</span>
</div>
<h3 style="font-size: 16px; font-weight: 600; color: #1e293b; margin-bottom: 8px;">创建任务</h3>
<p style="font-size: 13px; color: #64748b;">添加待办事项、记录心情或账单</p>
</div>
<div style="text-align: center;">
<div style="width: 72px; height: 72px; background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px;">
<span style="font-size: 32px;">3️⃣</span>
</div>
<h3 style="font-size: 16px; font-weight: 600; color: #1e293b; margin-bottom: 8px;">查看统计</h3>
<p style="font-size: 13px; color: #64748b;">查看完成进度、等级成长和成就</p>
</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section style="padding: 60px 24px; background: #fff;">
<div style="max-width: 600px; margin: 0 auto; text-align: center;">
<h2 style="font-size: 26px; font-weight: 700; color: #1e293b; margin-bottom: 12px;">开始您的记录之旅</h2>
<p style="font-size: 15px; color: #64748b; margin-bottom: 28px;" id="ctaDesc">加入简记memo,让每一天都值得记录</p>
<a href="/login" style="display: inline-flex; align-items: center; justify-content: center; padding: 14px 40px; background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border-radius: 8px; color: #fff; font-size: 15px; font-weight: 600; text-decoration: none; box-shadow: 0 4px 12px rgba(79, 158, 222, 0.3);">
立即体验
</a>
</div>
</section>
<!-- 公共底部 -->
<div id="commonFooter"></div>
</div>
<script src="https://unpkg.com/vue@2.6.14/dist/vue.min.js"></script>
<script src="https://unpkg.com/element-ui@2.15.14/lib/index.js"></script>
<script src="/static/js/config.js"></script>
<script src="/static/js/common-components.js"></script>
<script src="/static/js/toast.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('commonHeader').innerHTML = renderCommonHeader('/');
document.getElementById('commonFooter').innerHTML = renderCommonFooter();
initCommonComponents();
// 设置动态内容
if (typeof SITE_CONFIG !== 'undefined') {
document.getElementById('siteName').textContent = SITE_CONFIG.name || '简记memo';
document.getElementById('featureTitle').textContent = '为什么选择' + (SITE_CONFIG.name || '简记memo');
document.getElementById('ctaDesc').textContent = '加入' + (SITE_CONFIG.name || '简记memo') + ',让每一天都值得记录';
}
if (typeof APP_INFO !== 'undefined') {
document.getElementById('siteDesc').textContent = APP_INFO.description + '。简洁优雅的界面设计,让记录成为一种享受。';
}
// 设置当前日期
const today = new Date();
document.getElementById('currentDate').textContent = today.getMonth() + 1 + '月' + today.getDate() + '日';
const navLinks = document.querySelectorAll('a.nav-link');
navLinks.forEach(link => {
link.addEventListener('click', function(e) {
if (this.getAttribute('href') === '#features') {
e.preventDefault();
document.querySelector('#features').scrollIntoView({ behavior: 'smooth' });
}
});
});
// 头部滚动效果
const header = document.querySelector('#commonHeader header');
if (header) {
let ticking = false;
window.addEventListener('scroll', function() {
if (!ticking) {
window.requestAnimationFrame(function() {
if (window.scrollY > 10) {
header.classList.add('header-scrolled');
} else {
header.classList.remove('header-scrolled');
}
ticking = false;
});
ticking = true;
}
});
}
});
</script>
<script src="/static/js/auth.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>隐私政策 - 简记memo</title>
<link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: #f1f5f9; }
</style>
</head>
<body>
<div id="app">
<!-- 公共头部 -->
<div id="commonHeader"></div>
<!-- 页面内容 -->
<main style="padding: 84px 24px 40px; min-height: calc(100vh - 140px);">
<div style="max-width: 1000px; margin: 0 auto;">
<div style="background: #fff; border-radius: 16px; padding: 40px;">
<h1 style="font-size: 28px; font-weight: 700; color: #1e293b; text-align: center; margin-bottom: 12px;">简记memo隐私政策</h1>
<p style="font-size: 14px; color: #64748b; text-align: center; margin-bottom: 32px; padding-bottom: 20px; border-bottom: 1px dashed rgba(79, 158, 222, 0.2);">生效日期:2025年04月24日</p>
<div style="margin-bottom: 28px;">
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">1. 总则</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">1.1 为保护用户隐私,规范本服务的个人信息处理行为,本服务运营者(个人开发者,以下简称"运营者")依据《中华人民共和国个人信息保护法》《中华人民共和国网络安全法》等法律法规,制定本隐私政策。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">1.2 本隐私政策适用于您使用本服务过程中涉及的个人信息处理活动,您使用本服务即视为同意本政策;若您不同意本政策,请勿使用本服务。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">1.3 本服务由个人独立开发及运营,无所属企业/公司主体,所有信息处理行为均由运营者个人负责。</p>
</div>
<div style="margin-bottom: 28px;">
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">2. 个人信息的收集</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">2.1 登录相关信息:您注册账号时提供的邮箱地址、昵称等信息,用于识别您的用户身份。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">2.2 业务数据:您在本服务内录入的备忘录、待办事项、账单内容、编辑记录、时间戳等数据,将存储于运营者的服务器中,仅用于为您提供数据同步、查看、编辑等核心功能。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">2.3 设备与日志信息:为保障服务正常运行,可能被动获取基础设备信息(如设备型号、系统版本)、操作日志(如页面访问记录、功能使用记录),该类信息仅用于故障排查、功能优化。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">2.4 运营者不会主动收集您的姓名、身份证号、手机号、地理位置等其他个人敏感信息。</p>
</div>
<div style="margin-bottom: 28px;">
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">3. 个人信息的使用</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">3.1 用户身份信息仅用于识别您的用户身份,保障您能正常访问、管理自己存储在服务器的业务数据。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">3.2 您的业务数据仅用于为您本人提供查看、编辑、删除、同步等核心功能,运营者不会利用该数据进行商业推广、数据分析、第三方共享等其他用途。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">3.3 设备与日志信息仅用于定位和解决服务运行故障、优化产品体验,不会用于识别个人身份或向第三方披露。</p>
</div>
<div style="margin-bottom: 28px;">
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">4. 个人信息的存储与保护</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">4.1 存储方式:用户身份信息及您的业务数据存储于运营者的服务器中;设备与日志信息仅在故障排查期间临时存储,排查完成后即时删除。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">4.2 存储期限:用户身份信息与业务数据将持续存储,直至您主动申请删除账号;设备与日志信息存储期限不超过7日。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">4.3 安全措施:运营者通过服务器访问权限控制、数据加密传输等技术手段保障数据安全,但因网络攻击、服务器故障等非运营方可控因素导致的数据泄露,运营者不承担责任。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">4.4 您可通过服务内的删除功能清理自身业务数据,如需彻底删除账号及所有数据,可联系运营者协助处理。</p>
</div>
<div style="margin-bottom: 28px;">
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">5. 个人信息的共享、转让与公开披露</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">5.1 运营者不会将您的身份信息、业务数据、设备信息等任何信息共享、转让给第三方,除非获得您的明确授权,或法律法规、司法机关/行政机关的强制性要求。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">5.2 未经您同意,运营者不会公开披露您的任何信息;因法律法规要求披露时,将在允许范围内尽可能提前通知您。</p>
</div>
<div style="margin-bottom: 28px;">
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">6. 您的权利</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">6.1 您有权随时查看、编辑、删除自己存储在本服务内的业务数据;</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">6.2 您有权联系运营者申请删除账号及所有关联数据;</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">6.3 您有权向运营者查询自身个人信息的处理情况,运营者将在合理期限内予以答复;</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">6.4 若您发现本服务存在违规处理您信息的行为,有权向运营者反馈或向相关平台投诉。</p>
</div>
<div style="margin-bottom: 28px;">
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">7. 未成年人保护</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">7.1 本服务面向所有年龄段用户,若未成年人使用本服务,应在监护人指导下进行,监护人应承担相应监护责任。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">7.2 运营者不会主动收集未成年人的任何额外个人信息,若监护人发现未成年人的信息被不当处理,可联系运营者协助处理。</p>
</div>
<div style="margin-bottom: 28px;">
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">8. 隐私政策的变更</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">8.1 运营者可根据法律法规调整或业务优化,修改本隐私政策,修改后的政策将在服务内公示,公示满7日后生效。</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">8.2 若您不同意修改后的政策,应停止使用本服务并可申请删除账号数据;继续使用的,视为同意修改后的政策。</p>
</div>
<div style="margin-bottom: 28px;">
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">9. 联系我们</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">9.1 若您对本隐私政策有任何疑问、意见或投诉,可通过以下方式联系运营者:</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; padding-left: 2em; margin-bottom: 6px;" id="privacyEmail">联系邮箱:memo@miaoall.cn</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; padding-left: 2em;" id="privacyWechat">微信公众号:孙三苗(sunsanmiao)</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">9.2 运营者将在15个工作日内回复您的咨询或处理请求。</p>
</div>
<div>
<div style="font-size: 16px; font-weight: 600; color: #4F9EDE; background: rgba(79, 158, 222, 0.1); border-left: 4px solid #4F9EDE; padding: 10px 14px; border-radius: 0 8px 8px 0; margin-bottom: 14px;">10. 其他</div>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">10.1 本隐私政策的解释权归运营者个人所有;</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em; margin-bottom: 10px;">10.2 若本政策与法律法规冲突,以法律法规为准;</p>
<p style="font-size: 14px; color: #475569; line-height: 1.8; text-indent: 2em;">10.3 您使用本服务的行为,同时受相关平台的隐私政策及运营规范约束。</p>
</div>
</div>
</div>
</main>
<!-- 公共底部 -->
<div id="commonFooter"></div>
</div>
<script src="https://unpkg.com/vue@2.6.14/dist/vue.min.js"></script>
<script src="https://unpkg.com/element-ui@2.15.14/lib/index.js"></script>
<script src="/static/js/config.js"></script>
<script src="/static/js/common-components.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('commonHeader').innerHTML = renderCommonHeader('/privacy');
document.getElementById('commonFooter').innerHTML = renderCommonFooter();
initCommonComponents();
if (typeof SITE_CONFIG !== 'undefined') {
document.getElementById('privacyEmail').textContent = '联系邮箱:' + (SITE_CONFIG.email || 'memo@miaoall.cn');
if (SITE_CONFIG.wechat) {
document.getElementById('privacyWechat').textContent = '微信公众号:' + (SITE_CONFIG.wechat.name || '孙三苗') + '' + (SITE_CONFIG.wechat.account || 'sunsanmiao') + '';
}
}
});
</script>
</body>
</html>
+509
View File
@@ -0,0 +1,509 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>个人中心 - 简记memo</title>
<link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: #f8fafc; }
.nav-link.active { background: rgba(79, 158, 222, 0.1); color: #4F9EDE; }
.menu-card { transition: all 0.2s; cursor: pointer; }
.menu-card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
.stat-card { transition: all 0.2s; }
.stat-card:hover { transform: translateY(-2px); }
.avatar-option { cursor: pointer; transition: all 0.2s; border: 2px solid transparent; }
.avatar-option:hover { transform: scale(1.05); }
.avatar-option.selected { border-color: #4F9EDE; box-shadow: 0 0 0 3px rgba(79, 158, 222, 0.2); }
</style>
</head>
<body>
<div id="app">
<!-- 公共头部 -->
<div id="webHeader"></div>
<div style="margin-top: 56px; min-height: calc(100vh - 56px - 40px); padding: 24px;">
<div style="max-width: 1200px; margin: 0 auto;">
<!-- 左右布局 -->
<div style="display: grid; grid-template-columns: 380px 1fr; gap: 24px;">
<!-- 左侧:用户信息 -->
<div style="display: flex; flex-direction: column; gap: 16px;">
<!-- 用户信息卡片 -->
<div style="background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border-radius: 12px; padding: 32px 24px; color: #fff; text-align: center;">
<div id="userAvatarContainer" style="width: 100px; height: 100px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; margin: 0 auto 20px; font-size: 40px; font-weight: 600; cursor: pointer; position: relative; overflow: hidden;" onclick="showAvatarModal()">
U
<div style="position: absolute; bottom: 0; left: 0; right: 0; height: 28px; background: rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center; font-size: 12px;">更换</div>
</div>
<h2 style="font-size: 22px; font-weight: 700; margin-bottom: 6px;" id="userNickname">用户</h2>
<p style="font-size: 14px; opacity: 0.9; margin-bottom: 8px;" id="userEmail">未绑定邮箱</p>
<p style="font-size: 13px; opacity: 0.8;" id="userSignature">还没有个性签名</p>
<button onclick="showEditModal()" style="margin-top: 20px; padding: 10px 24px; background: rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.3); border-radius: 8px; font-size: 13px; color: #fff; cursor: pointer;">
编辑资料
</button>
</div>
<!-- 快捷入口 -->
<div style="background: #fff; border-radius: 12px; padding: 20px;">
<h3 style="font-size: 14px; font-weight: 600; color: #1e293b; margin-bottom: 16px;">快捷入口</h3>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div onclick="navigateTo('/web/calendar')" class="menu-card" style="display: flex; align-items: center; gap: 12px; padding: 14px; border-radius: 10px; background: #f8fafc;">
<div style="width: 40px; height: 40px; background: linear-gradient(135deg, rgba(79, 158, 222, 0.1) 0%, rgba(79, 158, 222, 0.05) 100%); border-radius: 10px; display: flex; align-items: center; justify-content: center;">
<svg style="width: 20px; height: 20px; fill: none; stroke: #4F9EDE;" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</div>
<div style="flex: 1;">
<div style="font-size: 14px; font-weight: 600; color: #1e293b;">日程管理</div>
<div style="font-size: 12px; color: #64748b;">管理每日待办任务</div>
</div>
<svg style="width: 16px; height: 16px; fill: none; stroke: #94a3b8;" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7"/></svg>
</div>
<div onclick="navigateTo('/web/growth')" class="menu-card" style="display: flex; align-items: center; gap: 12px; padding: 14px; border-radius: 10px; background: #f8fafc;">
<div style="width: 40px; height: 40px; background: linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(245, 158, 11, 0.05) 100%); border-radius: 10px; display: flex; align-items: center; justify-content: center;">
<svg style="width: 20px; height: 20px; fill: none; stroke: #f59e0b;" viewBox="0 0 24 24"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>
</div>
<div style="flex: 1;">
<div style="font-size: 14px; font-weight: 600; color: #1e293b;">成长中心</div>
<div style="font-size: 12px; color: #64748b;">查看等级与成就</div>
</div>
<svg style="width: 16px; height: 16px; fill: none; stroke: #94a3b8;" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7"/></svg>
</div>
</div>
</div>
<!-- 设置 -->
<div style="background: #fff; border-radius: 12px; padding: 8px;">
<div onclick="navigateTo('/about')" style="display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; cursor: pointer; border-radius: 8px; transition: all 0.15s;" onmouseover="this.style.background='#f8fafc'" onmouseout="this.style.background='transparent'">
<div style="display: flex; align-items: center; gap: 12px;">
<svg style="width: 18px; height: 18px; fill: none; stroke: #64748b;" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
<span style="font-size: 14px; color: #1e293b;">关于我们</span>
</div>
<svg style="width: 16px; height: 16px; fill: none; stroke: #94a3b8;" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7"/></svg>
</div>
<div onclick="navigateTo('/privacy')" style="display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; cursor: pointer; border-radius: 8px; transition: all 0.15s;" onmouseover="this.style.background='#f8fafc'" onmouseout="this.style.background='transparent'">
<div style="display: flex; align-items: center; gap: 12px;">
<svg style="width: 18px; height: 18px; fill: none; stroke: #64748b;" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<span style="font-size: 14px; color: #1e293b;">隐私政策</span>
</div>
<svg style="width: 16px; height: 16px; fill: none; stroke: #94a3b8;" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7"/></svg>
</div>
</div>
</div>
<!-- 右侧:统计数据 -->
<div style="display: flex; flex-direction: column; gap: 16px;">
<!-- 统计卡片 -->
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px;">
<div class="stat-card" style="background: #fff; border-radius: 12px; padding: 24px; text-align: center;">
<div style="font-size: 32px; font-weight: 700; color: #4F9EDE;" id="statTodoTotal">0</div>
<div style="font-size: 13px; color: #64748b; margin-top: 6px;">总待办</div>
</div>
<div class="stat-card" style="background: #fff; border-radius: 12px; padding: 24px; text-align: center;">
<div style="font-size: 32px; font-weight: 700; color: #22c55e;" id="statTodoDone">0</div>
<div style="font-size: 13px; color: #64748b; margin-top: 6px;">已完成</div>
</div>
<div class="stat-card" style="background: #fff; border-radius: 12px; padding: 24px; text-align: center;">
<div style="font-size: 32px; font-weight: 700; color: #f59e0b;" id="statLevel">1</div>
<div style="font-size: 13px; color: #64748b; margin-top: 6px;">当前等级</div>
</div>
<div class="stat-card" style="background: #fff; border-radius: 12px; padding: 24px; text-align: center;">
<div style="font-size: 32px; font-weight: 700; color: #8b5cf6;" id="statExp">0</div>
<div style="font-size: 13px; color: #64748b; margin-top: 6px;">经验值</div>
</div>
</div>
<!-- 成长进度 -->
<div style="background: #fff; border-radius: 12px; padding: 24px;">
<h3 style="font-size: 16px; font-weight: 600; color: #1e293b; margin-bottom: 20px;">成长进度</h3>
<div style="display: flex; align-items: center; gap: 20px;">
<div style="width: 80px; height: 80px; background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 24px; font-weight: 700;">
Lv.<span id="growthLevel">1</span>
</div>
<div style="flex: 1;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
<span style="font-size: 15px; font-weight: 600; color: #1e293b;">当前等级</span>
<span style="font-size: 13px; color: #64748b;" id="growthExpText">0 / 100 经验</span>
</div>
<div style="background: #e2e8f0; border-radius: 4px; height: 10px; overflow: hidden;">
<div id="growthExpBar" style="height: 100%; background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border-radius: 4px; transition: width 0.5s; width: 0%;"></div>
</div>
<p style="font-size: 12px; color: #94a3b8; margin-top: 8px;" id="growthRemain">还需 100 经验升级</p>
</div>
</div>
</div>
<!-- 最近成就 -->
<div style="background: #fff; border-radius: 12px; padding: 24px; flex: 1;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px;">
<h3 style="font-size: 16px; font-weight: 600; color: #1e293b;">我的成就</h3>
<div style="display: flex; align-items: center; gap: 12px;">
<select id="profileAchFilter" onchange="filterProfileAchievements()" style="padding: 6px 12px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 13px; color: #475569; background: #fff; cursor: pointer;">
<option value="all">全部分类</option>
</select>
</div>
</div>
<div id="recentAchievements" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 16px;">
<!-- 动态渲染 -->
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 公共底部 -->
<div id="webFooter"></div>
</div>
<!-- 编辑资料弹窗 -->
<div id="editModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center; padding: 24px;">
<div style="background: #fff; border-radius: 16px; padding: 28px; width: 100%; max-width: 400px;">
<h3 style="font-size: 18px; font-weight: 600; color: #1e293b; margin-bottom: 24px;">编辑资料</h3>
<div style="margin-bottom: 20px;">
<label style="display: block; font-size: 13px; font-weight: 500; color: #475569; margin-bottom: 8px;">昵称</label>
<input type="text" id="editNickname" placeholder="请输入昵称" style="width: 100%; padding: 12px 16px; border: 1px solid #e2e8f0; border-radius: 10px; font-size: 14px; outline: none;" onfocus="this.style.borderColor='#4F9EDE'" onblur="this.style.borderColor='#e2e8f0'">
</div>
<div style="margin-bottom: 28px;">
<label style="display: block; font-size: 13px; font-weight: 500; color: #475569; margin-bottom: 8px;">签名</label>
<textarea id="editSignature" placeholder="请输入个性签名" rows="3" style="width: 100%; padding: 12px 16px; border: 1px solid #e2e8f0; border-radius: 10px; font-size: 14px; resize: none; outline: none;" onfocus="this.style.borderColor='#4F9EDE'" onblur="this.style.borderColor='#e2e8f0'"></textarea>
</div>
<div style="display: flex; justify-content: flex-end; gap: 12px;">
<button onclick="closeEditModal()" style="padding: 10px 20px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 14px; color: #475569; cursor: pointer; background: #fff;">取消</button>
<button onclick="saveProfile()" style="padding: 10px 20px; background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border: none; border-radius: 8px; font-size: 14px; color: #fff; cursor: pointer;">保存</button>
</div>
</div>
</div>
<!-- 头像选择弹窗 -->
<div id="avatarModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center; padding: 24px;">
<div style="background: #fff; border-radius: 16px; padding: 28px; width: 100%; max-width: 480px;">
<h3 style="font-size: 18px; font-weight: 600; color: #1e293b; margin-bottom: 24px;">选择头像</h3>
<div id="avatarGrid" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px;">
<!-- 动态生成头像选项 -->
</div>
<div style="display: flex; justify-content: flex-end; gap: 12px;">
<button onclick="closeAvatarModal()" style="padding: 10px 20px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 14px; color: #475569; cursor: pointer; background: #fff;">取消</button>
<button onclick="saveAvatar()" style="padding: 10px 20px; background: linear-gradient(135deg, #4F9EDE 0%, #3A8BC9 100%); border: none; border-radius: 8px; font-size: 14px; color: #fff; cursor: pointer;">确认</button>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@2.6.14/dist/vue.min.js"></script>
<script src="https://unpkg.com/element-ui@2.15.14/lib/index.js"></script>
<script src="/static/js/config.js"></script>
<script src="/static/js/toast.js"></script>
<script src="/static/js/auth.js"></script>
<script src="/static/js/web-components.js"></script>
<script>
// 默认头像列表
const DEFAULT_AVATARS = [
{ url: '/static/avatar/ginger_cat.jpg', name: 'ginger_cat', label: '橘猫' },
{ url: '/static/avatar/ragdoll.jpg', name: 'ragdoll', label: '布偶猫' },
{ url: '/static/avatar/hamster.jpg', name: 'hamster', label: '小仓鼠' },
{ url: '/static/avatar/dragon.jpg', name: 'dragon', label: '龙' },
{ url: '/static/avatar/pony.jpg', name: 'pony', label: '小马' },
{ url: '/static/avatar/lamb.jpg', name: 'lamb', label: '小羊' },
{ url: '/static/avatar/corgi.jpg', name: 'corgi', label: '柯基' },
{ url: '/static/avatar/piglet.jpg', name: 'piglet', label: '小猪' },
];
let selectedAvatarUrl = '';
document.addEventListener('DOMContentLoaded', function() {
// 初始化公共头部底部
initWebPage('profile');
// 加载页面数据
loadUserInfo();
loadStats();
loadGrowthInfo();
renderAvatarGrid();
});
function navigateTo(url) {
window.location.href = url;
}
function loadUserInfo() {
const token = localStorage.getItem('user_token');
const nickname = localStorage.getItem('user_nickname') || '用户';
const avatar = localStorage.getItem('user_avatar');
const email = localStorage.getItem('user_email');
document.getElementById('userNickname').textContent = nickname;
// 优先显示localStorage中的邮箱
if (email) {
document.getElementById('userEmail').textContent = email;
}
const avatarContainer = document.getElementById('userAvatarContainer');
if (avatar) {
avatarContainer.innerHTML = `<img src="${avatar}" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover;"><div style="position: absolute; bottom: 0; left: 0; right: 0; height: 28px; background: rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center; font-size: 12px;">更换</div>`;
selectedAvatarUrl = avatar;
} else {
avatarContainer.textContent = nickname.charAt(0).toUpperCase();
}
fetch('/api/web/user/info', { headers: { 'token': token } })
.then(res => res.json())
.then(data => {
if (data.code === 200) {
const user = data.data;
document.getElementById('userNickname').textContent = user.nickname || '用户';
// 接口返回的邮箱优先
if (user.email) {
document.getElementById('userEmail').textContent = user.email;
localStorage.setItem('user_email', user.email);
}
if (user.signature) {
document.getElementById('userSignature').textContent = user.signature;
}
if (user.avatar) {
const container = document.getElementById('userAvatarContainer');
container.innerHTML = `<img src="${user.avatar}" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover;"><div style="position: absolute; bottom: 0; left: 0; right: 0; height: 28px; background: rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center; font-size: 12px;">更换</div>`;
localStorage.setItem('user_avatar', user.avatar);
selectedAvatarUrl = user.avatar;
}
localStorage.setItem('user_nickname', user.nickname || '用户');
}
});
}
function loadStats() {
const token = localStorage.getItem('user_token');
// 加载成长信息
fetch('/api/web/growth/info', { headers: { 'token': token } })
.then(res => res.json())
.then(data => {
if (data.code === 200) {
document.getElementById('statLevel').textContent = data.data.level || 1;
document.getElementById('statExp').textContent = data.data.total_exp || 0;
}
});
// 加载待办统计
fetch('/api/web/todo/list', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'token': token },
body: JSON.stringify({})
})
.then(res => res.json())
.then(data => {
if (data.code === 200) {
const todos = data.data || [];
const total = todos.length;
const done = todos.filter(t => t.done === 1 || t.status === 1).length;
document.getElementById('statTodoTotal').textContent = total;
document.getElementById('statTodoDone').textContent = done;
}
});
}
function loadGrowthInfo() {
const token = localStorage.getItem('user_token');
fetch('/api/web/growth/info', { headers: { 'token': token } })
.then(res => res.json())
.then(data => {
if (data.code === 200) {
const g = data.data;
document.getElementById('growthLevel').textContent = g.level || 1;
// 直接使用后端返回的字段
const currentExp = g.currentExp || 0;
const expToNext = g.nextLevelExp || 100;
const progress = g.levelProgress || 0;
document.getElementById('growthExpText').textContent = currentExp + ' / ' + expToNext + ' 经验';
document.getElementById('growthRemain').textContent = '还需 ' + expToNext + ' 经验升级';
document.getElementById('growthExpBar').style.width = Math.min(progress, 100) + '%';
// 更新统计数据
document.getElementById('statLevel').textContent = g.level || 1;
document.getElementById('statExp').textContent = g.experience || 0;
// 渲染成就
renderProfileAchievements(g.achievements?.list || []);
}
});
}
// 存储成就数据用于筛选
let profileAchievementsData = [];
let profileAchCategories = [];
// 渲染个人中心成就
function renderProfileAchievements(unlockedList) {
const token = localStorage.getItem('user_token');
// 获取成就分类
fetch('/api/web/growth/rules', { headers: { 'token': token } })
.then(res => res.json())
.then(data => {
if (data.code === 200) {
const sections = data.data.sections;
const achSection = sections.find(s => s.type === 'achievement_system');
if (achSection && achSection.categories) {
profileAchCategories = achSection.categories;
// 更新分类下拉框 - 使用小写字段名
const select = document.getElementById('profileAchFilter');
if (select) {
select.innerHTML = '<option value="all">全部分类</option>' +
profileAchCategories.map(c => `<option value="${c.key || c.Key}">${c.name || c.Name}</option>`).join('');
}
}
// 按稀有度排序(传说 > 史诗 > 稀有 > 高级 > 中级 > 初级)
const rarityOrder = { '传说': 6, '史诗': 5, '稀有': 4, '高级': 3, '中级': 2, '初级': 1 };
profileAchievementsData = (unlockedList || []).sort((a, b) => {
return (rarityOrder[b.rarityName || b.rarity] || 0) - (rarityOrder[a.rarityName || a.rarity] || 0);
});
renderProfileAchList('all');
}
})
.catch(err => {
console.error('加载成就分类失败:', err);
// 即使没有分类,也尝试渲染成就
profileAchievementsData = unlockedList || [];
renderProfileAchList('all');
});
}
function filterProfileAchievements() {
const filter = document.getElementById('profileAchFilter').value;
renderProfileAchList(filter);
}
function renderProfileAchList(filter) {
const container = document.getElementById('recentAchievements');
let filtered = profileAchievementsData;
if (filter !== 'all') {
filtered = profileAchievementsData.filter(ach => ach.category === filter);
}
if (filtered.length === 0) {
container.innerHTML = '<div style="text-align: center; padding: 40px; color: #94a3b8; grid-column: 1/-1;"><span style="font-size: 48px; display: block; margin-bottom: 12px;">🏆</span><p>' + (filter === 'all' ? '暂无成就,继续努力吧!' : '该分类暂无成就') + '</p></div>';
return;
}
let html = '';
filtered.forEach(ach => {
const color = ach.rarityColor || '#4F9EDE';
const icon = ach.icon || '🏆';
const name = ach.name || '未知成就';
const desc = ach.description || ach.desc || '';
const rarityName = ach.rarityName || ach.rarity || '';
html += `
<div style="text-align: center; padding: 16px; border-radius: 12px; background: linear-gradient(135deg, rgba(79,158,222,0.08) 0%, rgba(79,158,222,0.02) 100%);">
<div style="width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; margin: 0 auto 10px; background: ${color}20;">
<span style="font-size: 20px;">${icon}</span>
</div>
<h4 style="font-size: 13px; font-weight: 600; color: ${color}; margin-bottom: 2px;">${name}</h4>
${desc ? '<p style="font-size: 11px; color: #64748b; margin-bottom: 4px;">' + desc + '</p>' : ''}
${rarityName ? '<span style="display: inline-block; padding: 2px 8px; background: ' + color + '15; color: ' + color + '; font-size: 10px; border-radius: 4px;">' + rarityName + '</span>' : ''}
</div>
`;
});
container.innerHTML = html;
}
function renderAvatarGrid() {
const container = document.getElementById('avatarGrid');
let html = '';
DEFAULT_AVATARS.forEach(avatar => {
const isSelected = selectedAvatarUrl === avatar.url;
html += `
<div class="avatar-option ${isSelected ? 'selected' : ''}" onclick="selectAvatar('${avatar.url}', this)" style="border-radius: 50%; overflow: hidden; width: 80px; height: 80px; margin: 0 auto;">
<img src="${avatar.url}" style="width: 100%; height: 100%; object-fit: cover;" alt="${avatar.label}">
</div>
`;
});
container.innerHTML = html;
}
function selectAvatar(url, element) {
selectedAvatarUrl = url;
document.querySelectorAll('.avatar-option').forEach(el => el.classList.remove('selected'));
element.classList.add('selected');
}
function showAvatarModal() {
renderAvatarGrid();
document.getElementById('avatarModal').style.display = 'flex';
}
function closeAvatarModal() {
document.getElementById('avatarModal').style.display = 'none';
}
function saveAvatar() {
if (!selectedAvatarUrl) {
$toast.error('请选择头像');
return;
}
const token = localStorage.getItem('user_token');
fetch('/api/web/user/edit', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'token': token },
body: JSON.stringify({ avatar: selectedAvatarUrl })
})
.then(res => res.json())
.then(data => {
if (data.code === 200) {
$toast.success('头像更换成功');
localStorage.setItem('user_avatar', selectedAvatarUrl);
loadUserInfo();
closeAvatarModal();
} else {
$toast.error(data.msg || '更换失败');
}
});
}
function showEditModal() {
const nickname = document.getElementById('userNickname').textContent;
const signature = document.getElementById('userSignature').textContent;
document.getElementById('editNickname').value = nickname;
document.getElementById('editSignature').value = signature === '还没有个性签名' ? '' : signature;
document.getElementById('editModal').style.display = 'flex';
}
function closeEditModal() {
document.getElementById('editModal').style.display = 'none';
}
function saveProfile() {
const token = localStorage.getItem('user_token');
const nickname = document.getElementById('editNickname').value;
const signature = document.getElementById('editSignature').value;
if (!nickname) {
$toast.error('请输入昵称');
return;
}
fetch('/api/web/user/edit', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'token': token },
body: JSON.stringify({ nickname, signature })
})
.then(res => res.json())
.then(data => {
if (data.code === 200) {
$toast.success('保存成功');
closeEditModal();
document.getElementById('userNickname').textContent = nickname;
document.getElementById('userSignature').textContent = signature || '还没有个性签名';
localStorage.setItem('user_nickname', nickname);
} else {
$toast.error(data.msg || '保存失败');
}
});
}
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

+172
View File
@@ -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);
}
})();
+386
View File
@@ -0,0 +1,386 @@
// 公共头部组件渲染函数
function renderCommonHeader(currentPath) {
return `
<header id="commonHeader" style="position: fixed; top: 0; left: 0; right: 0; z-index: 999; background: rgba(255, 255, 255, 0.98); backdrop-filter: blur(20px); box-shadow: 0 1px 3px rgba(0,0,0,0.06);">
<div class="header-content" style="max-width: 1200px; margin: 0 auto; padding: 0 24px; display: flex; align-items: center; height: 64px;">
<a href="/" class="header-logo" style="display: flex; align-items: center; text-decoration: none; margin-right: 40px;">
<img src="${SITE_CONFIG.path}" alt="${SITE_CONFIG.name}" style="height: 36px; width: auto; border-radius: 8px; margin-right: 10px;" onerror="this.style.display='none'">
<span style="font-size: 18px; font-weight: 700; color: ${SITE_CONFIG.brand.primaryColor};">${SITE_CONFIG.name}</span>
</a>
<nav class="nav-menu" style="flex: 1; display: flex; gap: 8px;">
<a href="/" class="nav-link ${currentPath === '/' ? 'active' : ''}" data-path="/" style="padding: 8px 16px; border-radius: 8px; color: #475569; font-size: 14px; font-weight: 500; text-decoration: none; transition: all 0.2s;">首页</a>
<a href="/growth-rules" class="nav-link ${currentPath === '/growth-rules' ? 'active' : ''}" style="padding: 8px 16px; border-radius: 8px; color: #475569; font-size: 14px; font-weight: 500; text-decoration: none; transition: all 0.2s;">成长规则</a>
<a href="/about" class="nav-link ${currentPath === '/about' ? 'active' : ''}" style="padding: 8px 16px; border-radius: 8px; color: #475569; font-size: 14px; font-weight: 500; text-decoration: none; transition: all 0.2s;">关于我们</a>
</nav>
<div id="userMenuContainer" class="user-menu-container" style="display: flex; align-items: center;"></div>
<!-- 移动端菜单按钮 -->
<button id="mobileMenuBtn" class="mobile-menu-btn" style="display: none; flex-direction: column; gap: 4px; padding: 8px; background: none; border: none; cursor: pointer; border-radius: 8px; transition: background 0.2s; margin-left: auto;" aria-label="菜单">
<span style="width: 24px; height: 2px; background: #475569; border-radius: 1px; transition: all 0.2s;"></span>
<span style="width: 24px; height: 2px; background: #475569; border-radius: 1px; transition: all 0.2s;"></span>
<span style="width: 24px; height: 2px; background: #475569; border-radius: 1px; transition: all 0.2s;"></span>
</button>
</div>
<!-- 移动端下拉菜单遮罩 -->
<div id="mobileMenuOverlay" style="display: none; position: fixed; top: 64px; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 998; backdrop-filter: blur(4px);"></div>
<!-- 移动端下拉菜单 -->
<div id="mobileDropdownMenu" style="display: none; position: absolute; top: 64px; left: 0; right: 0; background: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.12); z-index: 999; max-height: calc(100vh - 64px); overflow-y: auto; animation: slideDown 0.3s ease-out;">
<div style="padding: 16px 0;">
<!-- Logo区域 -->
<div style="display: flex; align-items: center; padding: 0 20px 16px; border-bottom: 1px solid #f1f5f9;">
<img src="${SITE_CONFIG.path}" alt="${SITE_CONFIG.name}" style="height: 40px; width: auto; border-radius: 8px; margin-right: 12px;" onerror="this.style.display='none'">
<span style="font-size: 18px; font-weight: 700; color: ${SITE_CONFIG.brand.primaryColor};">${SITE_CONFIG.name}</span>
</div>
<!-- 导航链接 -->
<div style="padding: 8px 0;">
<a href="/" class="mobile-nav-item ${currentPath === '/' ? 'active' : ''}" data-path="/" style="display: flex; align-items: center; padding: 14px 20px; color: #1e293b; font-size: 15px; font-weight: 500; text-decoration: none; transition: all 0.2s;">
<svg style="width: 20px; height: 20px; fill: none; stroke: currentColor; margin-right: 14px;" viewBox="0 0 24 24"><path d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
首页
</a>
<a href="/growth-rules" class="mobile-nav-item ${currentPath === '/growth-rules' ? 'active' : ''}" style="display: flex; align-items: center; padding: 14px 20px; color: #1e293b; font-size: 15px; font-weight: 500; text-decoration: none; transition: all 0.2s;">
<svg style="width: 20px; height: 20px; fill: none; stroke: currentColor; margin-right: 14px;" viewBox="0 0 24 24"><path d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/></svg>
成长规则
</a>
<a href="/about" class="mobile-nav-item ${currentPath === '/about' ? 'active' : ''}" style="display: flex; align-items: center; padding: 14px 20px; color: #1e293b; font-size: 15px; font-weight: 500; text-decoration: none; transition: all 0.2s;">
<svg style="width: 20px; height: 20px; fill: none; stroke: currentColor; margin-right: 14px;" viewBox="0 0 24 24"><path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
关于我们
</a>
</div>
<!-- 用户菜单区域 -->
<div id="mobileUserMenu" style="border-top: 1px solid #f1f5f9; margin-top: 8px;"></div>
</div>
</div>
</header>
<style>
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.nav-link:hover, .nav-link.active {
background: rgba(79, 158, 222, 0.1);
color: ${SITE_CONFIG.brand.primaryColor};
}
.mobile-nav-item:hover, .mobile-nav-item.active {
background: rgba(79, 158, 222, 0.06);
color: ${SITE_CONFIG.brand.primaryColor};
}
.dropdown-item:hover {
background: rgba(79, 158, 222, 0.08);
}
.mobile-menu-btn:hover {
background: #f1f5f9;
}
.mobile-menu-btn.active span:nth-child(1) {
transform: rotate(45deg) translateY(6px);
}
.mobile-menu-btn.active span:nth-child(2) {
opacity: 0;
}
.mobile-menu-btn.active span:nth-child(3) {
transform: rotate(-45deg) translateY(-6px);
}
@media (max-width: 768px) {
.header-content {
padding: 0 16px;
height: 56px;
}
.header-logo span {
font-size: 16px;
}
.header-logo img {
height: 32px;
}
.nav-menu {
display: none !important;
}
.user-menu-container {
display: none !important;
}
#mobileMenuBtn {
display: flex !important;
}
#commonHeader {
height: 56px;
}
#mobileDropdownMenu {
top: 56px;
max-height: calc(100vh - 56px);
}
#mobileMenuOverlay {
top: 56px;
}
}
@media (min-width: 769px) {
#mobileMenuBtn, #mobileDropdownMenu, #mobileMenuOverlay {
display: none !important;
}
}
</style>
`;
}
// 用户菜单渲染函数
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 `
<div class="user-menu" style="position: relative;">
<div class="user-dropdown-trigger" style="display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: rgba(79, 158, 222, 0.08); border-radius: 20px; color: #4F9EDE; cursor: pointer;">
<img src="${avatarSrc}" alt="头像" style="width: 28px; height: 28px; border-radius: 50%; object-fit: cover;" onerror="this.src='${defaultAvatar}';">
<span style="font-size: 13px; font-weight: 500;">${nickname}</span>
<svg style="width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2;" viewBox="0 0 24 24">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
<div class="user-dropdown-menu" style="position: absolute; top: calc(100% + 8px); right: 0; min-width: 160px; background: #fff; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); padding: 8px 0; opacity: 0; visibility: hidden; transform: translateY(-8px); transition: all 0.2s; z-index: 1000;">
<a href="/web/calendar" class="dropdown-item" style="display: flex; align-items: center; gap: 10px; padding: 10px 16px; text-decoration: none; color: #475569; font-size: 14px;">
<svg style="width: 16px; height: 16px; fill: none; stroke: currentColor;" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
日历
</a>
<a href="/web/growth" class="dropdown-item" style="display: flex; align-items: center; gap: 10px; padding: 10px 16px; text-decoration: none; color: #475569; font-size: 14px;">
<svg style="width: 16px; height: 16px; fill: none; stroke: currentColor;" viewBox="0 0 24 24"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>
成长中心
</a>
<a href="/web/profile" class="dropdown-item" style="display: flex; align-items: center; gap: 10px; padding: 10px 16px; text-decoration: none; color: #475569; font-size: 14px;">
<svg style="width: 16px; height: 16px; fill: none; stroke: currentColor;" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/></svg>
个人中心
</a>
<div style="height: 1px; background: #e2e8f0; margin: 4px 0;"></div>
<a href="#" id="logoutBtn" class="dropdown-item" style="display: flex; align-items: center; gap: 10px; padding: 10px 16px; text-decoration: none; color: #ef4444; font-size: 14px;">
<svg style="width: 16px; height: 16px; fill: none; stroke: currentColor;" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/></svg>
退出登录
</a>
</div>
</div>
`;
} else {
return `
<a href="/login" class="login-btn" style="padding: 8px 20px; background: ${SITE_CONFIG.brand.gradient}; border-radius: 20px; color: #fff; font-size: 13px; font-weight: 500; text-decoration: none; transition: all 0.2s;">登录</a>
`;
}
}
// 公共底部渲染函数
function renderCommonFooter() {
return `
<footer id="commonFooter" style="background: #f8fafc; border-top: 1px solid #e2e8f0; padding: 40px 24px;">
<div style="max-width: 1200px; margin: 0 auto;">
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 16px;">
<div style="display: flex; align-items: center; gap: 12px;">
<img src="${SITE_CONFIG.path}" alt="${SITE_CONFIG.name}" style="height: 32px; width: auto; border-radius: 6px;" onerror="this.style.display='none'">
<span style="font-size: 14px; font-weight: 600; color: #1e293b;">${SITE_CONFIG.name}</span>
</div>
<div style="display: flex; gap: 24px; flex-wrap: wrap;">
<a href="/agreement" style="font-size: 13px; color: #64748b; text-decoration: none;">用户协议</a>
<a href="/privacy" style="font-size: 13px; color: #64748b; text-decoration: none;">隐私政策</a>
<a href="/about" style="font-size: 13px; color: #64748b; text-decoration: none;">联系我们</a>
</div>
</div>
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #e2e8f0; text-align: center;">
<span style="font-size: 12px; color: #94a3b8;">${SITE_CONFIG.copyright}</span>
<span style="margin: 0 12px; color: #e2e8f0;">|</span>
<a href="${APP_INFO.beian.icpLink}" target="_blank" style="font-size: 12px; color: #94a3b8; text-decoration: none;">${APP_INFO.beian.icp}</a>
</div>
</div>
</footer>
`;
}
// 初始化公共组件
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 `
<div style="padding: 8px 0;">
<a href="/web/calendar" class="mobile-nav-item" style="display: flex; align-items: center; padding: 14px 20px; color: #1e293b; font-size: 15px; font-weight: 500; text-decoration: none; transition: all 0.2s;">
<svg style="width: 20px; height: 20px; fill: none; stroke: currentColor; margin-right: 14px;" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
日历
</a>
<a href="/web/profile" class="mobile-nav-item" style="display: flex; align-items: center; padding: 14px 20px; color: #1e293b; font-size: 15px; font-weight: 500; text-decoration: none; transition: all 0.2s;">
<svg style="width: 20px; height: 20px; fill: none; stroke: currentColor; margin-right: 14px;" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/></svg>
个人中心
</a>
<a href="/web/growth" class="mobile-nav-item" style="display: flex; align-items: center; padding: 14px 20px; color: #1e293b; font-size: 15px; font-weight: 500; text-decoration: none; transition: all 0.2s;">
<svg style="width: 20px; height: 20px; fill: none; stroke: currentColor; margin-right: 14px;" viewBox="0 0 24 24"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>
成长中心
</a>
<a href="#" id="mobileLogoutBtn" class="mobile-nav-item" style="display: flex; align-items: center; padding: 14px 20px; color: #ef4444; font-size: 15px; font-weight: 500; text-decoration: none; transition: all 0.2s;">
<svg style="width: 20px; height: 20px; fill: none; stroke: currentColor; margin-right: 14px;" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/></svg>
退出登录
</a>
</div>
`;
} else {
return `
<div style="padding: 8px 0;">
<a href="/login" class="mobile-nav-item" style="display: flex; align-items: center; padding: 14px 20px; color: ${SITE_CONFIG.brand.primaryColor}; font-size: 15px; font-weight: 600; text-decoration: none; transition: all 0.2s;">
<svg style="width: 20px; height: 20px; fill: none; stroke: currentColor; margin-right: 14px;" viewBox="0 0 24 24"><path d="M16 11V7a4 4 0 0 0-8 0v4M5 9h14l1 12H4L5 9"/></svg>
登录 / 注册
</a>
</div>
`;
}
}
function navigateTo(url) {
window.location.href = url;
}
+139
View File
@@ -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 = `
<div class="minipro-modal">
<div class="minipro-modal-header">
<h3 class="minipro-modal-title">提示</h3>
<button class="minipro-modal-close" onclick="closeMiniProgramModal()">&times;</button>
</div>
<div class="minipro-modal-body">
<div class="minipro-modal-icon">📱</div>
<h3 id="minipro-modal-title">${title}</h3>
<p id="minipro-modal-desc">${desc}</p>
<p style="font-size: 0.875rem; color: var(--text-secondary);">
请打开微信搜索"简记memo"小程序体验完整功能
</p>
</div>
<div class="minipro-modal-footer">
<button class="btn btn-secondary" onclick="closeMiniProgramModal()">知道了</button>
</div>
</div>
`;
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 = '';
}
}

Some files were not shown because too many files have changed in this diff Show More