Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca0bb69ec5 |
@@ -1,8 +0,0 @@
|
|||||||
# 默认忽略的文件
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# 基于编辑器的 HTTP 客户端请求
|
|
||||||
/httpRequests/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="com.codeverse.userSettings.MarscodeWorkspaceAppSettingsState">
|
|
||||||
<option name="progress" value="1.0" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="GitToolBoxBlameSettings">
|
|
||||||
<option name="version" value="2" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"topic_20260729-054121_491ef2923de04c48": 1785303681311
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"topic_20260729-054121_491ef2923de04c48": "auto"
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"topic_20260729-054121_491ef2923de04c48": "新的会话"
|
|
||||||
}
|
|
||||||
@@ -1,507 +0,0 @@
|
|||||||
// 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,539 +0,0 @@
|
|||||||
// 账单接口
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
// 预算接口
|
|
||||||
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, "删除成功")
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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, "验证码错误")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
// 分类接口
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
// 首页聚合数据接口
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
// 心情接口
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
// 复盘接口(仅 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,436 +0,0 @@
|
|||||||
// 待办接口
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,982 +0,0 @@
|
|||||||
// 小程序用户接口
|
|
||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"simple-memo/global"
|
|
||||||
"simple-memo/models"
|
|
||||||
"simple-memo/utils"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// -------------------------- 1. 定义 Handler 接口 --------------------------
|
|
||||||
// UserHandler 用户模块接口定义
|
|
||||||
type UserHandler interface {
|
|
||||||
Login(c *gin.Context) // 微信登录
|
|
||||||
Logout(c *gin.Context) // 退出登录
|
|
||||||
UserInfo(c *gin.Context) // 获取用户信息
|
|
||||||
EditUser(c *gin.Context) // 修改用户信息
|
|
||||||
SendEmailCode(c *gin.Context) // 发送邮箱验证码(支持多种场景)
|
|
||||||
SetEmail(c *gin.Context) // 设置邮箱(绑定邮箱)
|
|
||||||
EmailLogin(c *gin.Context) // 邮箱密码登录
|
|
||||||
EmailRegister(c *gin.Context) // 邮箱注册
|
|
||||||
ResetPassword(c *gin.Context) // 重置密码(未登录找回密码)
|
|
||||||
ChangePassword(c *gin.Context) // 修改密码(已登录场景)
|
|
||||||
CheckEmailExists(c *gin.Context) // 检查邮箱是否已注册
|
|
||||||
BindWechat(c *gin.Context) // 绑定微信(Web端,网页授权code)
|
|
||||||
BindWechatMini(c *gin.Context) // 绑定微信(小程序端,wx.login code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------- 2. 实现结构体(依赖注入DB) --------------------------
|
|
||||||
// userHandler 接口实现结构体
|
|
||||||
type userHandler struct {
|
|
||||||
db *gorm.DB // 注入数据库,方便测试
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUserHandler 创建用户处理器(对外暴露)
|
|
||||||
func NewUserHandler() UserHandler {
|
|
||||||
return &userHandler{
|
|
||||||
db: global.DB,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------- 3. 请求结构体定义 --------------------------
|
|
||||||
// LoginReq 登录参数
|
|
||||||
type LoginReq struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type EditUserReq struct {
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Avatar string `json:"avatar"`
|
|
||||||
Signature string `json:"signature"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------- 4. 接口实现 --------------------------
|
|
||||||
|
|
||||||
// Login 微信登录
|
|
||||||
func (h *userHandler) Login(c *gin.Context) {
|
|
||||||
var req LoginReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
utils.Fail(c, "参数解析失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Code == "" {
|
|
||||||
utils.Fail(c, "code不能为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用微信接口获取 openid
|
|
||||||
wxResp, err := utils.MiniProgramCode2Session(req.Code)
|
|
||||||
if err != nil {
|
|
||||||
utils.Fail(c, "微信登录失败:"+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询或创建用户
|
|
||||||
var user models.User
|
|
||||||
err = h.db.Where("openid = ?", wxResp.OpenID).First(&user).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
// 创建新用户
|
|
||||||
user = models.User{
|
|
||||||
Openid: wxResp.OpenID,
|
|
||||||
UserCode: utils.GenerateUserCode(),
|
|
||||||
}
|
|
||||||
err = h.db.Create(&user).Error
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
utils.Fail(c, "用户操作失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成 token
|
|
||||||
token, expiresAt, err := utils.GenerateTokenWithExpire(user.ID)
|
|
||||||
if err != nil {
|
|
||||||
utils.Fail(c, "token生成失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 返回数据
|
|
||||||
utils.Ok(c, gin.H{
|
|
||||||
"token": token,
|
|
||||||
"expiresAt": expiresAt,
|
|
||||||
"expiresIn": utils.GetTokenExpireSeconds(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout 退出登录
|
|
||||||
func (h *userHandler) Logout(c *gin.Context) {
|
|
||||||
// 从上下文获取用户ID和token(JWTAuth中间件已注入)
|
|
||||||
userId, exists := c.Get("userID")
|
|
||||||
if !exists {
|
|
||||||
utils.Fail(c, "用户未登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token, tokenExists := c.Get("token")
|
|
||||||
if !tokenExists {
|
|
||||||
utils.Fail(c, "获取token失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 token 获取过期时间
|
|
||||||
claims, err := utils.ParseToken(token.(string))
|
|
||||||
if err != nil {
|
|
||||||
utils.Fail(c, "token无效")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算剩余过期时间
|
|
||||||
ttl := time.Until(claims.ExpiresAt.Time)
|
|
||||||
if ttl > 0 {
|
|
||||||
// 将 token 加入 Redis 黑名单
|
|
||||||
ctx := context.Background()
|
|
||||||
err = global.Redis.Set(ctx, "token_blacklist:"+token.(string), "1", ttl).Err()
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("添加token到黑名单失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
|
||||||
utils.Fail(c, "退出登录失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
global.Logger.Info("用户退出登录", append(utils.LogContextFields(c), zap.Uint("userID", userId.(uint)))...)
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.Ok(c, "退出登录成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserInfo 获取用户信息
|
|
||||||
func (h *userHandler) UserInfo(c *gin.Context) {
|
|
||||||
userId, exists := c.Get("userID")
|
|
||||||
if !exists {
|
|
||||||
utils.Fail(c, "用户未登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var user models.User
|
|
||||||
err := h.db.First(&user, userId).Error
|
|
||||||
if err != nil {
|
|
||||||
utils.Fail(c, "获取用户信息失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 返回脱敏用户信息
|
|
||||||
utils.Ok(c, gin.H{
|
|
||||||
"id": user.ID,
|
|
||||||
"nickname": user.Nickname,
|
|
||||||
"avatar": user.Avatar,
|
|
||||||
"signature": user.Signature,
|
|
||||||
"email": user.Email,
|
|
||||||
"user_code": user.UserCode,
|
|
||||||
"is_bind_wechat": user.Openid != "", // 是否绑定微信
|
|
||||||
"is_bind_email": user.Email != "", // 是否绑定邮箱
|
|
||||||
"createdAt": user.CreatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// EditUser 修改用户信息
|
|
||||||
func (h *userHandler) EditUser(c *gin.Context) {
|
|
||||||
userId, exists := c.Get("userID")
|
|
||||||
if !exists {
|
|
||||||
utils.Fail(c, "用户未登录")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var editUserReq EditUserReq
|
|
||||||
if err := c.ShouldBindJSON(&editUserReq); err != nil {
|
|
||||||
utils.Fail(c, "参数解析失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 如果 昵称或者头像为空,则不更新
|
|
||||||
if editUserReq.Nickname == "" && editUserReq.Avatar == "" && editUserReq.Signature == "" {
|
|
||||||
utils.Ok(c, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 构建更新数据(支持同时更新多个字段)
|
|
||||||
data := make(map[string]any)
|
|
||||||
if editUserReq.Nickname != "" {
|
|
||||||
data["nickname"] = utils.FilterSensitive(editUserReq.Nickname) // 敏感词过滤
|
|
||||||
}
|
|
||||||
if editUserReq.Avatar != "" {
|
|
||||||
data["avatar"] = editUserReq.Avatar
|
|
||||||
}
|
|
||||||
if editUserReq.Signature != "" {
|
|
||||||
data["signature"] = utils.FilterSensitive(editUserReq.Signature) // 敏感词过滤
|
|
||||||
}
|
|
||||||
if len(data) == 0 {
|
|
||||||
utils.Ok(c, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := h.db.Model(&models.User{}).Where("id = ?", userId).Updates(data).Error
|
|
||||||
if err != nil {
|
|
||||||
utils.Fail(c, "用户信息更新失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
utils.Ok(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------- 4. 邮箱验证接口 --------------------------
|
|
||||||
|
|
||||||
// SendEmailCodeReq 发送邮箱验证码请求
|
|
||||||
type SendEmailCodeReq struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Type string `json:"type" binding:"omitempty,oneof=register login bind reset_password change_email"` // type为空时:已登录状态下绑定邮箱
|
|
||||||
CaptchaId string `json:"captchaId"` // 图形验证码ID(注册/登录/找回密码场景必填)
|
|
||||||
Captcha string `json:"captcha"` // 图形验证码(注册/登录/找回密码场景必填)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendEmailCode 发送邮箱验证码(支持多种场景)
|
|
||||||
// type=register: 注册,检查邮箱未被注册
|
|
||||||
// type=login: 登录,检查邮箱已注册且有密码
|
|
||||||
// type=bind/空: 已登录状态下绑定邮箱,不检查邮箱状态
|
|
||||||
// type=reset_password: 找回密码,检查邮箱已注册且有密码
|
|
||||||
// type=change_email: 修改邮箱(已登录),检查新邮箱未被其他用户使用
|
|
||||||
func (h *userHandler) SendEmailCode(c *gin.Context) {
|
|
||||||
var req SendEmailCodeReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
utils.Fail(c, "请输入有效的邮箱地址")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// 1. 获取客户端IP
|
|
||||||
clientIP := c.ClientIP()
|
|
||||||
if clientIP == "" {
|
|
||||||
clientIP = c.GetHeader("X-Forwarded-For")
|
|
||||||
}
|
|
||||||
if clientIP == "" {
|
|
||||||
clientIP = c.GetHeader("X-Real-IP")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch req.Type {
|
|
||||||
case "register":
|
|
||||||
// 注册场景:检查邮箱未被注册,需要验证图形验证码
|
|
||||||
// 验证图形验证码
|
|
||||||
if req.CaptchaId == "" || req.Captcha == "" {
|
|
||||||
utils.Fail(c, "请输入图形验证码")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !store.Verify(req.CaptchaId, req.Captcha, true) {
|
|
||||||
utils.Fail(c, "图形验证码错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var user models.User
|
|
||||||
err := h.db.Where("email = ?", req.Email).First(&user).Error
|
|
||||||
if err == nil {
|
|
||||||
utils.Fail(c, "该邮箱已被注册")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "login":
|
|
||||||
// 登录场景:检查邮箱已注册且有密码,需要验证图形验证码
|
|
||||||
// 验证图形验证码
|
|
||||||
if req.CaptchaId == "" || req.Captcha == "" {
|
|
||||||
utils.Fail(c, "请输入图形验证码")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !store.Verify(req.CaptchaId, req.Captcha, true) {
|
|
||||||
utils.Fail(c, "图形验证码错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var user models.User
|
|
||||||
err := h.db.Where("email = ?", req.Email).First(&user).Error
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
utils.Fail(c, "该邮箱未注册")
|
|
||||||
} else {
|
|
||||||
utils.Fail(c, "查询用户失败")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if user.Password == "" {
|
|
||||||
utils.Fail(c, "该邮箱未设置密码,请先注册")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "reset_password":
|
|
||||||
// 找回密码场景:检查邮箱已注册,需要验证图形验证码
|
|
||||||
// 验证图形验证码
|
|
||||||
if req.CaptchaId == "" || req.Captcha == "" {
|
|
||||||
utils.Fail(c, "请输入图形验证码")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !store.Verify(req.CaptchaId, req.Captcha, true) {
|
|
||||||
utils.Fail(c, "图形验证码错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var user models.User
|
|
||||||
err := h.db.Where("email = ?", req.Email).First(&user).Error
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
utils.Fail(c, "该邮箱未注册")
|
|
||||||
} else {
|
|
||||||
utils.Fail(c, "查询用户失败")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if user.Password == "" {
|
|
||||||
utils.Fail(c, "该邮箱未设置密码,请先注册")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "change_email":
|
|
||||||
// 修改邮箱场景:需要用户已登录,检查新邮箱未被其他用户使用,需要验证图形验证码
|
|
||||||
userId, exists := c.Get("userID")
|
|
||||||
if !exists {
|
|
||||||
utils.Fail(c, "用户未登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证图形验证码
|
|
||||||
if req.CaptchaId == "" || req.Captcha == "" {
|
|
||||||
utils.Fail(c, "请输入图形验证码")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !store.Verify(req.CaptchaId, req.Captcha, true) {
|
|
||||||
utils.Fail(c, "图形验证码错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查新邮箱是否已被其他用户使用
|
|
||||||
var existingUser models.User
|
|
||||||
err := h.db.Where("email = ? AND id != ?", req.Email, userId).First(&existingUser).Error
|
|
||||||
if err == nil {
|
|
||||||
utils.Fail(c, "该邮箱已被其他用户使用")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "bind", "":
|
|
||||||
// 绑定邮箱场景:需要用户已登录,不检查邮箱状态,不需要图形验证码
|
|
||||||
_, exists := c.Get("userID")
|
|
||||||
if !exists {
|
|
||||||
utils.Fail(c, "用户未登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
utils.Fail(c, "无效的type参数")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. IP级别的频率限制:同一IP每分钟最多发送5次
|
|
||||||
ipRateLimitKey := "email_ip_rate_limit:" + clientIP
|
|
||||||
ipCount, err := global.Redis.Incr(ctx, ipRateLimitKey).Result()
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("IP频率限制计数失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
|
||||||
utils.Fail(c, "发送验证码失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ipCount == 1 {
|
|
||||||
global.Redis.Expire(ctx, ipRateLimitKey, 60*time.Second)
|
|
||||||
} else if ipCount > 5 {
|
|
||||||
ttl, _ := global.Redis.TTL(ctx, ipRateLimitKey).Result()
|
|
||||||
utils.Fail(c, fmt.Sprintf("当前IP发送过于频繁,请%d秒后再试", int(ttl.Seconds())))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 邮箱级别的频率限制:同一邮箱每60秒只能发送一次
|
|
||||||
emailRateLimitKey := "email_rate_limit:" + req.Email
|
|
||||||
emailCount, err := global.Redis.Exists(ctx, emailRateLimitKey).Result()
|
|
||||||
if err == nil && emailCount > 0 {
|
|
||||||
ttl, _ := global.Redis.TTL(ctx, emailRateLimitKey).Result()
|
|
||||||
utils.Fail(c, fmt.Sprintf("该邮箱发送太频繁,请%d秒后再试", int(ttl.Seconds())))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 邮箱每日发送次数限制:同一邮箱每天最多发送10次
|
|
||||||
now := time.Now()
|
|
||||||
dayKey := "email_daily_limit:" + req.Email + ":" + now.Format("2006-01-02")
|
|
||||||
dailyCount, err := global.Redis.Incr(ctx, dayKey).Result()
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("邮箱每日限制计数失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
|
||||||
utils.Fail(c, "发送验证码失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if dailyCount == 1 {
|
|
||||||
global.Redis.Expire(ctx, dayKey, 24*time.Hour)
|
|
||||||
} else if dailyCount > 10 {
|
|
||||||
utils.Fail(c, "该邮箱今日发送次数已达上限,请明天再试")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. IP每日发送次数限制:同一IP每天最多发送30次
|
|
||||||
ipDayKey := "email_ip_daily_limit:" + clientIP + ":" + now.Format("2006-01-02")
|
|
||||||
ipDailyCount, err := global.Redis.Incr(ctx, ipDayKey).Result()
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("IP每日限制计数失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
|
||||||
utils.Fail(c, "发送验证码失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ipDailyCount == 1 {
|
|
||||||
global.Redis.Expire(ctx, ipDayKey, 24*time.Hour)
|
|
||||||
} else if ipDailyCount > 30 {
|
|
||||||
utils.Fail(c, "当前IP今日发送次数已达上限,请明天再试")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
code := utils.GenerateRandomString(6)
|
|
||||||
err = global.Redis.Set(ctx, "email_code:"+req.Email, code, 5*time.Minute).Err()
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("保存邮箱验证码失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
|
||||||
utils.Fail(c, "发送验证码失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
global.Redis.Set(ctx, emailRateLimitKey, "1", 60*time.Second)
|
|
||||||
|
|
||||||
err = utils.SendEmailCode(req.Email, code)
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("发送邮件失败", append(utils.LogContextFields(c), zap.String("email", req.Email), zap.Error(err))...)
|
|
||||||
global.Redis.Del(ctx, "email_code:"+req.Email)
|
|
||||||
utils.Fail(c, "发送验证码失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
global.Logger.Info("验证码发送成功", append(utils.LogContextFields(c), zap.String("email", req.Email), zap.String("code", code))...)
|
|
||||||
utils.Ok(c, "验证码已发送,请注意查收")
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetEmailReq 设置邮箱请求
|
|
||||||
type SetEmailReq struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Code string `json:"code" binding:"required,len=6"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetEmail 设置邮箱(需验证验证码)
|
|
||||||
func (h *userHandler) SetEmail(c *gin.Context) {
|
|
||||||
userId, exists := c.Get("userID")
|
|
||||||
if !exists {
|
|
||||||
utils.Fail(c, "用户未登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req SetEmailReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
utils.Fail(c, "请输入有效的邮箱和验证码")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
storedCode, err := global.Redis.Get(ctx, "email_code:"+req.Email).Result()
|
|
||||||
if err != nil || storedCode != req.Code {
|
|
||||||
utils.Fail(c, "验证码错误或已过期")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
global.Redis.Del(ctx, "email_code:"+req.Email)
|
|
||||||
|
|
||||||
var existingUser models.User
|
|
||||||
err = h.db.Where("email = ? AND id != ?", req.Email, userId).First(&existingUser).Error
|
|
||||||
if err == nil {
|
|
||||||
utils.Fail(c, "该邮箱已被其他用户使用")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.db.Model(&models.User{}).Where("id = ?", userId).Update("email", req.Email).Error
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("更新邮箱失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
|
||||||
utils.Fail(c, "设置邮箱失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.Ok(c, "邮箱设置成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------- 5. 网页端邮箱登录接口 --------------------------
|
|
||||||
|
|
||||||
// EmailRegisterReq 邮箱注册请求
|
|
||||||
type EmailRegisterReq struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Password string `json:"password" binding:"required,min=6"`
|
|
||||||
Code string `json:"code" binding:"required,len=6"`
|
|
||||||
CaptchaId string `json:"captchaId"` // 图形验证码ID
|
|
||||||
Captcha string `json:"captcha"` // 图形验证码
|
|
||||||
}
|
|
||||||
|
|
||||||
// EmailLoginReq 邮箱登录请求
|
|
||||||
type EmailLoginReq struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Password string `json:"password" binding:"required"`
|
|
||||||
CaptchaId string `json:"captchaId"` // 图形验证码ID
|
|
||||||
Captcha string `json:"captcha"` // 图形验证码
|
|
||||||
}
|
|
||||||
|
|
||||||
// EmailRegister 邮箱注册
|
|
||||||
func (h *userHandler) EmailRegister(c *gin.Context) {
|
|
||||||
var req EmailRegisterReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
utils.Fail(c, "请输入有效的邮箱和密码")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注:图形验证码在发送邮箱验证码时已验证,此处不再重复验证
|
|
||||||
|
|
||||||
// 验证邮箱验证码(必填)
|
|
||||||
ctx := context.Background()
|
|
||||||
storedCode, err := global.Redis.Get(ctx, "email_code:"+req.Email).Result()
|
|
||||||
if err != nil || storedCode != req.Code {
|
|
||||||
utils.Fail(c, "邮箱验证码错误或已过期")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
global.Redis.Del(ctx, "email_code:"+req.Email)
|
|
||||||
|
|
||||||
var existingUser models.User
|
|
||||||
err = h.db.Where("email = ?", req.Email).First(&existingUser).Error
|
|
||||||
if err == nil {
|
|
||||||
utils.Fail(c, "该邮箱已被注册")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("密码加密失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
|
||||||
utils.Fail(c, "注册失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userCode := utils.GenerateUserCode()
|
|
||||||
|
|
||||||
user := models.User{
|
|
||||||
Email: req.Email,
|
|
||||||
Password: string(hashedPassword),
|
|
||||||
Nickname: "用户" + utils.GenerateRandomString(6),
|
|
||||||
UserCode: userCode,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.db.Create(&user).Error
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("创建用户失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
|
||||||
utils.Fail(c, "注册失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, expiresAt, err := utils.GenerateTokenWithExpire(user.ID)
|
|
||||||
if err != nil {
|
|
||||||
utils.Fail(c, "token生成失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.Ok(c, gin.H{
|
|
||||||
"token": token,
|
|
||||||
"expiresAt": expiresAt,
|
|
||||||
"expiresIn": utils.GetTokenExpireSeconds(),
|
|
||||||
"nickname": user.Nickname,
|
|
||||||
"user_code": user.UserCode,
|
|
||||||
"avatar": user.Avatar,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// EmailLogin 邮箱登录
|
|
||||||
func (h *userHandler) EmailLogin(c *gin.Context) {
|
|
||||||
var req EmailLoginReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
utils.Fail(c, "请输入有效的邮箱和密码")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证图形验证码(必填)
|
|
||||||
if req.CaptchaId == "" || req.Captcha == "" {
|
|
||||||
utils.Fail(c, "请输入图形验证码")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !store.Verify(req.CaptchaId, req.Captcha, true) {
|
|
||||||
utils.Fail(c, "图形验证码错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var user models.User
|
|
||||||
err := h.db.Where("email = ?", req.Email).First(&user).Error
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
utils.Fail(c, "邮箱或密码错误")
|
|
||||||
} else {
|
|
||||||
utils.Fail(c, "登录失败")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.Password == "" {
|
|
||||||
utils.Fail(c, "该邮箱未设置密码,请先注册")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password))
|
|
||||||
if err != nil {
|
|
||||||
utils.Fail(c, "邮箱或密码错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, expiresAt, err := utils.GenerateTokenWithExpire(user.ID)
|
|
||||||
if err != nil {
|
|
||||||
utils.Fail(c, "登录失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.Ok(c, gin.H{
|
|
||||||
"token": token,
|
|
||||||
"expiresAt": expiresAt,
|
|
||||||
"expiresIn": utils.GetTokenExpireSeconds(),
|
|
||||||
"nickname": user.Nickname,
|
|
||||||
"avatar": user.Avatar,
|
|
||||||
"email": user.Email,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckEmailExistsReq 检查邮箱是否存在请求
|
|
||||||
type CheckEmailExistsReq struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckEmailExists 检查邮箱是否已注册
|
|
||||||
func (h *userHandler) CheckEmailExists(c *gin.Context) {
|
|
||||||
var req CheckEmailExistsReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
utils.Fail(c, "请输入有效的邮箱地址")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var user models.User
|
|
||||||
err := h.db.Where("email = ?", req.Email).First(&user).Error
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
utils.Ok(c, gin.H{
|
|
||||||
"exists": false,
|
|
||||||
"hasPassword": false,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
utils.Fail(c, "查询失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.Ok(c, gin.H{
|
|
||||||
"exists": true,
|
|
||||||
"hasPassword": user.Password != "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetPasswordReq 重置密码请求(未登录找回密码场景)
|
|
||||||
type ResetPasswordReq struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Password string `json:"password" binding:"required,min=6"`
|
|
||||||
ConfirmPassword string `json:"confirmPassword"` // 确认密码(可选,前端已验证)
|
|
||||||
Code string `json:"code" binding:"required,len=6"` // 邮箱验证码(必填)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetPassword 重置密码(未登录找回密码场景)
|
|
||||||
func (h *userHandler) ResetPassword(c *gin.Context) {
|
|
||||||
var req ResetPasswordReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
utils.Fail(c, "请输入有效的邮箱、密码和验证码")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证密码一致性(如果前端传递了 confirmPassword)
|
|
||||||
if req.ConfirmPassword != "" && req.Password != req.ConfirmPassword {
|
|
||||||
utils.Fail(c, "两次输入的密码不一致")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
storedCode, err := global.Redis.Get(ctx, "email_code:"+req.Email).Result()
|
|
||||||
if err != nil || storedCode != req.Code {
|
|
||||||
utils.Fail(c, "验证码错误或已过期")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
global.Redis.Del(ctx, "email_code:"+req.Email)
|
|
||||||
|
|
||||||
var user models.User
|
|
||||||
err = h.db.Where("email = ?", req.Email).First(&user).Error
|
|
||||||
if err != nil {
|
|
||||||
utils.Fail(c, "该邮箱未注册")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("密码加密失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
|
||||||
utils.Fail(c, "重置密码失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.db.Model(&user).Update("password", string(hashedPassword)).Error
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("更新密码失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
|
||||||
utils.Fail(c, "重置密码失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.Ok(c, "密码重置成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChangePasswordReq 修改密码请求(已登录场景)
|
|
||||||
type ChangePasswordReq struct {
|
|
||||||
Password string `json:"password" binding:"required,min=6"` // 新密码
|
|
||||||
ConfirmPassword string `json:"confirmPassword" binding:"required"` // 确认密码
|
|
||||||
NewEmail string `json:"newEmail" binding:"omitempty,email"` // 新邮箱(可选)
|
|
||||||
CaptchaId string `json:"captchaId" binding:"required"` // 图形验证码ID
|
|
||||||
Captcha string `json:"captcha" binding:"required"` // 图形验证码
|
|
||||||
Code string `json:"code" binding:"omitempty,len=6"` // 邮箱验证码(修改邮箱时必填)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChangePassword 修改密码(已登录场景)
|
|
||||||
// 支持两种场景:
|
|
||||||
// 1. 只修改密码(不修改邮箱):只需图形验证码
|
|
||||||
// 2. 修改邮箱+密码:需要新邮箱的验证码
|
|
||||||
func (h *userHandler) ChangePassword(c *gin.Context) {
|
|
||||||
userId, exists := c.Get("userID")
|
|
||||||
if !exists {
|
|
||||||
utils.Fail(c, "用户未登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req ChangePasswordReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
utils.Fail(c, "请输入有效的参数")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 验证图形验证码(必填)
|
|
||||||
if !store.Verify(req.CaptchaId, req.Captcha, true) {
|
|
||||||
utils.Fail(c, "图形验证码错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 验证密码一致性
|
|
||||||
if req.Password != req.ConfirmPassword {
|
|
||||||
utils.Fail(c, "两次输入的密码不一致")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// 3. 如果提供了新邮箱,需要验证邮箱验证码
|
|
||||||
if req.NewEmail != "" {
|
|
||||||
if req.Code == "" {
|
|
||||||
utils.Fail(c, "修改邮箱需要邮箱验证码")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
storedCode, err := global.Redis.Get(ctx, "email_code:"+req.NewEmail).Result()
|
|
||||||
if err != nil || storedCode != req.Code {
|
|
||||||
utils.Fail(c, "邮箱验证码错误或已过期")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
global.Redis.Del(ctx, "email_code:"+req.NewEmail)
|
|
||||||
|
|
||||||
// 检查新邮箱是否已被其他用户使用
|
|
||||||
var existingUser models.User
|
|
||||||
err = h.db.Where("email = ? AND id != ?", req.NewEmail, userId).First(&existingUser).Error
|
|
||||||
if err == nil {
|
|
||||||
utils.Fail(c, "该邮箱已被其他用户使用")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 更新密码
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("密码加密失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
|
||||||
utils.Fail(c, "修改密码失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建更新数据
|
|
||||||
updateData := map[string]interface{}{
|
|
||||||
"password": string(hashedPassword),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果提供了新邮箱,同时更新邮箱
|
|
||||||
if req.NewEmail != "" {
|
|
||||||
updateData["email"] = req.NewEmail
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.db.Model(&models.User{}).Where("id = ?", userId).Updates(updateData).Error
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("更新用户信息失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
|
||||||
utils.Fail(c, "修改失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 返回成功消息
|
|
||||||
if req.NewEmail != "" {
|
|
||||||
utils.Ok(c, "邮箱和密码修改成功")
|
|
||||||
} else {
|
|
||||||
utils.Ok(c, "密码修改成功")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------- 6. 微信绑定接口 --------------------------
|
|
||||||
|
|
||||||
// BindWechatReq 绑定微信请求(Web端已登录)
|
|
||||||
type BindWechatReq struct {
|
|
||||||
Code string `json:"code" binding:"required"` // 微信网页授权code
|
|
||||||
}
|
|
||||||
|
|
||||||
// BindWechat 绑定微信(已登录场景,Web端使用)
|
|
||||||
// 流程:用户在Web端已登录 → 扫描微信二维码 → 微信回传code → 调用此接口绑定openid
|
|
||||||
func (h *userHandler) BindWechat(c *gin.Context) {
|
|
||||||
userId, exists := c.Get("userID")
|
|
||||||
if !exists {
|
|
||||||
utils.Fail(c, "用户未登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req BindWechatReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
utils.Fail(c, "参数解析失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Code == "" {
|
|
||||||
utils.Fail(c, "授权code不能为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用微信网页授权接口获取 openid
|
|
||||||
wxResp, err := utils.WebCode2Session(req.Code)
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("微信网页授权失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
|
||||||
utils.Fail(c, "微信授权失败:"+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if wxResp.OpenID == "" {
|
|
||||||
utils.Fail(c, "获取微信用户信息失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查该 openid 是否已被其他用户绑定
|
|
||||||
var existingUser models.User
|
|
||||||
err = h.db.Where("openid = ? AND id != ?", wxResp.OpenID, userId).First(&existingUser).Error
|
|
||||||
if err == nil {
|
|
||||||
utils.Fail(c, "该微信账号已被其他用户绑定")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查当前用户是否已绑定微信
|
|
||||||
var currentUser models.User
|
|
||||||
err = h.db.First(¤tUser, userId).Error
|
|
||||||
if err != nil {
|
|
||||||
utils.Fail(c, "用户不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if currentUser.Openid != "" {
|
|
||||||
utils.Fail(c, "您已绑定微信,请先解绑")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 绑定微信 openid 到当前用户
|
|
||||||
err = h.db.Model(&models.User{}).Where("id = ?", userId).Update("openid", wxResp.OpenID).Error
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("绑定微信失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
|
||||||
utils.Fail(c, "绑定微信失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
global.Logger.Info("微信绑定成功", append(utils.LogContextFields(c),
|
|
||||||
zap.Uint("userID", userId.(uint)),
|
|
||||||
zap.String("openid", wxResp.OpenID),
|
|
||||||
)...)
|
|
||||||
|
|
||||||
utils.Ok(c, "微信绑定成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------- 7. 小程序微信绑定接口 --------------------------
|
|
||||||
|
|
||||||
// BindWechatMiniReq 绑定微信请求(小程序端已登录)
|
|
||||||
type BindWechatMiniReq struct {
|
|
||||||
Code string `json:"code" binding:"required"` // 小程序 wx.login() 返回的临时code
|
|
||||||
}
|
|
||||||
|
|
||||||
// BindWechatMini 小程序绑定微信(已登录场景)
|
|
||||||
// 流程:小程序用户用邮箱注册并登录 → 点击"绑定微信" → wx.login() 获取code → 调用此接口绑定openid
|
|
||||||
//
|
|
||||||
// 与 Web 端 BindWechat 的关键区别:
|
|
||||||
//
|
|
||||||
// - Web 端:code 来自微信网页OAuth2.0扫码授权 → 调用 sns/oauth2/access_token 换取 → WebCode2Session
|
|
||||||
//
|
|
||||||
// - 小程序端:code 来自 wx.login() → 调用 sns/jscode2session 换取 → MiniProgramCode2Session
|
|
||||||
//
|
|
||||||
// 两者 code 完全不同,API 端点不同,不可混用!
|
|
||||||
func (h *userHandler) BindWechatMini(c *gin.Context) {
|
|
||||||
userId, exists := c.Get("userID")
|
|
||||||
if !exists {
|
|
||||||
utils.Fail(c, "用户未登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req BindWechatMiniReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
utils.Fail(c, "参数解析失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Code == "" {
|
|
||||||
utils.Fail(c, "授权code不能为空,请先调用wx.login获取")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用小程序专用接口:sns/jscode2session
|
|
||||||
wxResp, err := utils.MiniProgramCode2Session(req.Code)
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("小程序微信授权失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
|
||||||
utils.Fail(c, "微信授权失败,请稍后重试")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if wxResp.OpenID == "" {
|
|
||||||
utils.Fail(c, "获取微信用户信息失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查该 openid 是否已被其他用户绑定
|
|
||||||
var existingUser models.User
|
|
||||||
err = h.db.Where("openid = ? AND id != ?", wxResp.OpenID, userId).First(&existingUser).Error
|
|
||||||
if err == nil {
|
|
||||||
utils.Fail(c, "该微信账号已被其他用户绑定")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查当前用户是否已绑定微信
|
|
||||||
var currentUser models.User
|
|
||||||
err = h.db.First(¤tUser, userId).Error
|
|
||||||
if err != nil {
|
|
||||||
utils.Fail(c, "用户不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if currentUser.Openid != "" {
|
|
||||||
utils.Fail(c, "您已绑定微信,请先解绑")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新 openid 和相关字段
|
|
||||||
updates := map[string]interface{}{
|
|
||||||
"openid": wxResp.OpenID,
|
|
||||||
}
|
|
||||||
// 若存在 unionid 一并保存(跨平台识别用)
|
|
||||||
if wxResp.UnionID != "" {
|
|
||||||
updates["unionid"] = wxResp.UnionID
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.db.Model(&models.User{}).Where("id = ?", userId).Updates(updates).Error
|
|
||||||
if err != nil {
|
|
||||||
global.Logger.Error("小程序绑定微信失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
|
||||||
utils.Fail(c, "绑定微信失败,请稍后重试")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
global.Logger.Info("小程序微信绑定成功", append(utils.LogContextFields(c),
|
|
||||||
zap.Uint("userID", userId.(uint)),
|
|
||||||
zap.String("openid", wxResp.OpenID),
|
|
||||||
)...)
|
|
||||||
|
|
||||||
utils.Ok(c, "微信绑定成功")
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
// 小程序用户接口
|
|
||||||
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)
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
@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
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#!/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 "编译完成!"
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# 开发环境配置
|
|
||||||
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
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# 生产环境配置
|
|
||||||
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
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// 配置加载:区分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 // 赋值给全局
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
// 日志模块: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())
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
# 「简记memo」一体化小程序
|
|
||||||
## 完整产品原型设计文档
|
|
||||||
**定位**:极简高级、强功能、纯个人工具、云存储、无社交无广告
|
|
||||||
**风格**:留白、卡片、轻动画、深浅双主题
|
|
||||||
**架构**:底部 4 Tab + 全局快捷操作 + 数据云同步
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 一、全局规则(所有页面通用)
|
|
||||||
1. **数据隔离**
|
|
||||||
仅当前微信用户可见,openid 绑定,自动登录,无需注册
|
|
||||||
2. **日期体系**
|
|
||||||
所有数据按「yyyy-MM-dd」归类,支持跨天查看/顺延
|
|
||||||
3. **操作统一**
|
|
||||||
- 左滑:删除
|
|
||||||
- 右滑:完成/确认
|
|
||||||
- 长按:编辑/批量
|
|
||||||
- 悬浮按钮:快速添加
|
|
||||||
4. **主题**
|
|
||||||
浅色 / 深色 / 跟随系统
|
|
||||||
5. **云存储**
|
|
||||||
微信云开发数据库,无服务器、无域名、单人可维护
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 二、底部 Tab 结构(4 个)
|
|
||||||
1. **今日**(聚合页)
|
|
||||||
2. **待办**(全任务管理)
|
|
||||||
3. **账单**(全收支管理)
|
|
||||||
4. **我的**(配置+数据+统计)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 三、页面原型详细描述(逐页完整)
|
|
||||||
|
|
||||||
## Page 1:今日(核心聚合页)
|
|
||||||
### 顶部
|
|
||||||
- 日期:yyyy-MM-dd 星期X
|
|
||||||
- 左右箭头:昨日 / 明日
|
|
||||||
- 小开关:快速切换「只看今日 / 含逾期」
|
|
||||||
|
|
||||||
### 模块 A:今日待办
|
|
||||||
- 进度条:已完成 / 总数
|
|
||||||
- 列表:
|
|
||||||
图标 + 标题 + 优先级(红点/黄点/绿点)+ 完成勾选框
|
|
||||||
- 空态:今日暂无任务,添加一个开始吧
|
|
||||||
- 快捷操作:
|
|
||||||
右滑 = 完成
|
|
||||||
左滑 = 删除
|
|
||||||
点击 = 进入编辑
|
|
||||||
|
|
||||||
### 模块 B:今日记账
|
|
||||||
- 今日支出:¥xx
|
|
||||||
- 今日收入:¥xx
|
|
||||||
- 今日结余:自动计算
|
|
||||||
- 账单列表:
|
|
||||||
分类图标 + 备注 + 金额(红/绿)
|
|
||||||
- 空态:今日暂无记录
|
|
||||||
|
|
||||||
### 底部悬浮按钮(左右双按钮)
|
|
||||||
- 左侧:+ 记一笔
|
|
||||||
- 右侧:+ 加任务
|
|
||||||
|
|
||||||
### 页面逻辑
|
|
||||||
- 进入自动加载当日数据
|
|
||||||
- 完成/删除后实时刷新
|
|
||||||
- 切换日期自动重新请求
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Page 2:待办(全功能任务页)
|
|
||||||
### 顶部
|
|
||||||
- 搜索框:搜索标题/备注
|
|
||||||
- 筛选:全部 / 未完成 / 已完成 / 逾期
|
|
||||||
|
|
||||||
### 筛选栏
|
|
||||||
- 今天 / 本周 / 本月 / 自定义区间
|
|
||||||
|
|
||||||
### 任务列表结构
|
|
||||||
每条任务:
|
|
||||||
- 优先级图标(高/中/低)
|
|
||||||
- 任务标题
|
|
||||||
- 任务备注(折叠,超出两行隐藏)
|
|
||||||
- 重复标记(每日/每周/每月)
|
|
||||||
- 日期
|
|
||||||
- 完成开关
|
|
||||||
|
|
||||||
### 功能点(强但不复杂)
|
|
||||||
1. **新增任务**
|
|
||||||
- 标题(必填)
|
|
||||||
- 备注(选填)
|
|
||||||
- 分类:工作/生活/学习/自定义
|
|
||||||
- 优先级:高/中/低
|
|
||||||
- 日期:默认今日,可改
|
|
||||||
- 重复:不重复 / 每日 / 每周 / 每月
|
|
||||||
2. **子任务**
|
|
||||||
- 每条主任务可加多条子任务
|
|
||||||
- 子任务完成状态独立
|
|
||||||
3. **自动顺延**
|
|
||||||
当日未完成 → 次日自动显示(可开关)
|
|
||||||
4. **批量操作**
|
|
||||||
长按进入批量:完成/删除/移动日期
|
|
||||||
5. **重复任务自动生成**
|
|
||||||
每日/每周/每月自动生成下一条
|
|
||||||
|
|
||||||
### 空态
|
|
||||||
你还没有任何任务,点击右下角添加第一个任务
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Page 3:账单(全功能记账页)
|
|
||||||
### 顶部
|
|
||||||
- 搜索:备注/分类
|
|
||||||
- 切换:支出 / 收入 / 全部
|
|
||||||
|
|
||||||
### 统计条
|
|
||||||
- 时间段:今日/本周/本月
|
|
||||||
- 总支出、总收入、结余
|
|
||||||
|
|
||||||
### 分类快捷统计(横向滚动)
|
|
||||||
- 图标 + 分类名 + 金额 + 占比
|
|
||||||
|
|
||||||
### 账单列表
|
|
||||||
- 分类图标
|
|
||||||
- 分类名称
|
|
||||||
- 备注(可选)
|
|
||||||
- 金额(支出红、收入绿)
|
|
||||||
- 日期
|
|
||||||
- 左滑删、右滑快速修改
|
|
||||||
|
|
||||||
### 记账弹窗(强功能极简操作)
|
|
||||||
- 类型:支出 / 收入
|
|
||||||
- 金额:数字键盘
|
|
||||||
- 分类:预设16个 + 支持自定义
|
|
||||||
- 备注:选填
|
|
||||||
- 日期:默认今天,可改
|
|
||||||
- 保存
|
|
||||||
|
|
||||||
### 高级功能
|
|
||||||
1. **预算功能**
|
|
||||||
月预算,超支标红、进度条提醒
|
|
||||||
2. **自定义分类**
|
|
||||||
名称 + 图标 + 颜色
|
|
||||||
3. **转账记录**
|
|
||||||
账户之间转账,不影响收支统计
|
|
||||||
4. **按月自动归档**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Page 4:我的(设置+统计+数据)
|
|
||||||
### 模块 A:月度统计(卡片)
|
|
||||||
- 总支出、总收入、预算剩余
|
|
||||||
- 完成任务数、完成率
|
|
||||||
- 简约环形图
|
|
||||||
|
|
||||||
### 模块 B:数据管理
|
|
||||||
- 导出数据(Excel / 文本)
|
|
||||||
- 清空所有数据(二次确认)
|
|
||||||
- 数据同步状态
|
|
||||||
|
|
||||||
### 模块 C:个性化
|
|
||||||
- 主题:浅色 / 深色 / 自动
|
|
||||||
- 列表样式:紧凑/舒适
|
|
||||||
- 动画开关
|
|
||||||
|
|
||||||
### 模块 D:偏好设置
|
|
||||||
- 待办未完成自动顺延
|
|
||||||
- 重复任务提醒
|
|
||||||
- 预算超支提醒
|
|
||||||
- 首页默认显示日期
|
|
||||||
|
|
||||||
### 模块 E:其他
|
|
||||||
- 关于
|
|
||||||
- 版本号
|
|
||||||
- 清理缓存
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 四、数据库结构(可直接建表)
|
|
||||||
## 1. tasks(待办)
|
|
||||||
- _id
|
|
||||||
- openid(用户唯一标识)
|
|
||||||
- title(标题)
|
|
||||||
- content(备注)
|
|
||||||
- cate(分类:work/life/study/custom)
|
|
||||||
- priority(1高/2中/3低)
|
|
||||||
- repeat(none/day/week/month)
|
|
||||||
- done(true/false)
|
|
||||||
- subtasks(数组:[{title,done}])
|
|
||||||
- date(yyyy-MM-dd)
|
|
||||||
- createTime
|
|
||||||
- updateTime
|
|
||||||
|
|
||||||
## 2. bills(账单)
|
|
||||||
- _id
|
|
||||||
- openid
|
|
||||||
- type(income/expend)
|
|
||||||
- money(数字,正数)
|
|
||||||
- cate(分类)
|
|
||||||
- icon(图标)
|
|
||||||
- color(颜色)
|
|
||||||
- note(备注)
|
|
||||||
- date(yyyy-MM-dd)
|
|
||||||
- createTime
|
|
||||||
|
|
||||||
## 3. userConfig(用户配置)
|
|
||||||
- _id
|
|
||||||
- openid
|
|
||||||
- theme(light/dark/auto)
|
|
||||||
- budget(月预算)
|
|
||||||
- autoPostpone(自动顺延)
|
|
||||||
- showAnimation(动画)
|
|
||||||
- customCate(自定义分类数组)
|
|
||||||
- lastBackup(最后备份时间)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 五、核心业务逻辑(完整闭环)
|
|
||||||
1. **任务自动顺延**
|
|
||||||
每天0点,未完成、非重复、非已完成 → 复制到今日,标记顺延
|
|
||||||
2. **重复任务生成**
|
|
||||||
每日/每周/每月自动生成下一条任务,不修改原任务
|
|
||||||
3. **预算计算**
|
|
||||||
月支出总和对比预算,超支变红、进度条>100%
|
|
||||||
4. **数据权限**
|
|
||||||
云数据库权限只允许 openid 等于自身读写,绝对安全
|
|
||||||
5. **统计口径**
|
|
||||||
按自然月统计,不跨月混淆
|
|
||||||
6. **操作无刷新**
|
|
||||||
所有增删改查本地状态同步 + 云更新,不白屏、不重载
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 六、交互体验(简约但高级)
|
|
||||||
- 所有按钮有反馈
|
|
||||||
- 完成任务有轻微动效(不花哨)
|
|
||||||
- 列表滑动流畅
|
|
||||||
- 键盘自动收起
|
|
||||||
- 加载状态弱提示
|
|
||||||
- 错误提示轻量不打扰
|
|
||||||
- 无任何广告、无推送、无外链
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 七、产品亮点(真正「简约不简单」)
|
|
||||||
- 界面极简,功能专业
|
|
||||||
- 待办 + 记账深度融合,不割裂
|
|
||||||
- 重复任务、子任务、顺延、预算、图表、导出全有
|
|
||||||
- 单人可开发、可维护、可上线
|
|
||||||
- 无第三方依赖、无封闭资源、全自主
|
|
||||||
- 个人主体可过审
|
|
||||||
- 自用质感极强,可长期使用
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
如果你需要,我可以**下一步直接输出:**
|
|
||||||
1. 全套**小程序页面目录结构**
|
|
||||||
2. 每个页面的**WXML + WXSS + JS 骨架代码**
|
|
||||||
3. **云数据库创建脚本**
|
|
||||||
4. **全局工具函数(请求、日期、格式化、统计)**
|
|
||||||
5. **可直接复制使用的配色+字体+样式规范**
|
|
||||||
|
|
||||||
要我继续把**可直接开发的完整技术原型**一次性给全吗?
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
# 简记memo
|
|
||||||
## 完整版 UI 视觉设计规范 + 界面细节
|
|
||||||
**风格:清新极简 · 高级轻质感 · 温柔耐看 · 2026主流审美**
|
|
||||||
**定位:无冗余、高舒适、强精致、单人可还原**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 一、全局视觉规范(统一高级感)
|
|
||||||
## 1. 配色方案(清新治愈系)
|
|
||||||
### 主色
|
|
||||||
- **主色:#4CB8C6**(青薄荷绿,温柔干净)
|
|
||||||
- **强调色:#FF8A65**(暖珊瑚,不艳不刺眼)
|
|
||||||
|
|
||||||
### 中性色
|
|
||||||
- 背景:#F9FBFC
|
|
||||||
- 卡片:#FFFFFF
|
|
||||||
- 分割线:#EAEEF2
|
|
||||||
- 文字深:#2A3A4D
|
|
||||||
- 文字中:#5E6D7F
|
|
||||||
- 文字浅:#9AA4B2
|
|
||||||
|
|
||||||
### 功能色
|
|
||||||
- 支出红:#FF6B6B
|
|
||||||
- 收入绿:#2EC975
|
|
||||||
- 完成灰绿:#81C99B
|
|
||||||
- 高优:#FF6B6B
|
|
||||||
- 中优:#4CB8C6
|
|
||||||
- 低优:#9AA4B2
|
|
||||||
|
|
||||||
### 深色模式
|
|
||||||
- 背景:#121A24
|
|
||||||
- 卡片:#1E2734
|
|
||||||
- 文字:#E1E8F0
|
|
||||||
- 主色不变:#4CB8C6
|
|
||||||
|
|
||||||
## 2. 字体
|
|
||||||
- 标题:18 / 20px,600
|
|
||||||
- 正文:16px,400
|
|
||||||
- 小字:14px,400
|
|
||||||
- 辅助文字:12px,400
|
|
||||||
- 无衬线、无花字体、极度干净
|
|
||||||
|
|
||||||
## 3. 圆角 & 间距
|
|
||||||
- 全局圆角:**14px**
|
|
||||||
- 卡片间距:12px
|
|
||||||
- 页面左右边距:16px
|
|
||||||
- 呼吸感极强,不拥挤
|
|
||||||
|
|
||||||
## 4. 图标
|
|
||||||
- 线性极简图标
|
|
||||||
- 2px线条、无填充、统一风格
|
|
||||||
- 不花哨、不幼稚
|
|
||||||
|
|
||||||
## 5. 动效
|
|
||||||
- 轻柔 250ms 缓动
|
|
||||||
- 完成 → 轻微缩放消失
|
|
||||||
- 弹窗 → 底部滑出
|
|
||||||
- 无抖动、无浮夸
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 二、4大页面 UI 完整设计(逐页精细描述)
|
|
||||||
## 页面1:今日 · 聚合页(视觉最美)
|
|
||||||
**顶部**
|
|
||||||
- 居中日期:`3月31日 星期一`
|
|
||||||
- 左右淡色箭头,轻质感
|
|
||||||
- 无标题栏、无 LOGO、极度干净
|
|
||||||
|
|
||||||
**今日待办卡片**
|
|
||||||
- 白卡片、14px圆角
|
|
||||||
- 顶部:待办进度条(极细、柔和、主色填充)
|
|
||||||
- 文字:`今日待办 2/5 已完成`
|
|
||||||
- 列表:
|
|
||||||
圆形勾选框 → 淡灰 → 完成变主色
|
|
||||||
标题:#2A3A4D
|
|
||||||
优先级小圆点(左)
|
|
||||||
无多余装饰
|
|
||||||
|
|
||||||
**今日记账卡片**
|
|
||||||
- 布局:左支出、右收入
|
|
||||||
- 数字大字号、清新醒目
|
|
||||||
- 支出红、收入绿、柔和不刺眼
|
|
||||||
- 账单列表:
|
|
||||||
分类小图标 + 名称 + 金额右对齐
|
|
||||||
极简分割线
|
|
||||||
|
|
||||||
**底部悬浮按钮(双按钮)**
|
|
||||||
- 左:+ 记一笔(主色)
|
|
||||||
- 右:+ 待办(浅主色)
|
|
||||||
- 圆角、轻微阴影、悬浮感
|
|
||||||
|
|
||||||
**整体气质**
|
|
||||||
- 像一本干净手账本
|
|
||||||
- 留白充足、温柔治愈、高级耐看
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 页面2:待办 · 高级任务页
|
|
||||||
**顶部**
|
|
||||||
- 搜索框:浅灰背景、无边框、圆角、放大镜图标
|
|
||||||
- 标签栏:全部 / 未完成 / 已完成
|
|
||||||
选中 → 主色背景、白字
|
|
||||||
未选中 → 浅灰底、深灰字
|
|
||||||
|
|
||||||
**任务列表**
|
|
||||||
- 每条高度舒适、不拥挤
|
|
||||||
- 左侧优先级圆点
|
|
||||||
- 标题加粗、备注灰色小字
|
|
||||||
- 右侧:完成勾选框
|
|
||||||
- 重复任务 → 小循环图标
|
|
||||||
- 子任务 → 缩进灰色细线
|
|
||||||
|
|
||||||
**长按进入批量模式**
|
|
||||||
- 复选框出现
|
|
||||||
- 底部工具栏:完成 / 删除 / 改日期
|
|
||||||
- 半透明浮层
|
|
||||||
|
|
||||||
**空状态**
|
|
||||||
- 中央极简线条图标
|
|
||||||
- 文字:`今天也要认真生活呀`
|
|
||||||
- 颜色柔和、不压抑
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 页面3:账单 · 清新干净收支页
|
|
||||||
**顶部**
|
|
||||||
- 切换按钮:支出 / 收入 / 全部
|
|
||||||
- 风格同待办、统一视觉
|
|
||||||
|
|
||||||
**统计栏**
|
|
||||||
- 本月支出:数字大、醒目
|
|
||||||
- 预算进度条(柔和、不刺眼)
|
|
||||||
- 超支变珊瑚色、正常主色
|
|
||||||
|
|
||||||
**分类横向栏(可滑动)**
|
|
||||||
- 小图标 + 名称 + 金额
|
|
||||||
- 选中:主色圆角背景
|
|
||||||
- 未选中:浅灰透明
|
|
||||||
|
|
||||||
**账单列表**
|
|
||||||
- 左:图标圆角浅底色块
|
|
||||||
- 中:分类名 + 备注小字
|
|
||||||
- 右:金额(右对齐)
|
|
||||||
- 无多余线条、干净高级
|
|
||||||
|
|
||||||
**记账弹窗(底部滑出)**
|
|
||||||
- 金额数字键盘大按钮
|
|
||||||
- 分类宫格布局(3×4)
|
|
||||||
- 图标清新、不花俏
|
|
||||||
- 备注输入框极简无边框
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 页面4:我的 · 高级质感设置页
|
|
||||||
**顶部用户栏**
|
|
||||||
- 无头像、无昵称
|
|
||||||
- 文字:`简记memo`
|
|
||||||
|
|
||||||
**月度统计卡片**
|
|
||||||
- 双环形统计图(极细、清新)
|
|
||||||
- 左边:账单统计
|
|
||||||
- 右边:待办完成率
|
|
||||||
- 数字大、文字小、高级感
|
|
||||||
|
|
||||||
**功能菜单(列表)**
|
|
||||||
- 左图标、右文字、右箭头
|
|
||||||
- 每行高度舒适
|
|
||||||
- 无分割线、用间距区分
|
|
||||||
|
|
||||||
菜单结构:
|
|
||||||
- 数据导出
|
|
||||||
- 月度预算
|
|
||||||
- 主题切换(浅色/深色/自动)
|
|
||||||
- 待办自动顺延
|
|
||||||
- 分类自定义
|
|
||||||
- 清空数据
|
|
||||||
- 关于与版本
|
|
||||||
|
|
||||||
**底部**
|
|
||||||
- 小号灰色文字:极简生活 · 简单记录
|
|
||||||
- 无广告、无推荐、无多余信息
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 三、交互 UI 细节(惊艳但克制)
|
|
||||||
- 勾选完成 → 圆形填色 + 文字微淡出
|
|
||||||
- 切换日期 → 左右平滑滑动
|
|
||||||
- 预算不足 → 数字渐变珊瑚色
|
|
||||||
- 悬浮按钮按压 → 轻微缩小
|
|
||||||
- 弹窗从底部平滑弹出,遮罩渐变
|
|
||||||
- 左滑删除 → 红色按钮、圆角
|
|
||||||
- 所有可点区域 ≥44px
|
|
||||||
- 无锯齿、无生硬、无杂乱
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 四、UI 核心亮点(真正好看+高级+清新)
|
|
||||||
- 颜色:青薄荷 + 暖白,2026最流行清新风
|
|
||||||
- 无冗余元素,每一处都有意义
|
|
||||||
- 卡片呼吸感强,像苹果/Notion 风格
|
|
||||||
- 深浅双主题自动适配
|
|
||||||
- 文字层级清晰,阅读极舒适
|
|
||||||
- 统一圆角、统一间距、极度规整
|
|
||||||
- 温柔、安静、高级、不油腻
|
|
||||||
- 单人开发完全可还原
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 五、下一步我直接给你「可直接开发」的内容
|
|
||||||
你只要说一声,我立刻一次性完整输出:
|
|
||||||
1. 全局 **app.wxss 样式规范**(直接复制)
|
|
||||||
2. 4个页面 **UI 完整 wxml 结构**
|
|
||||||
3. 统一图标库、颜色常量、class 类名
|
|
||||||
4. 自适应 + 深色模式代码
|
|
||||||
5. 所有卡片、按钮、输入框、弹框样式
|
|
||||||
|
|
||||||
要我现在直接给你**全套可落地的 UI 代码**吗?
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
# 简记 · 成长等级特权系统方案
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 一、方案定位
|
|
||||||
|
|
||||||
### 核心理念
|
|
||||||
> **"限制现有功能 = 伤害用户体验,增值服务 = 激励用户成长"**
|
|
||||||
|
|
||||||
### 设计原则
|
|
||||||
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 保持一致
|
|
||||||
**设计理念**: "限制 = 伤害,激励 = 成长"
|
|
||||||
@@ -1,670 +0,0 @@
|
|||||||
# 成长等级系统规则文档
|
|
||||||
|
|
||||||
## 一、系统概述
|
|
||||||
|
|
||||||
成长等级系统是激励用户持续使用应用的核心机制,通过记录用户的日常行为(任务完成、记账、心情记录)来累计经验值,提升等级,解锁成就和特权。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、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`
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
# 🌱 成长之路 - 用户指南
|
|
||||||
|
|
||||||
欢迎来到成长系统!在这里,你的每一次记录都在积累力量,让我们一起看看如何快速成长吧~
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 我的等级
|
|
||||||
|
|
||||||
### 当前等级: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月*
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
# 「简记 · 事与钱」小程序 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 统一样式
|
|
||||||
|
|
||||||
需要我直接把**可马上开发的完整技术原型**全部给你吗?
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
// 全局变量包:存放项目所有全局单例
|
|
||||||
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()))
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,594 +0,0 @@
|
|||||||
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=
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
// 请求日志中间件:记录请求ID、路由、客户端IP等关键信息
|
|
||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"simple-memo/global"
|
|
||||||
"simple-memo/utils"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/sony/sonyflake"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// requestIDSF 用于生成全局唯一的请求ID
|
|
||||||
var requestIDSF *sonyflake.Sonyflake
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
requestIDSF = sonyflake.NewSonyflake(sonyflake.Settings{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// genRequestID 基于 sonyflake 生成请求ID(base36 紧凑字符串)
|
|
||||||
func genRequestID() string {
|
|
||||||
if requestIDSF == nil {
|
|
||||||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
|
||||||
}
|
|
||||||
id, err := requestIDSF.NextID()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
|
||||||
}
|
|
||||||
// base36 编码:紧凑且只含 [0-9a-z]
|
|
||||||
return strconv.FormatUint(id, 36)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequestLog 请求日志中间件
|
|
||||||
func RequestLog() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
// 优先从请求头获取 request-id,否则自动生成
|
|
||||||
requestID := c.GetHeader("X-Request-ID")
|
|
||||||
if requestID == "" {
|
|
||||||
requestID = genRequestID()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 客户端IP
|
|
||||||
clientIP := c.ClientIP()
|
|
||||||
|
|
||||||
// 路由路径
|
|
||||||
route := c.Request.URL.Path
|
|
||||||
|
|
||||||
// 请求方法
|
|
||||||
method := c.Request.Method
|
|
||||||
|
|
||||||
// 写入标准 context,供整条链路使用
|
|
||||||
ctx := context.WithValue(c.Request.Context(), utils.RequestIdKey, requestID)
|
|
||||||
ctx = context.WithValue(ctx, utils.RequestRouteKey, route)
|
|
||||||
ctx = context.WithValue(ctx, utils.ClientIPKey, clientIP)
|
|
||||||
c.Request = c.Request.WithContext(ctx)
|
|
||||||
|
|
||||||
// 同时记录到 gin context,兼容老代码
|
|
||||||
c.Set("request_id", requestID)
|
|
||||||
c.Set("client_ip", clientIP)
|
|
||||||
c.Set("route", route)
|
|
||||||
|
|
||||||
// 打印请求进入日志
|
|
||||||
global.Logger.Info("【请求进入】",
|
|
||||||
zap.String("request_id", requestID),
|
|
||||||
zap.String("route", route),
|
|
||||||
zap.String("method", method),
|
|
||||||
zap.String("client_ip", clientIP),
|
|
||||||
zap.String("user_agent", c.Request.UserAgent()),
|
|
||||||
)
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
|
|
||||||
// 请求结束后打印耗时日志
|
|
||||||
duration := time.Since(start)
|
|
||||||
global.Logger.Info("【请求结束】",
|
|
||||||
zap.String("request_id", requestID),
|
|
||||||
zap.String("route", route),
|
|
||||||
zap.Int("status", c.Writer.Status()),
|
|
||||||
zap.Duration("duration", duration),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// 账单表: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"`
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// 预算表: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"`
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
// 分类表: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"`
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
// 每日进度表: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"`
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
// 经验变化日志表: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"`
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
// 心情表: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"
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
// 心情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"
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
// 复盘表: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"
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// 待办表: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"`
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
// 用户表: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"`
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// 用户配置表: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"`
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// 用户成就表: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"`
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// 用户成长表: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"`
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
// 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 智能分类
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// 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"))
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// 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"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 120 KiB |
@@ -1,301 +0,0 @@
|
|||||||
/* ===================================
|
|
||||||
简记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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
}
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
/* ===================================
|
|
||||||
简记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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
/* ===================================
|
|
||||||
简记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;
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,509 +0,0 @@
|
|||||||
<!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>
|
|
||||||
|
Before Width: | Height: | Size: 223 KiB |
@@ -1,172 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
// 公共头部组件渲染函数
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
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()">×</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 = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||