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 @@
+
+
+
+
+
+ {{ detail.id }}
+
+
+ {{ detail.schedule_time || '—' }}
+
+
+
+ {{ detail.title }}
+
+
+
+
+
+ {{ channelText(ch) }}
+
+ 无渠道
+
+
+
+
+ 提前 {{ detail.advance_minutes ?? 0 }} 分钟
+
+
+
+
+ {{ detail.repeat_interval_minutes || 0 }} 分钟
+
+
+ {{ detail.max_send_count || 1 }} 次
+
+
+
+
+ {{ detail.content || '—' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 关闭
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 短信 (SMS)
+ 邮件 (EMAIL)
+ Bark 推送
+ 站内信 (SITE_MSG)
+
+
+
+
+
+ 提前多少分钟开始发送第一次提醒
+
+
+
+
+ 重复发送设置 (仅EMAIL/BARK生效)
+
+
+
+ 未确认前,每隔多少分钟重新发送一次
+
+
+
+
+ 防骚扰兜底,发送达到该次数后自动停止
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+
+ 重置
+
+
+
+
+
+ 已选 {{ selectedIds.length }} 条
+ 批量删除
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.is_finished ? '已结束' : '提醒中' }}
+
+
+
+
+
+
+
+ {{ channelText(ch) }}
+
+ 无
+
+
+
+
+
+ 提前 {{ row.advance_minutes ?? 0 }} 分
+
+
+
+
+ 详情
+
+ 结束
+
+
+ 编辑
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
内容
-
- {{ form.content.length }}/5000
+
+
+
+
+ {{ btn.label }}
+
+
+
@@ -57,6 +70,8 @@ import { formatDate } from '@/utils/date.js'
const noteId = ref('')
const saving = ref(false)
+const editorCtx = ref(null)
+const editorFocused = ref(false)
const form = reactive({
title: '',
content: '',
@@ -66,6 +81,18 @@ const meta = reactive({
updatedAt: 0
})
+const toolbarBtns = reactive([
+ { name: 'bold', label: 'B', active: false, value: 'bold' },
+ { name: 'italic', label: 'I', active: false, value: 'italic' },
+ { name: 'underline', label: 'U', active: false, value: 'underline' },
+ { name: 'strike', label: 'S', active: false, value: 'strikeThrough' },
+ { name: 'header', label: 'H', active: false, value: 'header' },
+ { name: 'list', label: '•', active: false, value: 'insertUnorderedList' },
+ { name: 'indent', label: '→', active: false, value: 'indent' },
+ { name: 'outdent', label: '←', active: false, value: 'outdent' },
+ { name: 'divider', label: '—', active: false, value: 'insertHorizontalRule' },
+])
+
onLoad(async (query) => {
if (query?.id) {
noteId.value = query.id
@@ -76,6 +103,30 @@ onLoad(async (query) => {
}
})
+function onEditorReady() {
+ uni.createSelectorQuery().select('#noteEditor').context((res) => {
+ if (res && res.context) {
+ editorCtx.value = res.context
+ if (form.content) {
+ editorCtx.value.setContents({ html: form.content })
+ }
+ }
+ }).exec()
+}
+
+function onEditorInput(e) {
+ form.content = e.detail.html || ''
+}
+
+function execCommand(btn) {
+ if (!editorCtx.value) return
+ if (btn.name === 'header') {
+ editorCtx.value.format('header', 'H2')
+ } else {
+ editorCtx.value.format(btn.value)
+ }
+}
+
async function loadNote(id) {
try {
const note = await getNote(id)
@@ -88,6 +139,9 @@ async function loadNote(id) {
form.content = note.content
form.pinned = !!note.pinned
meta.updatedAt = note.updatedAt
+ if (editorCtx.value && note.content) {
+ editorCtx.value.setContents({ html: note.content })
+ }
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
@@ -147,13 +201,19 @@ function onDelete() {
.page {
min-height: 100vh;
background: $color-bg-page;
- padding: 24rpx $page-padding-x;
- padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
+ display: flex;
+ flex-direction: column;
+ padding: 24rpx $page-padding-x 0;
+ padding-bottom: calc(130rpx + env(safe-area-inset-bottom));
}
.form-card {
@include card;
padding: 8rpx 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
}
.field {
@@ -183,12 +243,57 @@ function onDelete() {
}
.content-field {
- position: relative;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding-bottom: 0;
+ overflow: hidden;
}
-.field-textarea {
- height: 480rpx;
- line-height: 1.7;
+.editor-wrap {
+ background: $color-bg-page;
+ border-radius: $radius-md;
+ overflow: hidden;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.note-editor {
+ width: 100%;
+ flex: 1;
+ min-height: 0;
+ padding: 20rpx;
+ font-size: 28rpx;
+ color: $color-text;
+ box-sizing: border-box;
+ overflow-y: auto;
+}
+
+.editor-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8rpx;
+ padding: 16rpx 20rpx;
+ border-top: 1rpx solid $color-divider;
+ background: $color-card;
+}
+
+.toolbar-btn {
+ padding: 10rpx 20rpx;
+ border-radius: $radius-sm;
+ font-size: 24rpx;
+ color: $color-text-secondary;
+ background: $color-bg-page;
+
+ &.active {
+ color: $color-primary;
+ background: $color-primary-bg;
+ }
+
+ &:active {
+ opacity: 0.7;
+ }
}
.placeholder {
@@ -237,7 +342,7 @@ function onDelete() {
}
.meta {
- margin-top: 20rpx;
+ margin: 20rpx 0;
padding: 0 8rpx;
font-size: 24rpx;
color: $color-text-muted;
@@ -255,6 +360,7 @@ function onDelete() {
background: $color-card;
border-top: 1rpx solid $color-border;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.04);
+ z-index: 10;
}
.btn-primary,
diff --git a/uniapp/pages/tools/notepad/index.vue b/uniapp/pages/tools/notepad/index.vue
index 14dae10..6a6070e 100644
--- a/uniapp/pages/tools/notepad/index.vue
+++ b/uniapp/pages/tools/notepad/index.vue
@@ -111,7 +111,7 @@ function clearSearch() {
}
function preview(content) {
- const text = (content || '').replace(/\s+/g, ' ').trim()
+ const text = (content || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/\s+/g, ' ').trim()
if (!text) return '暂无内容'
return text.length > 60 ? `${text.slice(0, 60)}...` : text
}