diff --git a/backend/src/api/notebook.js b/backend/src/api/notebook.js new file mode 100644 index 0000000..44d7416 --- /dev/null +++ b/backend/src/api/notebook.js @@ -0,0 +1,39 @@ +import request from '@/utils/request' + +export function getNotebookList(params) { + return request({ + url: '/backend/notebook/list', + method: 'get', + params + }) +} + +export function getNotebookDetail(id) { + return request({ + url: `/backend/notebook/detail/${id}`, + method: 'get' + }) +} + +export function createNotebook(data) { + return request({ + url: '/backend/notebook/create', + method: 'post', + data + }) +} + +export function updateNotebook(id, data) { + return request({ + url: `/backend/notebook/update/${id}`, + method: 'post', + data + }) +} + +export function deleteNotebook(id) { + return request({ + url: `/backend/notebook/delete/${id}`, + method: 'delete' + }) +} diff --git a/backend/src/api/reminder.js b/backend/src/api/reminder.js new file mode 100644 index 0000000..d1aee6d --- /dev/null +++ b/backend/src/api/reminder.js @@ -0,0 +1,62 @@ +import request from '@/utils/request' + +export function getReminderList(params) { + return request({ + url: '/backend/reminder/list', + method: 'get', + params + }) +} + +export function getReminderDetail(id) { + return request({ + url: `/backend/reminder/${id}`, + method: 'get' + }) +} + +export function createReminder(data) { + return request({ + url: '/backend/reminder', + method: 'post', + data + }) +} + +export function updateReminder(id, data) { + return request({ + url: `/backend/reminder/${id}`, + method: 'put', + data + }) +} + +export function deleteReminder(id) { + return request({ + url: `/backend/reminder/${id}`, + method: 'delete' + }) +} + +export function batchDeleteReminder(data) { + return request({ + url: '/backend/reminder/batchDelete', + method: 'post', + data + }) +} + +export function finishReminder(id) { + return request({ + url: `/backend/reminder/finish/${id}`, + method: 'post' + }) +} + +export function testReminder(data) { + return request({ + url: '/backend/reminder/test', + method: 'post', + data + }) +} diff --git a/backend/src/views/system/permissions/index.vue b/backend/src/views/system/permissions/index.vue index 5950f51..fdf7840 100644 --- a/backend/src/views/system/permissions/index.vue +++ b/backend/src/views/system/permissions/index.vue @@ -167,7 +167,7 @@ import { Select, RefreshLeft, } from '@element-plus/icons-vue'; -import { getRoleByTenantId, getAllRoles } from '@/api/role'; +import { getAllRoles } from '@/api/role'; import { getAllMenuPermissions, getRolePermissions, diff --git a/backend/src/views/tools/notebook/README.md b/backend/src/views/tools/notebook/README.md new file mode 100644 index 0000000..393a9a0 --- /dev/null +++ b/backend/src/views/tools/notebook/README.md @@ -0,0 +1,247 @@ +# 记事本模块使用说明 + +## 功能概述 + +这是一个完整的记事本应用模块,支持富文本编辑、笔记管理等功能。 + +## 目录结构 + +``` +notebook/ +├── index.vue # 主页面(列表+编辑器) +├── components/ +│ ├── edit.vue # 编辑器组件 +│ └── TiptapEditor.vue # Tiptap富文本编辑器(已全局注册) +└── README.md # 说明文档 +``` + +## 数据库 + +### 表名 +`yz_platform_notebook` + +### 表结构 +- `id` - 主键ID +- `title` - 笔记标题 +- `content` - 笔记内容(HTML格式) +- `user_id` - 创建用户ID +- `user_name` - 创建用户名 +- `is_deleted` - 是否删除(0-否 1-是) +- `create_time` - 创建时间 +- `update_time` - 更新时间 +- `delete_time` - 删除时间 + +### SQL文件位置 +`sql/yz_platform_notebook.sql` + +## 后端接口 + +### 模型文件 +`go/models/platform_notebook.go` + +### 控制器文件 +`go/controllers/platform_notebook.go` + +### API端点 + +1. **获取笔记列表** + - URL: `GET /platform/notebook/list` + - 参数: + - `page`: 页码(默认1) + - `pageSize`: 每页数量(默认20,最大100) + - `keyword`: 搜索关键词(可选) + - 返回: 笔记列表和总数 + +2. **获取笔记详情** + - URL: `GET /platform/notebook/detail/:id` + - 参数: `id` - 笔记ID + - 返回: 笔记详细信息 + +3. **创建笔记** + - URL: `POST /platform/notebook/create` + - 参数: + ```json + { + "title": "笔记标题", + "content": "

笔记内容

" + } + ``` + - 返回: 创建的笔记信息 + +4. **更新笔记** + - URL: `POST /platform/notebook/update/:id` + - 参数: + ```json + { + "title": "更新的标题", + "content": "

更新的内容

" + } + ``` + - 返回: 更新后的笔记信息 + +5. **删除笔记** + - URL: `DELETE /platform/notebook/delete/:id` + - 参数: `id` - 笔记ID + - 返回: 删除结果 + +## 前端API + +### API文件 +`platform/src/api/notebook.js` + +### 方法说明 + +```javascript +// 获取笔记列表 +getNotebookList({ page, pageSize, keyword }) + +// 获取笔记详情 +getNotebookDetail(id) + +// 创建笔记 +createNotebook({ title, content }) + +// 更新笔记 +updateNotebook(id, { title, content }) + +// 删除笔记 +deleteNotebook(id) +``` + +## 使用步骤 + +### 1. 初始化数据库 +```bash +# 在MySQL中执行SQL文件 +mysql -u用户名 -p数据库名 < sql/yz_platform_notebook.sql +``` + +### 2. 启动后端服务 +后端已自动注册模型和路由,直接启动即可: +```bash +cd go +go run main.go +``` + +### 3. 访问前端 +在浏览器中访问笔记本页面(路由需要在菜单中配置) + +## 功能特性 + +### 列表功能 +- ✅ 显示所有笔记 +- ✅ 搜索笔记(按标题) +- ✅ 创建新笔记 +- ✅ 删除笔记 +- ✅ 查看笔记预览 +- ✅ 显示更新时间 + +### 编辑器功能 +- ✅ 富文本编辑(基于Tiptap) +- ✅ 标题编辑 +- ✅ 内容编辑 +- ✅ 保存笔记 +- ✅ 自动识别新建/编辑模式 +- ✅ 加载状态显示 + +### TiptapEditor支持的功能 +- 文本样式(加粗、斜体、下划线等) +- 标题(H1-H6) +- 引用 +- 代码块 +- 有序/无序列表 +- 表格 +- 链接 +- 图片上传(需配置上传接口) +- 视频嵌入 + +## 权限说明 + +- 所有接口都需要平台用户登录(JWT Token验证) +- 每个用户只能查看、编辑、删除自己创建的笔记 +- 删除操作为软删除,数据仍保留在数据库中 + +## 扩展功能(可选) + +如需添加以下功能,可以扩展: + +1. **笔记分类** + - 添加分类表和分类字段 + - 支持笔记分类管理 + +2. **笔记标签** + - 添加标签表和关联表 + - 支持多标签筛选 + +3. **笔记分享** + - 添加分享链接生成功能 + - 支持公开/私密设置 + +4. **笔记导出** + - 支持导出为PDF + - 支持导出为Markdown + +5. **版本历史** + - 记录笔记的修改历史 + - 支持版本回退 + +6. **协作编辑** + - 支持多人协作编辑 + - 实时同步功能 + +## 注意事项 + +1. 图片上传需要配置文件上传接口 +2. 富文本内容存储为HTML格式,注意XSS防护 +3. 数据库content字段使用longtext类型,支持大容量内容 +4. 建议定期清理软删除的数据 +5. 生产环境建议添加内容审核机制 + +## 技术栈 + +- **前端**: Vue 3 + Element Plus + Tiptap +- **后端**: Go + Beego + MySQL +- **编辑器**: Tiptap (基于ProseMirror) + +## 开发调试 + +### 前端调试 +```bash +cd platform +npm run dev +``` + +### 后端调试 +```bash +cd go +go run main.go +``` + +### 查看API请求 +打开浏览器开发者工具 -> Network 标签页,查看API请求和响应 + +## 问题排查 + +1. **笔记列表为空** + - 检查数据库表是否创建成功 + - 检查用户是否已登录 + - 查看浏览器Console是否有错误 + +2. **保存失败** + - 检查JWT Token是否有效 + - 检查标题是否为空 + - 查看后端日志 + +3. **编辑器显示异常** + - 检查Tiptap是否正确安装 + - 查看浏览器Console错误信息 + - 检查CSS样式是否正确加载 + +## 更新日志 + +### v1.0.0 (2024) +- ✅ 完成基础功能 +- ✅ 支持笔记CRUD操作 +- ✅ 集成Tiptap富文本编辑器 +- ✅ 响应式设计支持 +- ✅ 暗色模式支持 diff --git a/backend/src/views/tools/notebook/components/edit.vue b/backend/src/views/tools/notebook/components/edit.vue new file mode 100644 index 0000000..1452af7 --- /dev/null +++ b/backend/src/views/tools/notebook/components/edit.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/backend/src/views/tools/notebook/index.vue b/backend/src/views/tools/notebook/index.vue new file mode 100644 index 0000000..6252f37 --- /dev/null +++ b/backend/src/views/tools/notebook/index.vue @@ -0,0 +1,379 @@ + + + + + diff --git a/backend/src/views/tools/reminder/components/detail.vue b/backend/src/views/tools/reminder/components/detail.vue new file mode 100644 index 0000000..f1ba278 --- /dev/null +++ b/backend/src/views/tools/reminder/components/detail.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/backend/src/views/tools/reminder/components/edit.vue b/backend/src/views/tools/reminder/components/edit.vue new file mode 100644 index 0000000..d6f211b --- /dev/null +++ b/backend/src/views/tools/reminder/components/edit.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/backend/src/views/tools/reminder/index.vue b/backend/src/views/tools/reminder/index.vue new file mode 100644 index 0000000..81f1945 --- /dev/null +++ b/backend/src/views/tools/reminder/index.vue @@ -0,0 +1,359 @@ + + + + + diff --git a/backend/vite.config.js b/backend/vite.config.js index 12ca94e..64a4864 100644 --- a/backend/vite.config.js +++ b/backend/vite.config.js @@ -22,6 +22,6 @@ export default defineConfig({ }, }, server: { - port: 5000, + port: 4001, }, }); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0d680b2..73f45fa 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -50,7 +50,7 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => { // 开发服务器配置 server: { host: '0.0.0.0', // 监听所有地址 - port: 3000, // 默认端口 + port: 4002, // 默认端口 open: true, // 自动打开浏览器 cors: true, // 启用 CORS strictPort: false, // 端口被占用时尝试其他端口 diff --git a/go/controllers/app_activity.go b/go/controllers/app_activity.go new file mode 100644 index 0000000..d75e34a --- /dev/null +++ b/go/controllers/app_activity.go @@ -0,0 +1,178 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "server/models" + "server/pkg/jwtutil" + "strings" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +type AppActivityController struct { + beego.Controller +} + +func (c *AppActivityController) activityClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, orm.ErrNoRows + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, orm.ErrNoRows + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, err + } + if claims.UserType != "backend" && claims.UserType != "app" && claims.UserType != "platform" { + return nil, orm.ErrNoRows + } + return claims, nil +} + +// GetList GET /app/activity/list +func (c *AppActivityController) GetList() { + claims, err := c.activityClaims() + if err != nil { + c.Ctx.Output.SetStatus(401) + c.Data["json"] = map[string]interface{}{"code": 401, "msg": "未登录"} + _ = c.ServeJSON() + return + } + + limit, _ := c.GetInt("limit", 10) + if limit < 1 || limit > 50 { + limit = 10 + } + + var logs []models.SystemOperationLog + qs := models.Orm.QueryTable(new(models.SystemOperationLog)). + Filter("user_id", claims.UserID). + Exclude("action__in", "登录", "退出", "查询"). + OrderBy("-create_time"). + Limit(limit) + _, err = qs.All(&logs) + if err != nil && err != orm.ErrNoRows { + c.Ctx.Output.SetStatus(500) + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败"} + _ = c.ServeJSON() + return + } + + type activityItem struct { + ID uint64 `json:"id"` + Action string `json:"action"` + TargetType string `json:"target_type"` + Title string `json:"title"` + URL string `json:"url"` + CreatedAt string `json:"created_at"` + } + + list := make([]activityItem, 0, len(logs)) + for _, l := range logs { + module := l.Module + action := l.Action + + // 从请求体中提取标题 + title := extractTitle(l.Module, l.Action, l.RequestData) + if title == "" { + title = fmt.Sprintf("您%s了:%s", actionLabel(action), moduleLabel(module)) + } + + list = append(list, activityItem{ + ID: l.ID, + Action: action, + TargetType: module, + Title: title, + URL: l.URL, + CreatedAt: l.CreateTime.Format("2006-01-02 15:04:05"), + }) + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": list} + _ = c.ServeJSON() +} + +// extractTitle 从请求数据中提取可读标题 +func extractTitle(module, action string, reqData *string) string { + if reqData == nil { + return "" + } + data := strings.TrimSpace(*reqData) + if data == "" { + return "" + } + + // 解析 JSON 请求体 + var body map[string]interface{} + if err := json.Unmarshal([]byte(data), &body); err != nil { + return "" + } + + moduleCN := moduleLabel(module) + actionCN := actionLabel(action) + + // 笔记本:取 title + if module == "notebook" { + if t, ok := body["title"].(string); ok && t != "" { + return fmt.Sprintf("您%s了%s:【%s】", actionCN, moduleCN, truncateStr(t, 20)) + } + } + + // 日程:取 content 第一行 + if module == "schedule" { + if c, ok := body["content"].(string); ok && c != "" { + firstLine := strings.SplitN(c, "\n", 2)[0] + return fmt.Sprintf("您%s了%s:【%s】", actionCN, moduleCN, truncateStr(firstLine, 20)) + } + } + + return fmt.Sprintf("您%s了:%s", actionCN, moduleCN) +} + +func truncateStr(s string, maxLen int) string { + s = strings.TrimSpace(s) + if len([]rune(s)) > maxLen { + return string([]rune(s)[:maxLen]) + "..." + } + return s +} + +func moduleLabel(m string) string { + switch m { + case "notebook": + return "记事本" + case "schedule": + return "日程提醒" + case "erp": + return "ERP" + case "file": + return "文件" + case "article": + return "文章" + default: + if m == "" { + return "系统" + } + return m + } +} + +func actionLabel(a string) string { + switch a { + case "新增": + return "新增" + case "编辑": + return "编辑" + case "删除": + return "删除" + case "提交": + return "操作" + default: + return a + } +} diff --git a/go/controllers/app_notebook.go b/go/controllers/app_notebook.go new file mode 100644 index 0000000..8246223 --- /dev/null +++ b/go/controllers/app_notebook.go @@ -0,0 +1,376 @@ +package controllers + +import ( + "encoding/json" + "io" + "server/middleware" + "server/models" + "server/pkg/jwtutil" + "strconv" + "strings" + "time" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +type AppNotebookController struct { + beego.Controller +} + +func (c *AppNotebookController) appNotebookClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, orm.ErrNoRows + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, orm.ErrNoRows + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, err + } + if claims.UserType != "backend" && claims.UserType != "app" && claims.UserType != "platform" { + return nil, orm.ErrNoRows + } + return claims, nil +} + +func (c *AppNotebookController) nbJsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *AppNotebookController) nbOk(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +// GetList GET /app/notebook/list +func (c *AppNotebookController) GetList() { + claims, err := c.appNotebookClaims() + if err != nil { + c.nbJsonErr(401, 401, "未登录或无权限") + return + } + + keyword := strings.TrimSpace(c.GetString("keyword")) + + qs := models.Orm.QueryTable(new(models.BackendNotebook)). + Filter("is_deleted", 0). + Filter("user_id", claims.UserID) + + if keyword != "" { + qs = qs.Filter("title__icontains", keyword) + } + + var list []models.BackendNotebook + _, err = qs.OrderBy("-pinned", "-update_time").All(&list) + if err != nil && err != orm.ErrNoRows { + c.nbJsonErr(500, 500, "查询失败") + return + } + if list == nil { + list = []models.BackendNotebook{} + } + + type noteItem struct { + ID uint64 `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Pinned bool `json:"pinned"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } + + result := make([]noteItem, 0, len(list)) + for _, n := range list { + item := noteItem{ + ID: n.ID, + Title: n.Title, + Content: n.Content, + Pinned: n.Pinned == 1, + } + item.CreatedAt = n.CreateTime.Format("2006-01-02 15:04:05") + if n.UpdateTime != nil { + item.UpdatedAt = n.UpdateTime.Format("2006-01-02 15:04:05") + } else { + item.UpdatedAt = item.CreatedAt + } + result = append(result, item) + } + + c.nbOk(map[string]interface{}{ + "list": result, + "total": len(result), + }) +} + +// GetDetail GET /app/notebook/:id +func (c *AppNotebookController) GetDetail() { + claims, err := c.appNotebookClaims() + if err != nil { + c.nbJsonErr(401, 401, "未登录或无权限") + return + } + + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.nbJsonErr(400, 400, "无效ID") + return + } + + var note models.BackendNotebook + err = models.Orm.QueryTable(new(models.BackendNotebook)). + Filter("id", id). + Filter("is_deleted", 0). + Filter("user_id", claims.UserID). + One(¬e) + if err != nil { + c.nbJsonErr(404, 404, "笔记不存在") + return + } + + createdAt := note.CreateTime.Format("2006-01-02 15:04:05") + updatedAt := createdAt + if note.UpdateTime != nil { + updatedAt = note.UpdateTime.Format("2006-01-02 15:04:05") + } + + c.nbOk(map[string]interface{}{ + "id": note.ID, + "title": note.Title, + "content": note.Content, + "pinned": note.Pinned == 1, + "created_at": createdAt, + "updated_at": updatedAt, + }) +} + +// Create POST /app/notebook +func (c *AppNotebookController) Create() { + claims, err := c.appNotebookClaims() + if err != nil { + c.nbJsonErr(401, 401, "未登录或无权限") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.nbJsonErr(400, 400, "参数错误") + return + } + var payload struct { + Title string `json:"title"` + Content string `json:"content"` + Pinned bool `json:"pinned"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + c.nbJsonErr(400, 400, "参数错误") + return + } + + title := strings.TrimSpace(payload.Title) + if title == "" { + title = "无标题" + } + + userID := uint64(claims.UserID) + pinned := int8(0) + if payload.Pinned { + pinned = 1 + } + + note := &models.BackendNotebook{ + Tid: claims.TenantId, + Title: title, + Content: payload.Content, + Pinned: pinned, + UserID: &userID, + UserName: &claims.Username, + IsDeleted: 0, + } + + id, err := models.Orm.Insert(note) + if err != nil { + c.nbJsonErr(500, 500, "创建失败") + return + } + + note.ID = uint64(id) + c.nbOk(map[string]interface{}{ + "id": note.ID, + "title": note.Title, + "content": note.Content, + "pinned": note.Pinned == 1, + }) +} + +// Update PUT /app/notebook/:id +func (c *AppNotebookController) Update() { + claims, err := c.appNotebookClaims() + if err != nil { + c.nbJsonErr(401, 401, "未登录或无权限") + return + } + + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.nbJsonErr(400, 400, "无效ID") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.nbJsonErr(400, 400, "参数错误") + return + } + var payload struct { + Title string `json:"title"` + Content string `json:"content"` + Pinned *bool `json:"pinned"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + c.nbJsonErr(400, 400, "参数错误") + return + } + + var note models.BackendNotebook + err = models.Orm.QueryTable(new(models.BackendNotebook)). + Filter("id", id). + Filter("is_deleted", 0). + Filter("user_id", claims.UserID). + One(¬e) + if err != nil { + c.nbJsonErr(404, 404, "笔记不存在") + return + } + + updateFields := map[string]interface{}{ + "update_time": time.Now(), + } + + if payload.Title != "" { + updateFields["title"] = strings.TrimSpace(payload.Title) + } + updateFields["content"] = payload.Content + if payload.Pinned != nil { + if *payload.Pinned { + updateFields["pinned"] = int8(1) + } else { + updateFields["pinned"] = int8(0) + } + } + + _, err = models.Orm.QueryTable(new(models.BackendNotebook)). + Filter("id", id). + Update(updateFields) + if err != nil { + c.nbJsonErr(500, 500, "更新失败") + return + } + + now := time.Now() + createdAt := note.CreateTime.Format("2006-01-02 15:04:05") + + c.nbOk(map[string]interface{}{ + "id": note.ID, + "title": updateFields["title"], + "content": payload.Content, + "pinned": payload.Pinned != nil && *payload.Pinned, + "created_at": createdAt, + "updated_at": now.Format("2006-01-02 15:04:05"), + }) +} + +// Delete DELETE /app/notebook/:id +func (c *AppNotebookController) Delete() { + claims, err := c.appNotebookClaims() + if err != nil { + c.nbJsonErr(401, 401, "未登录或无权限") + return + } + + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.nbJsonErr(400, 400, "无效ID") + return + } + + var note models.BackendNotebook + err = models.Orm.QueryTable(new(models.BackendNotebook)). + Filter("id", id). + Filter("is_deleted", 0). + Filter("user_id", claims.UserID). + One(¬e) + if err != nil { + c.nbJsonErr(404, 404, "笔记不存在") + return + } + + now := time.Now() + _, err = models.Orm.QueryTable(new(models.BackendNotebook)). + Filter("id", id). + Update(map[string]interface{}{ + "is_deleted": 1, + "delete_time": now, + }) + if err != nil { + c.nbJsonErr(500, 500, "删除失败") + return + } + + middleware.WriteDeleteLog(uint64(claims.UserID), claims.TenantId, "notebook", note.Title) + c.nbOk(nil) +} + +// TogglePin POST /app/notebook/:id/togglePin +func (c *AppNotebookController) TogglePin() { + claims, err := c.appNotebookClaims() + if err != nil { + c.nbJsonErr(401, 401, "未登录或无权限") + return + } + + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.nbJsonErr(400, 400, "无效ID") + return + } + + var note models.BackendNotebook + err = models.Orm.QueryTable(new(models.BackendNotebook)). + Filter("id", id). + Filter("is_deleted", 0). + Filter("user_id", claims.UserID). + One(¬e) + if err != nil { + c.nbJsonErr(404, 404, "笔记不存在") + return + } + + var newPinned int8 + if note.Pinned == 1 { + newPinned = 0 + } else { + newPinned = 1 + } + + now := time.Now() + _, err = models.Orm.QueryTable(new(models.BackendNotebook)). + Filter("id", id). + Update(map[string]interface{}{ + "pinned": newPinned, + "update_time": now, + }) + if err != nil { + c.nbJsonErr(500, 500, "操作失败") + return + } + + c.nbOk(map[string]interface{}{ + "pinned": newPinned == 1, + }) +} diff --git a/go/controllers/app_reminder.go b/go/controllers/app_reminder.go new file mode 100644 index 0000000..56579a0 --- /dev/null +++ b/go/controllers/app_reminder.go @@ -0,0 +1,586 @@ +package controllers + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "io" + "server/middleware" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + beego "github.com/beego/beego/v2/server/web" +) + +type AppReminderController struct { + beego.Controller +} + +func (c *AppReminderController) appClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "backend" && claims.UserType != "app" && claims.UserType != "platform" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *AppReminderController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *AppReminderController) ok(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +func appGenerateToken() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) +} + +type appSchedulePayload struct { + Content string `json:"content"` + ScheduleTime string `json:"schedule_time"` + RemindChannels []string `json:"remind_channels"` + AdvanceMinutes int `json:"advance_minutes"` + RepeatIntervalMinutes int `json:"repeat_interval_minutes"` + MaxSendCount int `json:"max_send_count"` +} + +// GetList GET /app/schedule/list +func (c *AppReminderController) GetList() { + claims, err := c.appClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 20) + keyword := c.GetString("keyword", "") + status := c.GetString("status", "") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + qs := models.Orm.QueryTable(new(models.PlatformSchedule)). + Filter("user_id", claims.UserID) + + if keyword != "" { + qs = qs.Filter("content__contains", keyword) + } + + total, _ := qs.Count() + + var schedules []models.PlatformSchedule + _, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&schedules) + if err != nil { + c.jsonErr(500, 500, "查询失败: "+err.Error()) + return + } + + list := make([]map[string]interface{}, 0, len(schedules)) + for _, s := range schedules { + var reminders []models.PlatformScheduleReminder + _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("schedule_id", s.ID). + Filter("is_deleted", 0). + All(&reminders) + + channels := make([]string, 0, len(reminders)) + isFinished := true + if len(reminders) == 0 { + isFinished = false + } else { + for _, r := range reminders { + channels = append(channels, r.RemindChannel) + if r.RemindStatus != 2 { + isFinished = false + } + } + } + + // status filter + if status == "pending" && isFinished { + continue + } + if status == "done" && !isFinished { + continue + } + + item := map[string]interface{}{ + "id": s.ID, + "content": s.Content, + "schedule_time": s.ScheduleTime.Format("2006-01-02 15:04:05"), + "remind_channels": channels, + "is_finished": isFinished, + "created_at": s.ScheduleTime.Format("2006-01-02 15:04:05"), + "updated_at": s.ScheduleTime.Format("2006-01-02 15:04:05"), + } + if len(reminders) > 0 { + first := reminders[0] + item["advance_minutes"] = first.AdvanceMinutes + if !first.CreateTime.IsZero() { + item["created_at"] = first.CreateTime.Format("2006-01-02 15:04:05") + } + if !first.UpdateTime.IsZero() { + item["updated_at"] = first.UpdateTime.Format("2006-01-02 15:04:05") + } + } + list = append(list, item) + } + + c.ok(map[string]interface{}{ + "list": list, + "total": total, + "page": page, + "pageSize": pageSize, + }) +} + +// GetDetail GET /app/schedule/:id +func (c *AppReminderController) GetDetail() { + claims, err := c.appClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, _ := strconv.ParseUint(idStr, 10, 64) + if id == 0 { + c.jsonErr(400, 400, "无效的ID") + return + } + + var schedule models.PlatformSchedule + err = models.Orm.QueryTable(new(models.PlatformSchedule)). + Filter("id", id). + Filter("user_id", claims.UserID). + One(&schedule) + if err != nil { + c.jsonErr(404, 404, "日程未找到") + return + } + + var reminders []models.PlatformScheduleReminder + _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("schedule_id", schedule.ID). + Filter("is_deleted", 0). + All(&reminders) + + channels := make([]string, 0, len(reminders)) + var first models.PlatformScheduleReminder + for _, r := range reminders { + channels = append(channels, r.RemindChannel) + first = r + } + + isFinished := true + if len(reminders) == 0 { + isFinished = false + } else { + for _, r := range reminders { + if r.RemindStatus != 2 { + isFinished = false + break + } + } + } + + data := map[string]interface{}{ + "id": schedule.ID, + "content": schedule.Content, + "schedule_time": schedule.ScheduleTime.Format("2006-01-02 15:04:05"), + "remind_channels": channels, + "is_finished": isFinished, + } + if first.ID > 0 { + data["advance_minutes"] = first.AdvanceMinutes + if !first.CreateTime.IsZero() { + data["created_at"] = first.CreateTime.Format("2006-01-02 15:04:05") + } + if !first.UpdateTime.IsZero() { + data["updated_at"] = first.UpdateTime.Format("2006-01-02 15:04:05") + } + } + + c.ok(data) +} + +// Create POST /app/schedule +func (c *AppReminderController) Create() { + claims, err := c.appClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var p appSchedulePayload + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + if strings.TrimSpace(p.Content) == "" { + c.jsonErr(400, 400, "日程内容不能为空") + return + } + if strings.TrimSpace(p.ScheduleTime) == "" { + c.jsonErr(400, 400, "日程发生时间不能为空") + return + } + + schedTime, err := time.ParseInLocation("2006-01-02 15:04:05", p.ScheduleTime, time.Local) + if err != nil { + c.jsonErr(400, 400, "日程时间格式不合法,支持 YYYY-MM-DD HH:mm:ss") + return + } + + content := strings.TrimSpace(p.Content) + title := strings.SplitN(content, "\n", 2)[0] + if len(title) > 60 { + title = title[:60] + } + if title == "" { + title = "日程提醒" + } + + schedule := models.PlatformSchedule{ + Title: title, + Content: content, + ScheduleTime: schedTime, + UserID: uint64(claims.UserID), + } + schedID, err := models.Orm.Insert(&schedule) + if err != nil { + c.jsonErr(500, 500, "保存日程失败: "+err.Error()) + return + } + + for _, ch := range p.RemindChannels { + ch = strings.ToUpper(strings.TrimSpace(ch)) + if ch != "SMS" && ch != "EMAIL" && ch != "BARK" && ch != "SITE_MSG" && ch != "APP" { + continue + } + if ch == "APP" { + ch = "SITE_MSG" + } + + firstSendTime := schedTime.Add(-time.Duration(p.AdvanceMinutes) * time.Minute) + + reminder := models.PlatformScheduleReminder{ + ScheduleID: uint64(schedID), + RemindChannel: ch, + AdvanceMinutes: p.AdvanceMinutes, + NextRemindTime: firstSendTime, + ReceiverUserID: uint64(claims.UserID), + RemindStatus: 0, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + + if ch == "EMAIL" || ch == "BARK" { + token := appGenerateToken() + reminder.AckToken = &token + reminder.RepeatIntervalMinutes = p.RepeatIntervalMinutes + reminder.MaxSendCount = p.MaxSendCount + if reminder.MaxSendCount <= 0 { + reminder.MaxSendCount = 1 + } + } else { + reminder.RepeatIntervalMinutes = 0 + reminder.MaxSendCount = 1 + } + + _, err = models.Orm.Insert(&reminder) + if err != nil { + c.jsonErr(500, 500, "创建提醒失败: "+err.Error()) + return + } + } + + c.ok(map[string]interface{}{"id": schedID}) +} + +// Update PUT /app/schedule/:id +func (c *AppReminderController) Update() { + claims, err := c.appClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, _ := strconv.ParseUint(idStr, 10, 64) + if id == 0 { + c.jsonErr(400, 400, "无效的ID") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var p appSchedulePayload + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + schedTime, err := time.ParseInLocation("2006-01-02 15:04:05", p.ScheduleTime, time.Local) + if err != nil { + c.jsonErr(400, 400, "日程时间格式不合法") + return + } + + var schedule models.PlatformSchedule + err = models.Orm.QueryTable(new(models.PlatformSchedule)). + Filter("id", id). + Filter("user_id", claims.UserID). + One(&schedule) + if err != nil { + c.jsonErr(404, 404, "日程未找到") + return + } + + var reminders []models.PlatformScheduleReminder + _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("schedule_id", id). + Filter("is_deleted", 0). + All(&reminders) + isFinished := true + if len(reminders) == 0 { + isFinished = false + } else { + for _, r := range reminders { + if r.RemindStatus != 2 { + isFinished = false + break + } + } + } + if isFinished { + c.jsonErr(400, 400, "该日程提醒已全部结束,无法编辑") + return + } + + content := strings.TrimSpace(p.Content) + if content == "" { + content = schedule.Content + } + title := strings.SplitN(content, "\n", 2)[0] + if len(title) > 60 { + title = title[:60] + } + + schedule.Title = title + schedule.Content = content + schedule.ScheduleTime = schedTime + _, err = models.Orm.Update(&schedule, "Title", "Content", "ScheduleTime") + if err != nil { + c.jsonErr(500, 500, "更新失败") + return + } + + // 软删除旧提醒,重建 + _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("schedule_id", id). + Update(map[string]interface{}{ + "IsDeleted": 1, + "UpdateTime": time.Now(), + }) + + for _, ch := range p.RemindChannels { + ch = strings.ToUpper(strings.TrimSpace(ch)) + if ch != "SMS" && ch != "EMAIL" && ch != "BARK" && ch != "SITE_MSG" && ch != "APP" { + continue + } + if ch == "APP" { + ch = "SITE_MSG" + } + + firstSendTime := schedTime.Add(-time.Duration(p.AdvanceMinutes) * time.Minute) + + reminder := models.PlatformScheduleReminder{ + ScheduleID: id, + RemindChannel: ch, + AdvanceMinutes: p.AdvanceMinutes, + NextRemindTime: firstSendTime, + ReceiverUserID: schedule.UserID, + RemindStatus: 0, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + + if ch == "EMAIL" || ch == "BARK" { + token := appGenerateToken() + reminder.AckToken = &token + reminder.RepeatIntervalMinutes = p.RepeatIntervalMinutes + reminder.MaxSendCount = p.MaxSendCount + if reminder.MaxSendCount <= 0 { + reminder.MaxSendCount = 1 + } + } else { + reminder.RepeatIntervalMinutes = 0 + reminder.MaxSendCount = 1 + } + + _, err = models.Orm.Insert(&reminder) + if err != nil { + c.jsonErr(500, 500, "重新创建提醒失败") + return + } + } + + c.ok(nil) +} + +// Delete DELETE /app/schedule/:id +func (c *AppReminderController) Delete() { + claims, err := c.appClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, _ := strconv.ParseUint(idStr, 10, 64) + if id == 0 { + c.jsonErr(400, 400, "无效的ID") + return + } + + var schedule models.PlatformSchedule + err = models.Orm.QueryTable(new(models.PlatformSchedule)). + Filter("id", id). + Filter("user_id", claims.UserID). + One(&schedule) + if err != nil { + c.jsonErr(404, 404, "日程未找到") + return + } + + var reminders []models.PlatformScheduleReminder + _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("schedule_id", id). + Filter("is_deleted", 0). + All(&reminders) + isFinished := true + if len(reminders) == 0 { + isFinished = false + } else { + for _, r := range reminders { + if r.RemindStatus != 2 { + isFinished = false + break + } + } + } + if isFinished { + c.jsonErr(400, 400, "该日程提醒已全部结束,无法删除") + return + } + + _, err = models.Orm.QueryTable(new(models.PlatformSchedule)).Filter("id", id).Delete() + if err == nil { + _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("schedule_id", id). + Update(map[string]interface{}{ + "IsDeleted": 1, + "UpdateTime": time.Now(), + }) + } + + middleware.WriteDeleteLog(uint64(claims.UserID), claims.TenantId, "schedule", schedule.Content) + c.ok(nil) +} + +// ToggleComplete POST /app/schedule/:id/toggle +func (c *AppReminderController) ToggleComplete() { + claims, err := c.appClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, _ := strconv.ParseUint(idStr, 10, 64) + if id == 0 { + c.jsonErr(400, 400, "无效的ID") + return + } + + var schedule models.PlatformSchedule + err = models.Orm.QueryTable(new(models.PlatformSchedule)). + Filter("id", id). + Filter("user_id", claims.UserID). + One(&schedule) + if err != nil { + c.jsonErr(404, 404, "日程未找到") + return + } + + var reminders []models.PlatformScheduleReminder + _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("schedule_id", id). + Filter("is_deleted", 0). + All(&reminders) + + // Toggle: if all finished -> set to pending; else -> set to finished + allFinished := len(reminders) > 0 + for _, r := range reminders { + if r.RemindStatus != 2 { + allFinished = false + break + } + } + + var newStatus int8 + if allFinished { + newStatus = 0 // reopen + } else { + newStatus = 2 // mark done + } + + now := time.Now() + for _, r := range reminders { + r.RemindStatus = newStatus + r.UpdateTime = now + models.Orm.Update(&r, "RemindStatus", "UpdateTime") + } + + c.ok(map[string]interface{}{ + "is_finished": newStatus == 2, + }) +} diff --git a/go/controllers/backend_notebook.go b/go/controllers/backend_notebook.go new file mode 100644 index 0000000..608ca9f --- /dev/null +++ b/go/controllers/backend_notebook.go @@ -0,0 +1,274 @@ +package controllers + +import ( + "encoding/json" + "io" + "server/models" + "server/pkg/jwtutil" + "strconv" + "strings" + "time" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +type BackendNotebookController struct { + beego.Controller +} + +func (c *BackendNotebookController) backendNotebookClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, orm.ErrNoRows + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, orm.ErrNoRows + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, err + } + if claims.UserType != "backend" { + return nil, orm.ErrNoRows + } + return claims, nil +} + +func (c *BackendNotebookController) nbJsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *BackendNotebookController) nbOk(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +// List GET /backend/notebook/list +func (c *BackendNotebookController) List() { + claims, err := c.backendNotebookClaims() + if err != nil { + c.nbJsonErr(401, 401, "未登录或无权限") + return + } + + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 20) + keyword := strings.TrimSpace(c.GetString("keyword")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + qs := models.Orm.QueryTable(new(models.BackendNotebook)). + Filter("is_deleted", 0). + Filter("tid", claims.TenantId). + Filter("user_id", claims.UserID) + + if keyword != "" { + qs = qs.Filter("title__icontains", keyword) + } + + total, _ := qs.Count() + + var list []models.BackendNotebook + _, err = qs.OrderBy("-update_time", "-create_time"). + Limit(pageSize).Offset((page - 1) * pageSize). + All(&list) + if err != nil && err != orm.ErrNoRows { + c.nbJsonErr(500, 500, "查询失败") + return + } + if list == nil { + list = []models.BackendNotebook{} + } + + c.nbOk(map[string]interface{}{"list": list, "total": total}) +} + +// Detail GET /backend/notebook/detail/:id +func (c *BackendNotebookController) Detail() { + claims, err := c.backendNotebookClaims() + if err != nil { + c.nbJsonErr(401, 401, "未登录或无权限") + return + } + + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.nbJsonErr(400, 400, "无效ID") + return + } + + var note models.BackendNotebook + err = models.Orm.QueryTable(new(models.BackendNotebook)). + Filter("id", id). + Filter("is_deleted", 0). + Filter("tid", claims.TenantId). + Filter("user_id", claims.UserID). + One(¬e) + if err != nil { + c.nbJsonErr(404, 404, "笔记不存在") + return + } + + c.nbOk(note) +} + +// Create POST /backend/notebook/create +func (c *BackendNotebookController) Create() { + claims, err := c.backendNotebookClaims() + if err != nil { + c.nbJsonErr(401, 401, "未登录或无权限") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.nbJsonErr(400, 400, "参数错误") + return + } + var payload struct { + Title string `json:"title"` + Content string `json:"content"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + c.nbJsonErr(400, 400, "参数错误") + return + } + + payload.Title = strings.TrimSpace(payload.Title) + if payload.Title == "" { + payload.Title = "无标题" + } + + userID := uint64(claims.UserID) + note := &models.BackendNotebook{ + Tid: claims.TenantId, + Title: payload.Title, + Content: payload.Content, + UserID: &userID, + UserName: &claims.Username, + IsDeleted: 0, + } + + id, err := models.Orm.Insert(note) + if err != nil { + c.nbJsonErr(500, 500, "创建失败") + return + } + + note.ID = uint64(id) + c.nbOk(note) +} + +// Update POST /backend/notebook/update/:id +func (c *BackendNotebookController) Update() { + claims, err := c.backendNotebookClaims() + if err != nil { + c.nbJsonErr(401, 401, "未登录或无权限") + return + } + + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.nbJsonErr(400, 400, "无效ID") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.nbJsonErr(400, 400, "参数错误") + return + } + var payload struct { + Title string `json:"title"` + Content string `json:"content"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + c.nbJsonErr(400, 400, "参数错误") + return + } + + payload.Title = strings.TrimSpace(payload.Title) + if payload.Title == "" { + payload.Title = "无标题" + } + + var note models.BackendNotebook + err = models.Orm.QueryTable(new(models.BackendNotebook)). + Filter("id", id). + Filter("is_deleted", 0). + Filter("tid", claims.TenantId). + Filter("user_id", claims.UserID). + One(¬e) + if err != nil { + c.nbJsonErr(404, 404, "笔记不存在") + return + } + + now := time.Now() + _, err = models.Orm.QueryTable(new(models.BackendNotebook)). + Filter("id", id). + Update(map[string]interface{}{ + "title": payload.Title, + "content": payload.Content, + "update_time": now, + }) + if err != nil { + c.nbJsonErr(500, 500, "更新失败") + return + } + + note.Title = payload.Title + note.Content = payload.Content + note.UpdateTime = &now + c.nbOk(note) +} + +// Delete DELETE /backend/notebook/delete/:id +func (c *BackendNotebookController) Delete() { + claims, err := c.backendNotebookClaims() + if err != nil { + c.nbJsonErr(401, 401, "未登录或无权限") + return + } + + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.nbJsonErr(400, 400, "无效ID") + return + } + + var note models.BackendNotebook + err = models.Orm.QueryTable(new(models.BackendNotebook)). + Filter("id", id). + Filter("is_deleted", 0). + Filter("tid", claims.TenantId). + Filter("user_id", claims.UserID). + One(¬e) + if err != nil { + c.nbJsonErr(404, 404, "笔记不存在") + return + } + + now := time.Now() + _, err = models.Orm.QueryTable(new(models.BackendNotebook)). + Filter("id", id). + Update(map[string]interface{}{ + "is_deleted": 1, + "delete_time": now, + }) + if err != nil { + c.nbJsonErr(500, 500, "删除失败") + return + } + + c.nbOk(nil) +} diff --git a/go/controllers/backend_reminder.go b/go/controllers/backend_reminder.go new file mode 100644 index 0000000..77e43d1 --- /dev/null +++ b/go/controllers/backend_reminder.go @@ -0,0 +1,724 @@ +package controllers + +import ( + "context" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + "server/services" + + beego "github.com/beego/beego/v2/server/web" +) + +type BackendReminderController struct { + beego.Controller +} + +func (c *BackendReminderController) backendReminderClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "backend" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *BackendReminderController) brJsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *BackendReminderController) brOk(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +func backendGenerateToken() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) +} + +type backendReminderFormPayload struct { + Title string `json:"title"` + Content string `json:"content"` + ScheduleTime string `json:"schedule_time"` + RemindChannels []string `json:"remind_channels"` + AdvanceMinutes int `json:"advance_minutes"` + RepeatIntervalMinutes int `json:"repeat_interval_minutes"` + MaxSendCount int `json:"max_send_count"` + ReceiverUserID uint64 `json:"receiver_user_id"` + ReceiverTargets map[string]string `json:"receiver_targets"` +} + +// GetReminderList GET /backend/reminder/list +func (c *BackendReminderController) GetReminderList() { + claims, err := c.backendReminderClaims() + if err != nil { + c.brJsonErr(401, 401, err.Error()) + return + } + + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 20) + keyword := strings.TrimSpace(c.GetString("keyword")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + qs := models.Orm.QueryTable(new(models.BackendSchedule)). + Filter("tid", claims.TenantId) + + if keyword != "" { + qs = qs.Filter("content__contains", keyword) + } + + total, _ := qs.Count() + + var schedules []models.BackendSchedule + _, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&schedules) + if err != nil { + c.brJsonErr(500, 500, "查询失败: "+err.Error()) + return + } + + list := make([]map[string]interface{}, 0, len(schedules)) + for _, s := range schedules { + var reminders []models.BackendScheduleReminder + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). + Filter("schedule_id", s.ID). + Filter("is_deleted", 0). + All(&reminders) + + channels := make([]string, 0, len(reminders)) + isFinished := true + if len(reminders) == 0 { + isFinished = false + } else { + for _, r := range reminders { + channels = append(channels, r.RemindChannel) + if r.RemindStatus != 2 { + isFinished = false + } + } + } + + item := map[string]interface{}{ + "id": s.ID, + "title": s.Title, + "content": s.Content, + "schedule_time": s.ScheduleTime.Format("2006-01-02 15:04:05"), + "remind_channels": channels, + "user_id": s.UserID, + "is_finished": isFinished, + } + if len(reminders) > 0 { + first := reminders[0] + item["advance_minutes"] = first.AdvanceMinutes + item["repeat_interval_minutes"] = first.RepeatIntervalMinutes + item["max_send_count"] = first.MaxSendCount + item["receiver_user_id"] = first.ReceiverUserID + } + list = append(list, item) + } + + c.brOk(map[string]interface{}{ + "list": list, + "total": total, + "page": page, + "pageSize": pageSize, + }) +} + +// GetReminderDetail GET /backend/reminder/:id +func (c *BackendReminderController) GetReminderDetail() { + claims, err := c.backendReminderClaims() + if err != nil { + c.brJsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, _ := strconv.ParseUint(idStr, 10, 64) + if id == 0 { + c.brJsonErr(400, 400, "无效的ID") + return + } + + var schedule models.BackendSchedule + err = models.Orm.QueryTable(new(models.BackendSchedule)). + Filter("id", id). + Filter("tid", claims.TenantId). + One(&schedule) + if err != nil { + c.brJsonErr(404, 404, "日程未找到") + return + } + + var reminders []models.BackendScheduleReminder + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). + Filter("schedule_id", schedule.ID). + Filter("is_deleted", 0). + All(&reminders) + + channels := make([]string, 0, len(reminders)) + targets := make(map[string]string) + var first models.BackendScheduleReminder + + for _, r := range reminders { + channels = append(channels, r.RemindChannel) + if r.ReceiverTarget != nil { + targets[r.RemindChannel] = *r.ReceiverTarget + } + first = r + } + + isFinished := true + if len(reminders) == 0 { + isFinished = false + } else { + for _, r := range reminders { + if r.RemindStatus != 2 { + isFinished = false + break + } + } + } + + data := map[string]interface{}{ + "id": schedule.ID, + "title": schedule.Title, + "content": schedule.Content, + "schedule_time": schedule.ScheduleTime.Format("2006-01-02 15:04:05"), + "remind_channels": channels, + "receiver_targets": targets, + "is_finished": isFinished, + } + if first.ID > 0 { + data["advance_minutes"] = first.AdvanceMinutes + data["repeat_interval_minutes"] = first.RepeatIntervalMinutes + data["max_send_count"] = first.MaxSendCount + data["receiver_user_id"] = first.ReceiverUserID + } + + c.brOk(data) +} + +// CreateReminder POST /backend/reminder +func (c *BackendReminderController) CreateReminder() { + claims, err := c.backendReminderClaims() + if err != nil { + c.brJsonErr(401, 401, err.Error()) + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.brJsonErr(400, 400, "参数错误") + return + } + var p backendReminderFormPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.brJsonErr(400, 400, "参数错误") + return + } + + if strings.TrimSpace(p.ScheduleTime) == "" { + c.brJsonErr(400, 400, "日程发生时间不能为空") + return + } + + schedTime, err := time.ParseInLocation("2006-01-02 15:04:05", p.ScheduleTime, time.Local) + if err != nil { + c.brJsonErr(400, 400, "日程时间格式不合法,支持 YYYY-MM-DD HH:mm:ss") + return + } + + content := strings.TrimSpace(p.Content) + title := strings.SplitN(content, "\n", 2)[0] + if len(title) > 60 { + title = title[:60] + } + if title == "" { + title = "日程提醒" + } + + schedule := models.BackendSchedule{ + Tid: claims.TenantId, + Title: title, + Content: content, + ScheduleTime: schedTime, + UserID: uint64(claims.UserID), + } + schedID, err := models.Orm.Insert(&schedule) + if err != nil { + c.brJsonErr(500, 500, "保存日程失败: "+err.Error()) + return + } + + for _, ch := range p.RemindChannels { + ch = strings.ToUpper(strings.TrimSpace(ch)) + if ch != "SMS" && ch != "EMAIL" && ch != "BARK" && ch != "SITE_MSG" { + continue + } + + targetVal := p.ReceiverTargets[ch] + var target *string + if targetVal != "" { + target = &targetVal + } + + firstSendTime := schedTime.Add(-time.Duration(p.AdvanceMinutes) * time.Minute) + + reminder := models.BackendScheduleReminder{ + Tid: claims.TenantId, + ScheduleID: uint64(schedID), + RemindChannel: ch, + AdvanceMinutes: p.AdvanceMinutes, + NextRemindTime: firstSendTime, + ReceiverUserID: uint64(claims.UserID), + ReceiverTarget: target, + RemindStatus: 0, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + + if ch == "EMAIL" || ch == "BARK" { + token := backendGenerateToken() + reminder.AckToken = &token + reminder.RepeatIntervalMinutes = p.RepeatIntervalMinutes + reminder.MaxSendCount = p.MaxSendCount + if reminder.MaxSendCount <= 0 { + reminder.MaxSendCount = 1 + } + } else { + reminder.RepeatIntervalMinutes = 0 + reminder.MaxSendCount = 1 + } + + _, err = models.Orm.Insert(&reminder) + if err != nil { + c.brJsonErr(500, 500, "创建提醒失败: "+err.Error()) + return + } + } + + c.brOk(map[string]interface{}{"schedule_id": schedID}) +} + +// UpdateReminder PUT /backend/reminder/:id +func (c *BackendReminderController) UpdateReminder() { + claims, err := c.backendReminderClaims() + if err != nil { + c.brJsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, _ := strconv.ParseUint(idStr, 10, 64) + if id == 0 { + c.brJsonErr(400, 400, "无效的ID") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.brJsonErr(400, 400, "参数错误") + return + } + var p backendReminderFormPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.brJsonErr(400, 400, "参数错误") + return + } + + schedTime, err := time.ParseInLocation("2006-01-02 15:04:05", p.ScheduleTime, time.Local) + if err != nil { + c.brJsonErr(400, 400, "日程时间格式不合法") + return + } + + var schedule models.BackendSchedule + err = models.Orm.QueryTable(new(models.BackendSchedule)). + Filter("id", id). + Filter("tid", claims.TenantId). + One(&schedule) + if err != nil { + c.brJsonErr(404, 404, "日程未找到") + return + } + + var reminders []models.BackendScheduleReminder + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). + Filter("schedule_id", id). + Filter("is_deleted", 0). + All(&reminders) + isFinished := true + if len(reminders) == 0 { + isFinished = false + } else { + for _, r := range reminders { + if r.RemindStatus != 2 { + isFinished = false + break + } + } + } + if isFinished { + c.brJsonErr(400, 400, "该日程提醒已全部结束,无法编辑") + return + } + + content := strings.TrimSpace(p.Content) + if content == "" { + content = schedule.Content + } + title := strings.SplitN(content, "\n", 2)[0] + if len(title) > 60 { + title = title[:60] + } + + schedule.Title = title + schedule.Content = content + schedule.ScheduleTime = schedTime + _, err = models.Orm.Update(&schedule, "Title", "Content", "ScheduleTime") + if err != nil { + c.brJsonErr(500, 500, "更新失败") + return + } + + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). + Filter("schedule_id", id). + Update(map[string]interface{}{ + "IsDeleted": 1, + "UpdateTime": time.Now(), + }) + + for _, ch := range p.RemindChannels { + ch = strings.ToUpper(strings.TrimSpace(ch)) + if ch != "SMS" && ch != "EMAIL" && ch != "BARK" && ch != "SITE_MSG" { + continue + } + + targetVal := p.ReceiverTargets[ch] + var target *string + if targetVal != "" { + target = &targetVal + } + + firstSendTime := schedTime.Add(-time.Duration(p.AdvanceMinutes) * time.Minute) + + reminder := models.BackendScheduleReminder{ + Tid: claims.TenantId, + ScheduleID: id, + RemindChannel: ch, + AdvanceMinutes: p.AdvanceMinutes, + NextRemindTime: firstSendTime, + ReceiverUserID: schedule.UserID, + ReceiverTarget: target, + RemindStatus: 0, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + + if ch == "EMAIL" || ch == "BARK" { + token := backendGenerateToken() + reminder.AckToken = &token + reminder.RepeatIntervalMinutes = p.RepeatIntervalMinutes + reminder.MaxSendCount = p.MaxSendCount + if reminder.MaxSendCount <= 0 { + reminder.MaxSendCount = 1 + } + } else { + reminder.RepeatIntervalMinutes = 0 + reminder.MaxSendCount = 1 + } + + _, err = models.Orm.Insert(&reminder) + if err != nil { + c.brJsonErr(500, 500, "重新创建提醒失败") + return + } + } + + c.brOk(nil) +} + +// DeleteReminder DELETE /backend/reminder/:id +func (c *BackendReminderController) DeleteReminder() { + claims, err := c.backendReminderClaims() + if err != nil { + c.brJsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, _ := strconv.ParseUint(idStr, 10, 64) + if id == 0 { + c.brJsonErr(400, 400, "无效的ID") + return + } + + var schedule models.BackendSchedule + err = models.Orm.QueryTable(new(models.BackendSchedule)). + Filter("id", id). + Filter("tid", claims.TenantId). + One(&schedule) + if err != nil { + c.brJsonErr(404, 404, "日程未找到") + return + } + + var reminders []models.BackendScheduleReminder + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). + Filter("schedule_id", id). + Filter("is_deleted", 0). + All(&reminders) + isFinished := true + if len(reminders) == 0 { + isFinished = false + } else { + for _, r := range reminders { + if r.RemindStatus != 2 { + isFinished = false + break + } + } + } + if isFinished { + c.brJsonErr(400, 400, "该日程提醒已全部结束,无法删除") + return + } + + _, err = models.Orm.QueryTable(new(models.BackendSchedule)).Filter("id", id).Delete() + if err == nil { + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). + Filter("schedule_id", id). + Update(map[string]interface{}{ + "IsDeleted": 1, + "UpdateTime": time.Now(), + }) + } + + c.brOk(nil) +} + +type backendReminderBatchDeletePayload struct { + Ids []uint64 `json:"ids"` +} + +// BatchDeleteReminder POST /backend/reminder/batchDelete +func (c *BackendReminderController) BatchDeleteReminder() { + claims, err := c.backendReminderClaims() + if err != nil { + c.brJsonErr(401, 401, err.Error()) + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.brJsonErr(400, 400, "参数错误") + return + } + var p backendReminderBatchDeletePayload + if err := json.Unmarshal(raw, &p); err != nil { + c.brJsonErr(400, 400, "参数错误") + return + } + + if len(p.Ids) == 0 { + c.brOk(nil) + return + } + + for _, scheduleID := range p.Ids { + var reminders []models.BackendScheduleReminder + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). + Filter("schedule_id", scheduleID). + Filter("is_deleted", 0). + All(&reminders) + isFinished := true + if len(reminders) == 0 { + isFinished = false + } else { + for _, r := range reminders { + if r.RemindStatus != 2 { + isFinished = false + break + } + } + } + if isFinished { + c.brJsonErr(400, 400, fmt.Sprintf("日程ID %d 的提醒已全部结束,无法删除", scheduleID)) + return + } + } + + _, _ = models.Orm.QueryTable(new(models.BackendSchedule)). + Filter("id__in", p.Ids). + Filter("tid", claims.TenantId). + Delete() + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). + Filter("schedule_id__in", p.Ids). + Update(map[string]interface{}{ + "IsDeleted": 1, + "UpdateTime": time.Now(), + }) + + c.brOk(nil) +} + +// FinishReminder POST /backend/reminder/finish/:id +func (c *BackendReminderController) FinishReminder() { + claims, err := c.backendReminderClaims() + if err != nil { + c.brJsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, _ := strconv.ParseUint(idStr, 10, 64) + if id == 0 { + c.brJsonErr(400, 400, "无效的ID") + return + } + + var schedule models.BackendSchedule + err = models.Orm.QueryTable(new(models.BackendSchedule)). + Filter("id", id). + Filter("tid", claims.TenantId). + One(&schedule) + if err != nil { + c.brJsonErr(404, 404, "日程未找到") + return + } + + var reminders []models.BackendScheduleReminder + _, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)). + Filter("schedule_id", id). + Filter("is_deleted", 0). + All(&reminders) + + allFinished := len(reminders) > 0 + for _, r := range reminders { + if r.RemindStatus != 2 { + allFinished = false + break + } + } + + var newStatus int8 + if allFinished { + newStatus = 0 + } else { + newStatus = 2 + } + + now := time.Now() + for _, r := range reminders { + r.RemindStatus = newStatus + r.UpdateTime = now + models.Orm.Update(&r, "RemindStatus", "UpdateTime") + } + + c.brOk(map[string]interface{}{"is_finished": newStatus == 2}) +} + +type backendReminderTestPayload struct { + Title string `json:"title"` + Content string `json:"content"` + RemindChannels []string `json:"remind_channels"` +} + +// TestReminder POST /backend/reminder/test +func (c *BackendReminderController) TestReminder() { + claims, err := c.backendReminderClaims() + if err != nil { + c.brJsonErr(401, 401, err.Error()) + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.brJsonErr(400, 400, "参数错误") + return + } + var p backendReminderTestPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.brJsonErr(400, 400, "参数错误") + return + } + + if strings.TrimSpace(p.Title) == "" { + p.Title = "测试提醒" + } + if strings.TrimSpace(p.Content) == "" { + p.Content = "这是一条验证日程提醒配置的测试通知。" + } + + senders := map[string]services.ReminderSender{ + "SMS": &services.SMSSender{}, + "EMAIL": &services.EmailSender{}, + "BARK": &services.BarkSender{}, + "SITE_MSG": &services.SiteMsgSender{}, + } + + type TestResult struct { + Channel string `json:"channel"` + Success bool `json:"success"` + Msg string `json:"msg"` + } + results := make([]TestResult, 0) + + for _, ch := range p.RemindChannels { + ch = strings.ToUpper(strings.TrimSpace(ch)) + sender, ok := senders[ch] + if !ok { + results = append(results, TestResult{Channel: ch, Success: false, Msg: "不支持的提醒渠道"}) + continue + } + + dummyToken := "test-token-for-verification" + reminder := &models.PlatformScheduleReminder{ + RemindChannel: ch, + ReceiverUserID: uint64(claims.UserID), + AckToken: &dummyToken, + } + + success, sendErr := sender.Send(context.Background(), reminder, "[测试]"+p.Title, p.Content) + msg := "发送成功" + if !success { + msg = "发送失败" + if sendErr != nil { + msg = sendErr.Error() + } + } + results = append(results, TestResult{Channel: ch, Success: success, Msg: msg}) + } + + c.brOk(results) +} diff --git a/go/middleware/operationLog.go b/go/middleware/operationLog.go index 6cb3f61..56b25b6 100644 --- a/go/middleware/operationLog.go +++ b/go/middleware/operationLog.go @@ -2,11 +2,13 @@ package middleware import ( "encoding/json" + "fmt" "strconv" "strings" "time" "server/models" + "server/pkg/jwtutil" "github.com/beego/beego/v2/server/web/context" ) @@ -23,12 +25,96 @@ func BeginOperationLog(ctx *context.Context) { if shouldSkipLogging(method, url) { return } - ctx.Input.SetData(oplogStartKey, time.Now()) + fmt.Printf("[oplog-begin] method=%s url=%s\n", method, url) - // 请求体由 main.go 的 CopyBody 保留在 Input.RequestBody + // 解析用户信息(直接从 header 解析,不依赖 JWT 中间件设置的 context) + var uid uint64 + var tid *uint64 + if auth := ctx.Request.Header.Get("Authorization"); auth != "" { + parts := strings.SplitN(auth, " ", 2) + if len(parts) == 2 && parts[0] == "Bearer" { + if claims, err := jwtutil.ParseToken(parts[1]); err == nil { + uid = uint64(claims.UserID) + if claims.TenantId > 0 { + tidVal := uint64(claims.TenantId) + tid = &tidVal + } + } + } + } + + // 采集请求信息 + reqBody := "" if rb := ctx.Input.RequestBody; len(rb) > 0 { - s := string(rb) - ctx.Input.SetData(oplogReqBodyKey, truncateString(maskSensitive(s), 5000)) + reqBody = truncateString(maskSensitive(string(rb)), 5000) + } else if q := strings.TrimSpace(ctx.Request.URL.RawQuery); q != "" { + reqBody = truncateString(maskSensitive(q), 5000) + } + + module := parseModule(url) + action := parseAction(method, url) + ip := truncateString(ctx.Input.IP(), 50) + userAgent := truncateString(ctx.Request.Header.Get("User-Agent"), 500) + + // 异步写入数据库 + go func() { + time.Sleep(100 * time.Millisecond) // 等待响应完成 + logRow := &models.SystemOperationLog{ + Tid: tid, + UserID: uid, + Module: module, + Action: action, + Method: method, + URL: truncateString(url, 255), + IP: ip, + UserAgent: userAgent, + RequestData: strPtr(reqBody), + Status: 1, + ExecutionTime: 0, + } + _, err := models.Orm.Insert(logRow) + if err != nil { + fmt.Printf("[oplog] INSERT ERROR: %v\n", err) + } else { + fmt.Printf("[oplog] INSERTED uid=%d module=%s action=%s\n", uid, module, action) + } + }() +} + +func strPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + +// WriteDeleteLog 控制器删除后直接写操作日志(带标题) +func WriteDeleteLog(userID uint64, tid int, module, title string) { + moduleCN := oplogModuleLabel(module) + desc := fmt.Sprintf("您删除了%s:【%s】", moduleCN, truncateString(title, 20)) + tidVal := uint64(tid) + logRow := &models.SystemOperationLog{ + UserID: userID, + Tid: &tidVal, + Module: module, + Action: "删除", + Method: "DELETE", + URL: fmt.Sprintf("/app/%s", module), + Status: 1, + RequestData: &desc, + ExecutionTime: 0, + } + models.Orm.Insert(logRow) +} + +func oplogModuleLabel(m string) string { + switch m { + case "notebook": + return "记事本" + case "schedule": + return "日程提醒" + default: + return m } } @@ -36,7 +122,9 @@ func BeginOperationLog(ctx *context.Context) { func FinishOperationLog(ctx *context.Context) { url := ctx.Input.URL() method := ctx.Input.Method() + fmt.Printf("[oplog-finish] method=%s url=%s\n", method, url) if shouldSkipLogging(method, url) { + fmt.Printf("[oplog-finish] SKIPPED\n") return } @@ -48,6 +136,22 @@ func FinishOperationLog(ctx *context.Context) { uid := parseUint64FromCtx(ctx.Input.GetData("userId")) tidVal := parseUint64FromCtx(ctx.Input.GetData("tenantId")) + + // 如果 context 中没有用户信息,尝试从 Authorization header 解析 + if uid == 0 { + if auth := ctx.Request.Header.Get("Authorization"); auth != "" { + parts := strings.SplitN(auth, " ", 2) + if len(parts) == 2 && parts[0] == "Bearer" { + if claims, err := jwtutil.ParseToken(parts[1]); err == nil { + uid = uint64(claims.UserID) + if claims.TenantId > 0 { + tidVal = uint64(claims.TenantId) + } + } + } + } + } + var tid *uint64 if tidVal > 0 { tid = &tidVal @@ -100,7 +204,11 @@ func FinishOperationLog(ctx *context.Context) { ErrorMessage: errMsg, ExecutionTime: execSec, } - _, _ = models.Orm.Insert(logRow) + fmt.Printf("[oplog] method=%s url=%s uid=%d tid=%v module=%s action=%s\n", method, url, uid, tid, module, action) + _, err := models.Orm.Insert(logRow) + if err != nil { + fmt.Printf("[oplog] INSERT ERROR: %v\n", err) + } } func parseAction(method, url string) string { @@ -183,6 +291,14 @@ func shouldSkipLogging(method, url string) bool { if strings.HasPrefix(url, "/api/softwareupgrade/check") { return true } + // App 端读接口跳过(只记录写操作:POST/PUT/DELETE) + if strings.HasPrefix(url, "/app/") && method == "GET" { + return true + } + // App 端 DELETE 由控制器自行记录(需要标题信息) + if strings.HasPrefix(url, "/app/") && method == "DELETE" { + return true + } } return false } diff --git a/go/models/backend_notebook.go b/go/models/backend_notebook.go new file mode 100644 index 0000000..17ca8de --- /dev/null +++ b/go/models/backend_notebook.go @@ -0,0 +1,22 @@ +package models + +import "time" + +// BackendNotebook 租户记事本表: yz_backend_notebook +type BackendNotebook struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid int `orm:"column(tid)" json:"tid"` + Title string `orm:"column(title);size(255)" json:"title"` + Content string `orm:"column(content);type(longtext);null" json:"content"` + Pinned int8 `orm:"column(pinned);default(0)" json:"pinned"` + UserID *uint64 `orm:"column(user_id);null" json:"user_id"` + UserName *string `orm:"column(user_name);size(100);null" json:"user_name"` + IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *BackendNotebook) TableName() string { + return "yz_backend_notebook" +} diff --git a/go/models/backend_schedule.go b/go/models/backend_schedule.go new file mode 100644 index 0000000..05cd16e --- /dev/null +++ b/go/models/backend_schedule.go @@ -0,0 +1,58 @@ +package models + +import "time" + +// BackendSchedule 租户日程主表: yz_backend_schedule +type BackendSchedule struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid int `orm:"column(tid)" json:"tid"` + Title string `orm:"column(title);size(255)" json:"title"` + Content string `orm:"column(content);type(text)" json:"content"` + ScheduleTime time.Time `orm:"column(schedule_time);type(datetime)" json:"schedule_time"` + UserID uint64 `orm:"column(user_id)" json:"user_id"` +} + +func (m *BackendSchedule) TableName() string { + return "yz_backend_schedule" +} + +// BackendScheduleReminder 租户日程提醒: yz_backend_schedule_reminder +type BackendScheduleReminder struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid int `orm:"column(tid)" json:"tid"` + ScheduleID uint64 `orm:"column(schedule_id)" json:"schedule_id"` + RemindChannel string `orm:"column(remind_channel);size(20)" json:"remind_channel"` + AdvanceMinutes int `orm:"column(advance_minutes);default(0)" json:"advance_minutes"` + RepeatIntervalMinutes int `orm:"column(repeat_interval_minutes);default(0)" json:"repeat_interval_minutes"` + NextRemindTime time.Time `orm:"column(next_remind_time);type(datetime)" json:"next_remind_time"` + SendCount int `orm:"column(send_count);default(0)" json:"send_count"` + MaxSendCount int `orm:"column(max_send_count);default(1)" json:"max_send_count"` + AckToken *string `orm:"column(ack_token);size(64);null" json:"ack_token"` + AckStatus int8 `orm:"column(ack_status);default(0)" json:"ack_status"` + AckTime *time.Time `orm:"column(ack_time);type(datetime);null" json:"ack_time"` + ReceiverUserID uint64 `orm:"column(receiver_user_id)" json:"receiver_user_id"` + ReceiverTarget *string `orm:"column(receiver_target);size(255);null" json:"receiver_target"` + RemindStatus int8 `orm:"column(remind_status);default(0)" json:"remind_status"` + ScanLock string `orm:"column(scan_lock);size(64);default('')" json:"scan_lock"` + IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` +} + +func (m *BackendScheduleReminder) TableName() string { + return "yz_backend_schedule_reminder" +} + +// BackendScheduleReminderSendLog 租户提醒发送流水: yz_backend_schedule_reminder_send_log +type BackendScheduleReminderSendLog struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid int `orm:"column(tid)" json:"tid"` + ReminderID uint64 `orm:"column(reminder_id)" json:"reminder_id"` + SendTime time.Time `orm:"column(send_time);type(datetime)" json:"send_time"` + SendResult int8 `orm:"column(send_result)" json:"send_result"` + FailReason *string `orm:"column(fail_reason);size(255);null" json:"fail_reason"` +} + +func (m *BackendScheduleReminderSendLog) TableName() string { + return "yz_backend_schedule_reminder_send_log" +} diff --git a/go/models/init.go b/go/models/init.go index 1e6f163..25cfaef 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -74,6 +74,11 @@ func Init(_ string) { new(PlatformSchedule), new(PlatformScheduleReminder), new(PlatformScheduleReminderSendLog), + + new(BackendNotebook), + new(BackendSchedule), + new(BackendScheduleReminder), + new(BackendScheduleReminderSendLog), ) // 创建全局 Ormer diff --git a/go/routers/app/app.go b/go/routers/app/app.go index 266098e..2a8048e 100644 --- a/go/routers/app/app.go +++ b/go/routers/app/app.go @@ -30,4 +30,19 @@ func Register() { // 注册 beego.Router("/app/register", &controllers.AppAuthController{}, "post:Register") beego.Router("/app/sendRegisterCode", &controllers.AppAuthController{}, "post:SendRegisterCode") + + // 日程提醒 + beego.Router("/app/schedule/list", &controllers.AppReminderController{}, "get:GetList") + beego.Router("/app/schedule", &controllers.AppReminderController{}, "post:Create") + beego.Router("/app/schedule/:id", &controllers.AppReminderController{}, "get:GetDetail;put:Update;delete:Delete") + beego.Router("/app/schedule/:id/toggle", &controllers.AppReminderController{}, "post:ToggleComplete") + + // 记事本 + beego.Router("/app/notebook/list", &controllers.AppNotebookController{}, "get:GetList") + beego.Router("/app/notebook", &controllers.AppNotebookController{}, "post:Create") + beego.Router("/app/notebook/:id", &controllers.AppNotebookController{}, "get:GetDetail;put:Update;delete:Delete") + beego.Router("/app/notebook/:id/togglePin", &controllers.AppNotebookController{}, "post:TogglePin") + + // 活动日志 + beego.Router("/app/activity/list", &controllers.AppActivityController{}, "get:GetList") } diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go index d3378f2..c990dc5 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -144,4 +144,18 @@ func RegisterAuthRoutes() { beego.Router("/backend/domain/tenant/toggleStatus", &controllers.BackendTenantDomainController{}, "post:ToggleStatus") beego.Router("/backend/domain/tenant/delete/:id", &controllers.BackendTenantDomainController{}, "delete:Delete") + // 记事本管理 + beego.Router("/backend/notebook/list", &controllers.BackendNotebookController{}, "get:List") + beego.Router("/backend/notebook/detail/:id", &controllers.BackendNotebookController{}, "get:Detail") + beego.Router("/backend/notebook/create", &controllers.BackendNotebookController{}, "post:Create") + beego.Router("/backend/notebook/update/:id", &controllers.BackendNotebookController{}, "post:Update") + beego.Router("/backend/notebook/delete/:id", &controllers.BackendNotebookController{}, "delete:Delete") + + // 日程提醒管理 + beego.Router("/backend/reminder/list", &controllers.BackendReminderController{}, "get:GetReminderList") + beego.Router("/backend/reminder/test", &controllers.BackendReminderController{}, "post:TestReminder") + beego.Router("/backend/reminder/:id", &controllers.BackendReminderController{}, "get:GetReminderDetail;put:UpdateReminder;delete:DeleteReminder") + beego.Router("/backend/reminder", &controllers.BackendReminderController{}, "post:CreateReminder") + beego.Router("/backend/reminder/batchDelete", &controllers.BackendReminderController{}, "post:BatchDeleteReminder") + beego.Router("/backend/reminder/finish/:id", &controllers.BackendReminderController{}, "post:FinishReminder") } diff --git a/go/routers/router.go b/go/routers/router.go index d6b3af2..6ce9aef 100644 --- a/go/routers/router.go +++ b/go/routers/router.go @@ -32,10 +32,8 @@ func init() { } }) - // 全局操作日志:请求开始采集 + // 全局操作日志:请求开始采集并异步写入 beego.InsertFilter("*", beego.BeforeRouter, middleware.BeginOperationLog) - // 全局操作日志:请求结束统一落库 - beego.InsertFilter("*", beego.FinishRouter, middleware.FinishOperationLog) // 根据运行模式选择要注册的路由组 // 优先读取环境变量 APP_MODE,其次读取配置 app_mode,默认 all diff --git a/platform/vite.config.js b/platform/vite.config.js index d4d939f..a82e8a7 100644 --- a/platform/vite.config.js +++ b/platform/vite.config.js @@ -28,7 +28,7 @@ export default defineConfig({ }, server: { host: "127.0.0.1", - port: 5000, + port: 4000, // 开发时前端在 5000,接口走相对路径 /platform/*、/backend/*,转发到本地 Go(当前 httpport=8081) proxy: { "/platform": { diff --git a/uniapp/api/note.js b/uniapp/api/note.js index e5d96ee..2ac5b21 100644 --- a/uniapp/api/note.js +++ b/uniapp/api/note.js @@ -1,138 +1,85 @@ -import { generateId } from '@/api/request.js' - -const STORAGE_KEY = 'app_notes' - -function readLocal() { - return uni.getStorageSync(STORAGE_KEY) || [] -} - -function writeLocal(list) { - uni.setStorageSync(STORAGE_KEY, list) -} - -function seedIfEmpty() { - const list = readLocal() - if (list.length) return list - const now = Date.now() - const seeded = [ - { - id: generateId(), - title: '欢迎使用记事本', - content: '在这里记录灵感、待办或任何想法。支持置顶、搜索与编辑,后续可一键接入云端同步。', - pinned: true, - createdAt: now - 86400000, - updatedAt: now - 3600000 - }, - { - id: generateId(), - title: '项目会议要点', - content: '1. 确认 Q3 目标\n2. 排期评审\n3. 接口联调时间', - pinned: false, - createdAt: now - 172800000, - updatedAt: now - 7200000 - } - ] - writeLocal(seeded) - return seeded -} - -function sortNotes(list) { - return [...list].sort((a, b) => { - if (a.pinned !== b.pinned) return a.pinned ? -1 : 1 - return (b.updatedAt || 0) - (a.updatedAt || 0) - }) -} +import { request } from '@/api/request.js' /** * 获取笔记列表 - * @param {{ keyword?: string }} params - * @returns {Promise<{ list: Array, total: number }>} */ export async function fetchNotes(params = {}) { - // TODO: return request({ url: '/api/notes', data: params }) - let list = seedIfEmpty() - const keyword = (params.keyword || '').trim().toLowerCase() - if (keyword) { - list = list.filter( - item => - item.title.toLowerCase().includes(keyword) || - item.content.toLowerCase().includes(keyword) - ) - } - list = sortNotes(list) - return { list, total: list.length } + const data = await request({ + url: '/app/notebook/list', + data: { + keyword: params.keyword || '' + } + }) + const list = (data.list || []).map(item => ({ + ...item, + pinned: !!item.pinned, + createdAt: item.created_at ? new Date(item.created_at.replace(' ', 'T')).getTime() : 0, + updatedAt: item.updated_at ? new Date(item.updated_at.replace(' ', 'T')).getTime() : 0 + })) + return { list, total: data.total || 0 } } /** * 获取单条笔记 - * @param {string} id */ export async function getNote(id) { - // TODO: return request({ url: `/api/notes/${id}` }) - const list = readLocal() - return list.find(item => item.id === id) || null + const data = await request({ url: `/app/notebook/${id}` }) + if (!data) return null + return { + ...data, + pinned: !!data.pinned, + createdAt: data.created_at ? new Date(data.created_at.replace(' ', 'T')).getTime() : 0, + updatedAt: data.updated_at ? new Date(data.updated_at.replace(' ', 'T')).getTime() : 0 + } } /** * 创建笔记 - * @param {{ title: string, content: string, pinned?: boolean }} data */ export async function createNote(data) { - // TODO: return request({ url: '/api/notes', method: 'POST', data }) - const now = Date.now() - const note = { - id: generateId(), - title: (data.title || '').trim() || '无标题', - content: (data.content || '').trim(), - pinned: !!data.pinned, - createdAt: now, - updatedAt: now - } - const list = readLocal() - list.unshift(note) - writeLocal(list) - return note + const res = await request({ + url: '/app/notebook', + method: 'POST', + data: { + title: data.title || '', + content: data.content || '', + pinned: !!data.pinned + } + }) + return res } /** * 更新笔记 - * @param {string} id - * @param {{ title?: string, content?: string, pinned?: boolean }} data */ export async function updateNote(id, data) { - // TODO: return request({ url: `/api/notes/${id}`, method: 'PUT', data }) - const list = readLocal() - const index = list.findIndex(item => item.id === id) - if (index === -1) throw new Error('笔记不存在') - const note = { - ...list[index], - ...data, - title: data.title !== undefined ? (data.title.trim() || '无标题') : list[index].title, - content: data.content !== undefined ? data.content.trim() : list[index].content, - updatedAt: Date.now() - } - list[index] = note - writeLocal(list) - return note + const res = await request({ + url: `/app/notebook/${id}`, + method: 'PUT', + data: { + title: data.title || '', + content: data.content || '', + pinned: data.pinned + } + }) + return res } /** * 删除笔记 - * @param {string} id */ export async function deleteNote(id) { - // TODO: return request({ url: `/api/notes/${id}`, method: 'DELETE' }) - const list = readLocal().filter(item => item.id !== id) - writeLocal(list) + await request({ url: `/app/notebook/${id}`, method: 'DELETE' }) return true } /** * 切换置顶 - * @param {string} id */ export async function toggleNotePin(id) { - const note = await getNote(id) - if (!note) throw new Error('笔记不存在') - return updateNote(id, { pinned: !note.pinned }) + const res = await request({ + url: `/app/notebook/${id}/togglePin`, + method: 'POST' + }) + return res } diff --git a/uniapp/api/schedule.js b/uniapp/api/schedule.js index 69cc182..8e1740d 100644 --- a/uniapp/api/schedule.js +++ b/uniapp/api/schedule.js @@ -1,15 +1,4 @@ -import { generateId } from '@/api/request.js' -import { startOfDay, endOfDay } from '@/utils/date.js' - -const STORAGE_KEY = 'app_schedules' - -const PRIORITY = { - low: { value: 'low', label: '低', color: '#909399' }, - medium: { value: 'medium', label: '中', color: '#3c9cff' }, - high: { value: 'high', label: '高', color: '#f56c6c' } -} - -export const PRIORITY_OPTIONS = Object.values(PRIORITY) +import { request } from '@/api/request.js' export const REMIND_CHANNEL_OPTIONS = [ { value: 'sms', label: '短信' }, @@ -30,181 +19,112 @@ export const REMIND_MINUTES_OPTIONS = [ { label: '提前 1 天', value: 1440 } ] -function readLocal() { - const list = uni.getStorageSync(STORAGE_KEY) || [] - return list.map(normalizeSchedule) -} - -function writeLocal(list) { - uni.setStorageSync(STORAGE_KEY, list) -} - export function normalizeSchedule(item) { const content = (item.content || item.title || '').trim() const title = (item.title || content.split('\n')[0] || '无标题').trim().slice(0, 60) - let remindChannels = item.remindChannels + let remindChannels = item.remindChannels || item.remind_channels || [] if (!Array.isArray(remindChannels)) { - remindChannels = item.remindMinutes > 0 ? ['app'] : [] + remindChannels = [] } + remindChannels = remindChannels.map(ch => ch === 'SITE_MSG' ? 'app' : ch.toLowerCase()) + + const datetime = item.schedule_time + ? new Date(item.schedule_time.replace(' ', 'T')).getTime() + : item.datetime || 0 + + const createdAt = item.created_at + ? new Date(item.created_at.replace(' ', 'T')).getTime() + : item.createdAt || datetime + + const updatedAt = item.updated_at + ? new Date(item.updated_at.replace(' ', 'T')).getTime() + : item.updatedAt || datetime + return { ...item, + id: item.id, title, content, remindChannels, - remindMinutes: item.remindMinutes ?? 15 + remindMinutes: item.advance_minutes ?? item.remindMinutes ?? 15, + completed: item.is_finished ?? item.completed ?? false, + datetime, + createdAt, + updatedAt } } -function buildTitle(content) { - const line = (content || '').split('\n')[0].trim() - return line.slice(0, 60) || '无标题' -} - -function seedIfEmpty() { - const raw = uni.getStorageSync(STORAGE_KEY) - if (raw && raw.length) return raw.map(normalizeSchedule) - const now = Date.now() - const today = new Date() - const seeded = [ - { - id: generateId(), - content: '团队周会\n汇报本周进度,讨论下周计划', - datetime: new Date(today.getFullYear(), today.getMonth(), today.getDate(), 10, 0).getTime(), - remindChannels: ['app', 'site'], - remindMinutes: 15, - priority: 'high', - completed: false, - createdAt: now - 86400000, - updatedAt: now - 86400000 - }, - { - id: generateId(), - content: '提交月报\n整理本月数据并发送给主管', - datetime: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1, 18, 0).getTime(), - remindChannels: ['email', 'site'], - remindMinutes: 30, - priority: 'medium', - completed: false, - createdAt: now - 43200000, - updatedAt: now - 43200000 - }, - { - id: generateId(), - content: '健身打卡\n有氧运动 30 分钟', - datetime: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1, 19, 30).getTime(), - remindChannels: ['bark', 'app'], - remindMinutes: 10, - priority: 'low', - completed: true, - createdAt: now - 259200000, - updatedAt: now - 86400000 - } - ].map(normalizeSchedule) - writeLocal(seeded) - return seeded -} - -function sortSchedules(list) { - return [...list].sort((a, b) => { - if (a.completed !== b.completed) return a.completed ? 1 : -1 - return (a.datetime || 0) - (b.datetime || 0) - }) -} - /** * 获取日程列表 */ export async function fetchSchedules(params = {}) { - // TODO: return request({ url: '/api/schedules', data: params }) - let list = seedIfEmpty() - const keyword = (params.keyword || '').trim().toLowerCase() - const status = params.status || 'all' - - if (keyword) { - list = list.filter(item => { - const text = `${item.title} ${item.content}`.toLowerCase() - return text.includes(keyword) - }) - } - if (status === 'pending') list = list.filter(item => !item.completed) - if (status === 'done') list = list.filter(item => item.completed) - - if (params.date) { - const dayStart = startOfDay(params.date).getTime() - const dayEnd = endOfDay(params.date).getTime() - list = list.filter(item => item.datetime >= dayStart && item.datetime <= dayEnd) - } - - list = sortSchedules(list) - return { list, total: list.length } + const data = await request({ + url: '/app/schedule/list', + data: { + page: params.page || 1, + pageSize: params.pageSize || 50, + keyword: params.keyword || '', + status: params.status || '' + } + }) + const list = (data.list || []).map(normalizeSchedule) + return { list, total: data.total || 0 } } /** * 获取单条日程 */ export async function getSchedule(id) { - // TODO: return request({ url: `/api/schedules/${id}` }) - const list = readLocal() - const item = list.find(entry => entry.id === id) - return item ? normalizeSchedule(item) : null + const data = await request({ url: `/app/schedule/${id}` }) + return data ? normalizeSchedule(data) : null } /** * 创建日程 - * @param {{ content: string, datetime: number, remindChannels?: string[], remindMinutes?: number }} data */ export async function createSchedule(data) { - // TODO: return request({ url: '/api/schedules', method: 'POST', data }) - const now = Date.now() - const content = (data.content || '').trim() - const schedule = normalizeSchedule({ - id: generateId(), - title: buildTitle(content), - content, - datetime: data.datetime, - remindChannels: data.remindChannels || [], - remindMinutes: data.remindMinutes ?? 15, - priority: data.priority || 'medium', - completed: false, - createdAt: now, - updatedAt: now + const channels = (data.remindChannels || []).map(ch => { + if (ch === 'app') return 'SITE_MSG' + return ch.toUpperCase() }) - const list = readLocal() - list.push(schedule) - writeLocal(list) - return schedule + const res = await request({ + url: '/app/schedule', + method: 'POST', + data: { + content: data.content, + schedule_time: formatDateTime(data.datetime), + remind_channels: channels, + advance_minutes: data.remindMinutes ?? 15 + } + }) + return res } /** * 更新日程 */ export async function updateSchedule(id, data) { - // TODO: return request({ url: `/api/schedules/${id}`, method: 'PUT', data }) - const list = readLocal() - const index = list.findIndex(item => item.id === id) - if (index === -1) throw new Error('日程不存在') - - const prev = list[index] - const content = data.content !== undefined ? data.content.trim() : prev.content - const schedule = normalizeSchedule({ - ...prev, - ...data, - title: data.content !== undefined ? buildTitle(content) : prev.title, - content, - updatedAt: Date.now() + const channels = (data.remindChannels || []).map(ch => { + if (ch === 'app') return 'SITE_MSG' + return ch.toUpperCase() + }) + await request({ + url: `/app/schedule/${id}`, + method: 'PUT', + data: { + content: data.content, + schedule_time: formatDateTime(data.datetime), + remind_channels: channels, + advance_minutes: data.remindMinutes ?? 15 + } }) - list[index] = schedule - writeLocal(list) - return schedule } /** * 删除日程 */ export async function deleteSchedule(id) { - // TODO: return request({ url: `/api/schedules/${id}`, method: 'DELETE' }) - const list = readLocal().filter(item => item.id !== id) - writeLocal(list) + await request({ url: `/app/schedule/${id}`, method: 'DELETE' }) return true } @@ -212,13 +132,20 @@ export async function deleteSchedule(id) { * 切换完成状态 */ export async function toggleScheduleComplete(id) { - const item = await getSchedule(id) - if (!item) throw new Error('日程不存在') - return updateSchedule(id, { completed: !item.completed }) + const res = await request({ + url: `/app/schedule/${id}/toggle`, + method: 'POST' + }) + return { + ...res, + completed: res?.is_finished ?? false + } } -export function getPriorityMeta(value) { - return PRIORITY[value] || PRIORITY.medium +function formatDateTime(timestamp) { + const d = new Date(timestamp) + const pad = n => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` } export function getRemindChannelLabel(value) { diff --git a/uniapp/pages/dashboard/dashboard.vue b/uniapp/pages/dashboard/dashboard.vue index 8349c84..88fc78e 100644 --- a/uniapp/pages/dashboard/dashboard.vue +++ b/uniapp/pages/dashboard/dashboard.vue @@ -50,13 +50,13 @@ 最近动态 - 查看全部 - - - - + + 暂无动态 + + + {{ item.title }} {{ item.time }} @@ -75,7 +75,11 @@ @@ -294,6 +358,16 @@ function onQuickTap(item) { overflow: hidden; } +.activity-empty { + padding: 48rpx 0; + text-align: center; +} + +.activity-empty-text { + font-size: 26rpx; + color: $color-text-muted; +} + .activity-item { display: flex; align-items: center; @@ -309,10 +383,28 @@ function onQuickTap(item) { } } -.activity-icon { - width: 32rpx; - display: flex; - justify-content: center; +.activity-dot { + width: 16rpx; + height: 16rpx; + border-radius: 50%; + flex-shrink: 0; + background: #909399; + + &.notebook { + background: #67c23a; + } + + &.schedule { + background: $color-primary; + } + + &.erp { + background: #e6a23c; + } + + &.file { + background: #f56c6c; + } } .activity-body { @@ -324,6 +416,9 @@ function onQuickTap(item) { display: block; font-size: 28rpx; color: $color-text; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .activity-time { diff --git a/uniapp/pages/tools/notepad/edit.vue b/uniapp/pages/tools/notepad/edit.vue index cf662b6..c0c4bdc 100644 --- a/uniapp/pages/tools/notepad/edit.vue +++ b/uniapp/pages/tools/notepad/edit.vue @@ -15,16 +15,29 @@ 内容 -