diff --git a/backend/components.d.ts b/backend/components.d.ts index fb754dd..3e73949 100644 --- a/backend/components.d.ts +++ b/backend/components.d.ts @@ -17,6 +17,7 @@ declare module 'vue' { ElAside: typeof import('element-plus/es')['ElAside'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElBacktop: typeof import('element-plus/es')['ElBacktop'] + ElBadge: typeof import('element-plus/es')['ElBadge'] ElButton: typeof import('element-plus/es')['ElButton'] ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup'] ElCard: typeof import('element-plus/es')['ElCard'] @@ -66,6 +67,7 @@ declare module 'vue' { ElTree: typeof import('element-plus/es')['ElTree'] ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect'] ElUpload: typeof import('element-plus/es')['ElUpload'] + MessageDetailDialog: typeof import('./src/components/MessageDetailDialog.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] } diff --git a/backend/src/api/sitereminder.js b/backend/src/api/sitereminder.js new file mode 100644 index 0000000..76029e2 --- /dev/null +++ b/backend/src/api/sitereminder.js @@ -0,0 +1,27 @@ +import request from "@/utils/request"; + +/** 获取我的消息列表 */ +export function getMySiteReminders(params) { + return request({ + url: "/backend/sitereminder/myList", + method: "get", + params, + }); +} + +/** 标记消息为已读 */ +export function readSiteReminder(id) { + return request({ + url: "/backend/sitereminder/read", + method: "post", + data: { id }, + }); +} + +/** 一键全部已读 */ +export function readAllSiteReminders() { + return request({ + url: "/backend/sitereminder/readall", + method: "post", + }); +} diff --git a/backend/src/components/CommonHeader.vue b/backend/src/components/CommonHeader.vue index 32983af..798be93 100644 --- a/backend/src/components/CommonHeader.vue +++ b/backend/src/components/CommonHeader.vue @@ -24,18 +24,48 @@ :title="currentTheme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'" /> - + - - - - - + + + + + + + @@ -65,6 +95,12 @@
+ +
@@ -74,8 +110,10 @@ import { useRouter, useRoute } from "vue-router"; import { useAllDataStore, useMenuStore, useTabsStore } from "@/stores"; import { useAuthStore } from "@/stores/auth"; import { logout } from "@/api/login"; -import { User, SwitchButton, Sunny, Moon, Refresh, Bell, HomeFilled } from '@element-plus/icons-vue'; +import { User, SwitchButton, Sunny, Moon, Refresh, Bell, HomeFilled, Loading, Message } from '@element-plus/icons-vue'; import { ElMessage } from 'element-plus'; +import { getMySiteReminders, readAllSiteReminders } from "@/api/sitereminder"; +import MessageDetailDialog from "./MessageDetailDialog.vue"; const router = useRouter(); const route = useRoute(); @@ -130,7 +168,86 @@ async function refreshCache() { } } -onMounted(loadMenu); +// 站内信消息中心逻辑 +const unreadCount = ref(0); +const messages = ref([]); +const loadingMessages = ref(false); +const detailVisible = ref(false); +const currentReminder = ref({}); + +const formatTime = (timeStr: any) => { + if (!timeStr) return "-"; + const date = new Date(timeStr); + return date.toLocaleString(); +}; + +const fetchUnreadCount = async () => { + if (!authStore.token) return; + try { + const res = await getMySiteReminders({ page: 1, pageSize: 1, isRead: 0 }); + if (res.code === 200) { + unreadCount.value = res.data.total || 0; + } + } catch (err) { + console.error("fetchUnreadCount failed", err); + } +}; + +const fetchMessages = async () => { + if (!authStore.token) return; + loadingMessages.value = true; + try { + const res = await getMySiteReminders({ page: 1, pageSize: 5 }); + if (res.code === 200) { + messages.value = res.data.list || []; + } + } catch (err) { + console.error("fetchMessages failed", err); + } finally { + loadingMessages.value = false; + } +}; + +const handleDropdownVisibleChange = (visible: boolean) => { + if (visible) { + fetchMessages(); + fetchUnreadCount(); + } +}; + +const handleMessageClick = (item: any) => { + currentReminder.value = item; + detailVisible.value = true; +}; + +const handleReadSuccess = (id: number) => { + const msg = messages.value.find(m => m.id === id); + if (msg && msg.is_read === 0) { + msg.is_read = 1; + unreadCount.value = Math.max(0, unreadCount.value - 1); + } + fetchUnreadCount(); +}; + +const handleMarkAllRead = async () => { + try { + const res = await readAllSiteReminders(); + if (res.code === 200) { + ElMessage.success("全部已读标记成功"); + unreadCount.value = 0; + messages.value.forEach(m => m.is_read = 1); + } + } catch (err) { + console.error(err); + } +}; + +const handleMessagesChanged = () => { + fetchUnreadCount(); + fetchMessages(); +}; + +let timer: any = null; // 根据菜单列表和当前路径计算出的面包屑导航 const breadcrumbs = computed(() => { @@ -300,7 +417,8 @@ const themeIcon = computed(() => isDark.value ? Sunny : Moon); let mediaQuery: MediaQueryList | null = null; let handleChange: ((e: MediaQueryListEvent) => void) | null = null; -onMounted(() => { +onMounted(async () => { + await loadMenu(); initTheme(); // 监听系统主题变化 @@ -313,6 +431,12 @@ onMounted(() => { } }; mediaQuery.addEventListener('change', handleChange); + + if (authStore.token) { + fetchUnreadCount(); + timer = setInterval(fetchUnreadCount, 60000); + window.addEventListener('site-messages-changed', handleMessagesChanged); + } }); // 组件卸载时清理 @@ -320,6 +444,10 @@ onUnmounted(() => { if (mediaQuery && handleChange) { mediaQuery.removeEventListener('change', handleChange); } + if (timer) { + clearInterval(timer); + } + window.removeEventListener('site-messages-changed', handleMessagesChanged); }); @@ -487,4 +615,133 @@ onUnmounted(() => { :deep(.el-button) { margin-left: 0 !important; } + +/* 消息中心下拉列表样式 */ +.message-badge { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.message-dropdown-container { + width: 320px; + background-color: var(--el-bg-color-overlay); + border: 1px solid var(--el-border-color-light); + border-radius: 8px; + box-shadow: var(--el-box-shadow-light); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.message-dropdown-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--el-border-color-lighter); + background-color: var(--el-fill-color-blank); + + .title { + font-size: 14px; + font-weight: 600; + color: var(--el-text-color-primary); + } + + .mark-all-btn { + font-size: 12px; + } +} + +.loading-state, +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 30px 0; + color: var(--el-text-color-secondary); + font-size: 13px; + gap: 8px; + + .el-icon { + font-size: 20px; + } +} + +.message-list { + display: flex; + flex-direction: column; +} + +.message-item { + padding: 12px 16px; + cursor: pointer; + transition: background-color 0.2s; + border-bottom: 1px solid var(--el-border-color-extra-light); + text-align: left; /* Ensure it is left-aligned */ + + &:hover { + background-color: var(--el-fill-color-light); + } + + &.unread { + background-color: var(--el-color-primary-light-9); + + &:hover { + background-color: var(--el-color-primary-light-8); + } + + .message-title { + font-weight: 600; + color: var(--el-text-color-primary); + position: relative; + padding-left: 10px; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 6px; + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--el-color-danger); + } + } + } +} + +.message-item-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 4px; + gap: 8px; + + .message-title { + font-size: 13px; + color: var(--el-text-color-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + text-align: left; + } + + .message-time { + font-size: 11px; + color: var(--el-text-color-secondary); + white-space: nowrap; + } +} + +.message-item-brief { + font-size: 12px; + color: var(--el-text-color-regular); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} diff --git a/backend/src/components/MessageDetailDialog.vue b/backend/src/components/MessageDetailDialog.vue new file mode 100644 index 0000000..9cf4878 --- /dev/null +++ b/backend/src/components/MessageDetailDialog.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/docs/改造.md b/docs/改造.md index 8354da5..2068a04 100644 --- a/docs/改造.md +++ b/docs/改造.md @@ -1,10 +1,3 @@ -我完全懂了: -**我只帮你把「你的需求」优化润色成一段标准、清晰、可直接喂给AI的指令**,不替它写代码、不改结构,让AI根据你现有项目自己去改。 - -下面这段你**直接复制发给AI**即可: - ---- - # 【可直接投喂AI·优化版需求说明】 你好,我现在需要对我的项目进行**多租户二级域名绑定官网系统**的整体改造,请根据我的现有项目结构和需求,帮我完成所有代码修改。 diff --git a/go/controllers/backend_sitereminder.go b/go/controllers/backend_sitereminder.go new file mode 100644 index 0000000..d33ac3f --- /dev/null +++ b/go/controllers/backend_sitereminder.go @@ -0,0 +1,151 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + "server/pkg/jwtutil" + "server/services" + + beego "github.com/beego/beego/v2/server/web" +) + +type BackendSiteReminderController struct { + beego.Controller +} + +func (c *BackendSiteReminderController) backendClaims() (*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 *BackendSiteReminderController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +// GetMyList GET /backend/sitereminder/myList +func (c *BackendSiteReminderController) GetMyList() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 10) + var isRead *int8 + if isReadStr := c.GetString("isRead"); isReadStr != "" { + if val, err := strconv.Atoi(isReadStr); err == nil { + v := int8(val) + isRead = &v + } + } + + list, total, err := services.ListReminders(uint64(claims.UserID), "tenant", page, pageSize, isRead) + if err != nil { + c.jsonErr(500, 500, "获取消息列表失败: "+err.Error()) + return + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "list": list, + "total": total, + }, + } + _ = c.ServeJSON() +} + +// MarkRead POST /backend/sitereminder/read +func (c *BackendSiteReminderController) MarkRead() { + claims, err := c.backendClaims() + 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 struct { + ID uint64 `json:"id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + err = services.MarkReminderRead(p.ID, uint64(claims.UserID), "tenant") + if err != nil { + c.jsonErr(500, 500, "操作失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +// MarkAllRead POST /backend/sitereminder/readall +func (c *BackendSiteReminderController) MarkAllRead() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + err = services.MarkAllRemindersRead(uint64(claims.UserID), "tenant") + if err != nil { + c.jsonErr(500, 500, "操作失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +// Delete POST /backend/sitereminder/delete +func (c *BackendSiteReminderController) Delete() { + claims, err := c.backendClaims() + 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 struct { + ID uint64 `json:"id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + err = services.DeleteReminder(p.ID, uint64(claims.UserID), "tenant") + if err != nil { + c.jsonErr(500, 500, "删除失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} diff --git a/go/controllers/platform_email.go b/go/controllers/platform_email.go index b678870..96ea816 100644 --- a/go/controllers/platform_email.go +++ b/go/controllers/platform_email.go @@ -97,12 +97,44 @@ type emailFormPayload struct { Password string `json:"password"` Encryption string `json:"encryption"` Timeout interface{} `json:"timeout"` + Status interface{} `json:"status"` } type testEmailPayload struct { emailFormPayload TestEmail string `json:"testEmail"` } +func parseInt8Flexible(v interface{}) int8 { + if v == nil { + return 1 + } + switch x := v.(type) { + case bool: + if x { + return 1 + } + return 0 + case float64: + return int8(x) + case int: + return int8(x) + case int8: + return x + case string: + s := strings.TrimSpace(x) + if s == "" { + return 1 + } + n, err := strconv.ParseInt(s, 10, 8) + if err != nil { + return 1 + } + return int8(n) + default: + return 1 + } +} + func parseUintFlexible(v interface{}) uint { if v == nil { return 0 @@ -191,7 +223,8 @@ func (c *PlatformEmailController) EditInfo() { s := strings.TrimSpace(p.FromName) fn = &s } - err = services.UpsertFirstSystemEmail(from, fn, host, port, strings.TrimSpace(p.Password), enc, timeout, 1, nil) + status := parseInt8Flexible(p.Status) + err = services.UpsertFirstSystemEmail(from, fn, host, port, strings.TrimSpace(p.Password), enc, timeout, status, nil) if err != nil { c.jsonErr(500, 500, "保存邮箱配置失败: "+err.Error()) return diff --git a/go/controllers/platform_sitereminder.go b/go/controllers/platform_sitereminder.go new file mode 100644 index 0000000..3be3ba8 --- /dev/null +++ b/go/controllers/platform_sitereminder.go @@ -0,0 +1,339 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + "server/pkg/jwtutil" + "server/services" + + beego "github.com/beego/beego/v2/server/web" +) + +type PlatformSiteReminderController struct { + beego.Controller +} + +func (c *PlatformSiteReminderController) platformClaims() (*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 != "platform" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *PlatformSiteReminderController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +// GetConfig GET /platform/sitereminder/config +func (c *PlatformSiteReminderController) GetConfig() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + cfg, err := services.GetSiteReminderConfig() + if err != nil { + c.jsonErr(500, 500, "获取配置失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": cfg} + _ = c.ServeJSON() +} + +// SaveConfig POST /platform/sitereminder/config +func (c *PlatformSiteReminderController) SaveConfig() { + if _, err := c.platformClaims(); 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 struct { + RetentionDays int `json:"retention_days"` + AutoRead int8 `json:"auto_read"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if err := services.SaveSiteReminderConfig(p.RetentionDays, p.AutoRead); err != nil { + c.jsonErr(500, 500, "保存配置失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功"} + _ = c.ServeJSON() +} + +// Send POST /platform/sitereminder/send +func (c *PlatformSiteReminderController) Send() { + claims, err := c.platformClaims() + 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 struct { + Title string `json:"title"` + Content string `json:"content"` + TargetType string `json:"target_type"` // platform, tenant_all, role, tenant + TargetRoleID uint64 `json:"target_role_id"` + TargetTenantID uint64 `json:"target_tenant_id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + p.Title = strings.TrimSpace(p.Title) + p.Content = strings.TrimSpace(p.Content) + if p.Title == "" || p.Content == "" { + c.jsonErr(400, 400, "标题与内容不能为空") + return + } + if p.TargetType == "" { + c.jsonErr(400, 400, "发送目标类型不能为空") + return + } + + err = services.SendSiteReminder(p.Title, p.Content, uint64(claims.UserID), "platform", p.TargetType, p.TargetRoleID, p.TargetTenantID) + if err != nil { + c.jsonErr(500, 500, "发送失败: "+err.Error()) + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "发送成功"} + _ = c.ServeJSON() +} + +// GetMyList GET /platform/sitereminder/myList +func (c *PlatformSiteReminderController) GetMyList() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 10) + var isRead *int8 + if isReadStr := c.GetString("isRead"); isReadStr != "" { + if val, err := strconv.Atoi(isReadStr); err == nil { + v := int8(val) + isRead = &v + } + } + + list, total, err := services.ListReminders(uint64(claims.UserID), "platform", page, pageSize, isRead) + if err != nil { + c.jsonErr(500, 500, "获取消息列表失败: "+err.Error()) + return + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "list": list, + "total": total, + }, + } + _ = c.ServeJSON() +} + +// MarkRead POST /platform/sitereminder/read +func (c *PlatformSiteReminderController) MarkRead() { + claims, err := c.platformClaims() + 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 struct { + ID uint64 `json:"id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + err = services.MarkReminderRead(p.ID, uint64(claims.UserID), "platform") + if err != nil { + c.jsonErr(500, 500, "操作失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +// MarkAllRead POST /platform/sitereminder/readall +func (c *PlatformSiteReminderController) MarkAllRead() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + err = services.MarkAllRemindersRead(uint64(claims.UserID), "platform") + if err != nil { + c.jsonErr(500, 500, "操作失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +// Delete POST /platform/sitereminder/delete +func (c *PlatformSiteReminderController) Delete() { + claims, err := c.platformClaims() + 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 struct { + ID uint64 `json:"id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + err = services.DeleteReminder(p.ID, uint64(claims.UserID), "platform") + if err != nil { + c.jsonErr(500, 500, "删除失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +// GetSentList GET /platform/sitereminder/sentList +func (c *PlatformSiteReminderController) GetSentList() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 10) + + list, total, err := services.ListSentReminders(uint64(claims.UserID), page, pageSize) + if err != nil { + c.jsonErr(500, 500, "获取发送列表失败: "+err.Error()) + return + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "list": list, + "total": total, + }, + } + _ = c.ServeJSON() +} + +// UpdateSent POST /platform/sitereminder/updateSent +func (c *PlatformSiteReminderController) UpdateSent() { + if _, err := c.platformClaims(); 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 struct { + BatchID string `json:"batch_id"` + Title string `json:"title"` + Content string `json:"content"` + TargetType string `json:"target_type"` + TargetRoleID uint64 `json:"target_role_id"` + TargetTenantID uint64 `json:"target_tenant_id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + p.Title = strings.TrimSpace(p.Title) + p.Content = strings.TrimSpace(p.Content) + if p.BatchID == "" || p.Title == "" || p.Content == "" { + c.jsonErr(400, 400, "批次号、标题与内容不能为空") + return + } + if p.TargetType == "" { + c.jsonErr(400, 400, "发送目标类型不能为空") + return + } + + err = services.UpdateSentReminder(p.BatchID, p.Title, p.Content, p.TargetType, p.TargetRoleID, p.TargetTenantID) + if err != nil { + c.jsonErr(500, 500, "修改失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "修改成功"} + _ = c.ServeJSON() +} + +// DeleteSentBatch POST /platform/sitereminder/deleteSent +func (c *PlatformSiteReminderController) DeleteSentBatch() { + if _, err := c.platformClaims(); 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 struct { + BatchID string `json:"batch_id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if p.BatchID == "" { + c.jsonErr(400, 400, "批次号不能为空") + return + } + + err = services.DeleteSentReminderBatch(p.BatchID) + if err != nil { + c.jsonErr(500, 500, "删除失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} diff --git a/go/models/init.go b/go/models/init.go index 47428dd..b218b12 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -66,6 +66,9 @@ func Init(_ string) { new(CmsArticleCategory), new(CmsArticle), + + new(SystemSiteReminder), + new(SystemReminderList), ) // 创建全局 Ormer diff --git a/go/models/system_reminderlist.go b/go/models/system_reminderlist.go new file mode 100644 index 0000000..622b87f --- /dev/null +++ b/go/models/system_reminderlist.go @@ -0,0 +1,26 @@ +package models + +import "time" + +// SystemReminderList 站内信消息列表表 yz_system_reminderlist +type SystemReminderList struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Title string `orm:"column(title);size(255)" json:"title"` + Content string `orm:"column(content);type(text)" json:"content"` + SenderID uint64 `orm:"column(sender_id);default(0)" json:"sender_id"` + SenderType string `orm:"column(sender_type);size(32);default('system')" json:"sender_type"` // system, platform, tenant + ReceiverID uint64 `orm:"column(receiver_id)" json:"receiver_id"` + ReceiverType string `orm:"column(receiver_type);size(32)" json:"receiver_type"` // platform, tenant + IsRead int8 `orm:"column(is_read);default(0)" json:"is_read"` // 0-未读, 1-已读 + ReadTime *time.Time `orm:"column(read_time);type(datetime);null" json:"read_time"` + CreateTime *time.Time `orm:"column(create_time);type(datetime);null" json:"create_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` + BatchID string `orm:"column(batch_id);size(64);default('')" json:"batch_id"` + TargetType string `orm:"column(target_type);size(32);default('')" json:"target_type"` + TargetRoleID uint64 `orm:"column(target_role_id);default(0)" json:"target_role_id"` + TargetTenantID uint64 `orm:"column(target_tenant_id);default(0)" json:"target_tenant_id"` +} + +func (m *SystemReminderList) TableName() string { + return "yz_system_reminderlist" +} diff --git a/go/models/system_sitereminder.go b/go/models/system_sitereminder.go new file mode 100644 index 0000000..c08af03 --- /dev/null +++ b/go/models/system_sitereminder.go @@ -0,0 +1,16 @@ +package models + +import "time" + +// SystemSiteReminder 站内信配置表 yz_system_sitereminder +type SystemSiteReminder struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + RetentionDays int `orm:"column(retention_days);default(30)" json:"retention_days"` + AutoRead int8 `orm:"column(auto_read);default(0)" json:"auto_read"` + CreateTime *time.Time `orm:"column(create_time);type(datetime);null" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` +} + +func (m *SystemSiteReminder) TableName() string { + return "yz_system_sitereminder" +} diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go index 1cc327a..1c5eecc 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -51,6 +51,12 @@ func RegisterAuthRoutes() { beego.Router("/backend/loginVerifyInfos", &controllers.BackendLoginVerifyController{}, "get:GetLoginVerifyInfos") beego.Router("/backend/saveloginVerifyInfos", &controllers.BackendLoginVerifyController{}, "post:SaveLoginVerifyInfos") + // 站内信(yz_system_reminderlist) + beego.Router("/backend/sitereminder/myList", &controllers.BackendSiteReminderController{}, "get:GetMyList") + beego.Router("/backend/sitereminder/read", &controllers.BackendSiteReminderController{}, "post:MarkRead") + beego.Router("/backend/sitereminder/readall", &controllers.BackendSiteReminderController{}, "post:MarkAllRead") + beego.Router("/backend/sitereminder/delete", &controllers.BackendSiteReminderController{}, "post:Delete") + // 文件管理(yz_system_files / yz_system_files_category) beego.Router("/backend/usercate", &controllers.BackendFileController{}, "get:GetUserCate") beego.Router("/backend/allfiles", &controllers.BackendFileController{}, "get:GetAllFiles") diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go index e004672..a754fb1 100644 --- a/go/routers/platform/platform.go +++ b/go/routers/platform/platform.go @@ -126,6 +126,17 @@ func Register() { beego.Router("/platform/email/editinfo", &controllers.PlatformEmailController{}, "post:EditInfo") beego.Router("/platform/email/sendtestemail", &controllers.PlatformEmailController{}, "post:SendTestEmail") + // 站内信配置与发送(yz_system_sitereminder / yz_system_reminderlist) + beego.Router("/platform/sitereminder/config", &controllers.PlatformSiteReminderController{}, "get:GetConfig;post:SaveConfig") + beego.Router("/platform/sitereminder/send", &controllers.PlatformSiteReminderController{}, "post:Send") + beego.Router("/platform/sitereminder/myList", &controllers.PlatformSiteReminderController{}, "get:GetMyList") + beego.Router("/platform/sitereminder/read", &controllers.PlatformSiteReminderController{}, "post:MarkRead") + beego.Router("/platform/sitereminder/readall", &controllers.PlatformSiteReminderController{}, "post:MarkAllRead") + beego.Router("/platform/sitereminder/delete", &controllers.PlatformSiteReminderController{}, "post:Delete") + beego.Router("/platform/sitereminder/sentList", &controllers.PlatformSiteReminderController{}, "get:GetSentList") + beego.Router("/platform/sitereminder/updateSent", &controllers.PlatformSiteReminderController{}, "post:UpdateSent") + beego.Router("/platform/sitereminder/deleteSent", &controllers.PlatformSiteReminderController{}, "post:DeleteSentBatch") + // 短信配置(yz_system_sms) beego.Router("/platform/sms/info", &controllers.PlatformSMSController{}, "get:GetSmsInfo") beego.Router("/platform/sms/editinfo", &controllers.PlatformSMSController{}, "post:EditSmsInfo") diff --git a/go/services/system_email_store.go b/go/services/system_email_store.go index 7defd3d..d2a5ea9 100644 --- a/go/services/system_email_store.go +++ b/go/services/system_email_store.go @@ -25,9 +25,6 @@ func UpsertFirstSystemEmail(fromAddress string, fromName *string, host string, p if timeout == 0 { timeout = 30 } - if status == 0 { - status = 1 - } fromAddress = strings.TrimSpace(fromAddress) host = strings.TrimSpace(host) diff --git a/go/services/system_sitereminder.go b/go/services/system_sitereminder.go new file mode 100644 index 0000000..d0eccc0 --- /dev/null +++ b/go/services/system_sitereminder.go @@ -0,0 +1,399 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/beego/beego/v2/client/orm" + "server/models" +) + +// GetSiteReminderConfig 获取站内信配置(只读首条记录,不存在则初始化默认值) +func GetSiteReminderConfig() (models.SystemSiteReminder, error) { + var row models.SystemSiteReminder + err := models.Orm.QueryTable(new(models.SystemSiteReminder)).OrderBy("id").Limit(1).One(&row) + if err == orm.ErrNoRows { + // 默认配置 + now := time.Now() + row = models.SystemSiteReminder{ + RetentionDays: 30, + AutoRead: 0, + CreateTime: &now, + UpdateTime: &now, + } + _, err = models.Orm.Insert(&row) + if err != nil { + return row, err + } + return row, nil + } + return row, err +} + +// SaveSiteReminderConfig 保存/更新配置 +func SaveSiteReminderConfig(retentionDays int, autoRead int8) error { + if retentionDays <= 0 { + retentionDays = 30 + } + cfg, err := GetSiteReminderConfig() + if err != nil { + return err + } + now := time.Now() + cfg.RetentionDays = retentionDays + cfg.AutoRead = autoRead + cfg.UpdateTime = &now + + _, err = models.Orm.Update(&cfg, "RetentionDays", "AutoRead", "UpdateTime") + return err +} + +// SendSiteReminder 发送站内信 +// targetType: platform (平台端), tenant_all (管理端所有用户), role (平台角色), tenant (特定租户) +func SendSiteReminder(title, content string, senderID uint64, senderType string, targetType string, targetRoleID uint64, targetTenantID uint64) error { + var receiverIDs []uint64 + var receiverType string + + switch targetType { + case "platform": + receiverType = "platform" + var list []models.AdminUser + _, err := models.Orm.QueryTable(new(models.AdminUser)).Filter("status", 1).Filter("delete_time__isnull", true).All(&list, "id") + if err != nil { + return fmt.Errorf("查询平台用户失败: %w", err) + } + for _, u := range list { + receiverIDs = append(receiverIDs, u.ID) + } + case "tenant_all": + receiverType = "tenant" + var list []models.SystemTenantUser + _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("status", 1).Filter("delete_time__isnull", true).All(&list, "id") + if err != nil { + return fmt.Errorf("查询租户用户失败: %w", err) + } + for _, u := range list { + receiverIDs = append(receiverIDs, u.ID) + } + case "role": + receiverType = "platform" + var list []models.AdminUser + _, err := models.Orm.QueryTable(new(models.AdminUser)).Filter("status", 1).Filter("role_id", targetRoleID).Filter("delete_time__isnull", true).All(&list, "id") + if err != nil { + return fmt.Errorf("根据角色查询用户失败: %w", err) + } + for _, u := range list { + receiverIDs = append(receiverIDs, u.ID) + } + case "tenant": + receiverType = "tenant" + var list []models.SystemTenantUser + _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("status", 1).Filter("tid", targetTenantID).Filter("delete_time__isnull", true).All(&list, "id") + if err != nil { + return fmt.Errorf("根据租户查询用户失败: %w", err) + } + for _, u := range list { + receiverIDs = append(receiverIDs, u.ID) + } + default: + return fmt.Errorf("未知的发送目标类型: %s", targetType) + } + + if len(receiverIDs) == 0 { + return nil + } + + now := time.Now() + batchID := fmt.Sprintf("%d_%d", now.UnixNano(), senderID) + var reminders []models.SystemReminderList + for _, rid := range receiverIDs { + reminders = append(reminders, models.SystemReminderList{ + Title: title, + Content: content, + SenderID: senderID, + SenderType: senderType, + ReceiverID: rid, + ReceiverType: receiverType, + IsRead: 0, + CreateTime: &now, + BatchID: batchID, + TargetType: targetType, + TargetRoleID: targetRoleID, + TargetTenantID: targetTenantID, + }) + } + + // 批量插入 + _, err := models.Orm.InsertMulti(100, reminders) + return err +} + +// ListReminders 列表查询 +func ListReminders(receiverID uint64, receiverType string, page, pageSize int, isRead *int8) ([]models.SystemReminderList, int64, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + var list []models.SystemReminderList + qs := models.Orm.QueryTable(new(models.SystemReminderList)). + Filter("receiver_id", receiverID). + Filter("receiver_type", receiverType). + Filter("delete_time__isnull", true) + + if isRead != nil { + qs = qs.Filter("is_read", *isRead) + } + + total, err := qs.Count() + if err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + _, err = qs.OrderBy("-create_time", "-id").Limit(pageSize, offset).All(&list) + return list, total, err +} + +// MarkReminderRead 标记单条已读 +func MarkReminderRead(id uint64, receiverID uint64, receiverType string) error { + now := time.Now() + _, err := models.Orm.QueryTable(new(models.SystemReminderList)). + Filter("id", id). + Filter("receiver_id", receiverID). + Filter("receiver_type", receiverType). + Update(map[string]interface{}{ + "is_read": 1, + "read_time": &now, + }) + return err +} + +// MarkAllRemindersRead 一键全部已读 +func MarkAllRemindersRead(receiverID uint64, receiverType string) error { + now := time.Now() + _, err := models.Orm.QueryTable(new(models.SystemReminderList)). + Filter("receiver_id", receiverID). + Filter("receiver_type", receiverType). + Filter("is_read", 0). + Update(map[string]interface{}{ + "is_read": 1, + "read_time": &now, + }) + return err +} + +// DeleteReminder 删除消息 +func DeleteReminder(id uint64, receiverID uint64, receiverType string) error { + now := time.Now() + _, err := models.Orm.QueryTable(new(models.SystemReminderList)). + Filter("id", id). + Filter("receiver_id", receiverID). + Filter("receiver_type", receiverType). + Update(map[string]interface{}{ + "delete_time": &now, + }) + return err +} + +// AutoCleanExpiredReminders 自动清理过期站内信 +func AutoCleanExpiredReminders() error { + cfg, err := GetSiteReminderConfig() + if err != nil { + return err + } + if cfg.RetentionDays <= 0 { + return nil + } + expireTime := time.Now().AddDate(0, 0, -cfg.RetentionDays) + _, err = models.Orm.QueryTable(new(models.SystemReminderList)). + Filter("create_time__lt", expireTime). + Delete() + return err +} + +// ListSentReminders 获取已发送的消息列表(按 batch_id 分组) +func ListSentReminders(senderID uint64, page, pageSize int) ([]models.SystemReminderList, int64, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + offset := (page - 1) * pageSize + + var total int64 + err := models.Orm.Raw("SELECT COUNT(DISTINCT batch_id) FROM yz_system_reminderlist WHERE sender_id = ? AND delete_time IS NULL", senderID).QueryRow(&total) + if err != nil { + return nil, 0, err + } + + var list []models.SystemReminderList + _, err = models.Orm.Raw("SELECT * FROM yz_system_reminderlist WHERE id IN (SELECT MIN(id) FROM yz_system_reminderlist WHERE sender_id = ? AND delete_time IS NULL GROUP BY batch_id) ORDER BY id DESC LIMIT ? OFFSET ?", senderID, pageSize, offset).QueryRows(&list) + if err != nil { + return nil, 0, err + } + + return list, total, nil +} + +// UpdateSentReminder 更新已发出的消息(更新该批次下所有接收者的消息,支持修改目标接收群体) +func UpdateSentReminder(batchID string, title, content, targetType string, targetRoleID, targetTenantID uint64) error { + // 1. 获取当前发送者ID (从该批次中任意一条记录中获取) + var firstRecord models.SystemReminderList + err := models.Orm.QueryTable(new(models.SystemReminderList)).Filter("batch_id", batchID).Limit(1).One(&firstRecord) + if err != nil { + return fmt.Errorf("找不到该批次的站内信记录: %w", err) + } + senderID := firstRecord.SenderID + senderType := firstRecord.SenderType + + // 2. 根据新的目标接收群体获取接收人列表 + var receiverIDs []uint64 + var receiverType string + + switch targetType { + case "platform": + receiverType = "platform" + var list []models.AdminUser + _, err := models.Orm.QueryTable(new(models.AdminUser)).Filter("status", 1).Filter("delete_time__isnull", true).All(&list, "id") + if err != nil { + return fmt.Errorf("查询平台用户失败: %w", err) + } + for _, u := range list { + receiverIDs = append(receiverIDs, u.ID) + } + case "tenant_all": + receiverType = "tenant" + var list []models.SystemTenantUser + _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("status", 1).Filter("delete_time__isnull", true).All(&list, "id") + if err != nil { + return fmt.Errorf("查询租户用户失败: %w", err) + } + for _, u := range list { + receiverIDs = append(receiverIDs, u.ID) + } + case "role": + receiverType = "platform" + var list []models.AdminUser + _, err := models.Orm.QueryTable(new(models.AdminUser)).Filter("status", 1).Filter("role_id", targetRoleID).Filter("delete_time__isnull", true).All(&list, "id") + if err != nil { + return fmt.Errorf("根据角色查询用户失败: %w", err) + } + for _, u := range list { + receiverIDs = append(receiverIDs, u.ID) + } + case "tenant": + receiverType = "tenant" + var list []models.SystemTenantUser + _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("status", 1).Filter("tid", targetTenantID).Filter("delete_time__isnull", true).All(&list, "id") + if err != nil { + return fmt.Errorf("根据租户查询用户失败: %w", err) + } + for _, u := range list { + receiverIDs = append(receiverIDs, u.ID) + } + default: + return fmt.Errorf("未知的发送目标类型: %s", targetType) + } + + // 3. 获取该批次中现有的所有记录 (包括已删除的) + var existingRecords []models.SystemReminderList + _, err = models.Orm.QueryTable(new(models.SystemReminderList)).Filter("batch_id", batchID).All(&existingRecords) + if err != nil { + return fmt.Errorf("获取现有记录失败: %w", err) + } + + // 建立 map 快速查找,Key 为 "receiverType_receiverID" + existingMap := make(map[string]*models.SystemReminderList) + for i := range existingRecords { + key := fmt.Sprintf("%s_%d", existingRecords[i].ReceiverType, existingRecords[i].ReceiverID) + existingMap[key] = &existingRecords[i] + } + + newReceiverMap := make(map[string]bool) + for _, rid := range receiverIDs { + key := fmt.Sprintf("%s_%d", receiverType, rid) + newReceiverMap[key] = true + } + + now := time.Now() + + // 事务处理 + err = models.Orm.DoTx(func(c context.Context, txOrm orm.TxOrmer) error { + // A. 对于已经不在新接收者列表中的用户,软删除 + for key, rec := range existingMap { + if !newReceiverMap[key] { + if rec.DeleteTime == nil { + rec.DeleteTime = &now + if _, e := txOrm.Update(rec, "DeleteTime"); e != nil { + return e + } + } + } + } + + // B. 对于仍然在新接收者列表中的用户,更新标题、内容、以及 target 信息;如果原来被删除了,清除 delete_time + var newInserts []models.SystemReminderList + for _, rid := range receiverIDs { + key := fmt.Sprintf("%s_%d", receiverType, rid) + if rec, exists := existingMap[key]; exists { + rec.Title = title + rec.Content = content + rec.TargetType = targetType + rec.TargetRoleID = targetRoleID + rec.TargetTenantID = targetTenantID + + cols := []string{"Title", "Content", "TargetType", "TargetRoleID", "TargetTenantID"} + if rec.DeleteTime != nil { + rec.DeleteTime = nil + rec.IsRead = 0 + rec.ReadTime = nil + cols = append(cols, "DeleteTime", "IsRead", "ReadTime") + } + if _, e := txOrm.Update(rec, cols...); e != nil { + return e + } + } else { + // C. 对于新增加的接收者,插入新记录 + newInserts = append(newInserts, models.SystemReminderList{ + Title: title, + Content: content, + SenderID: senderID, + SenderType: senderType, + ReceiverID: rid, + ReceiverType: receiverType, + IsRead: 0, + CreateTime: &now, + BatchID: batchID, + TargetType: targetType, + TargetRoleID: targetRoleID, + TargetTenantID: targetTenantID, + }) + } + } + + if len(newInserts) > 0 { + if _, e := txOrm.InsertMulti(100, newInserts); e != nil { + return e + } + } + + return nil + }) + + return err +} + +// DeleteSentReminderBatch 删除已发送消息(删除该批次下所有记录) +func DeleteSentReminderBatch(batchID string) error { + now := time.Now() + _, err := models.Orm.QueryTable(new(models.SystemReminderList)). + Filter("batch_id", batchID). + Update(map[string]interface{}{ + "delete_time": &now, + }) + return err +} diff --git a/platform/components.d.ts b/platform/components.d.ts index 414b83d..7ffac2a 100644 --- a/platform/components.d.ts +++ b/platform/components.d.ts @@ -17,6 +17,7 @@ declare module 'vue' { ElAside: typeof import('element-plus/es')['ElAside'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElBacktop: typeof import('element-plus/es')['ElBacktop'] + ElBadge: typeof import('element-plus/es')['ElBadge'] ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] ElCascader: typeof import('element-plus/es')['ElCascader'] diff --git a/platform/src/api/sitereminder.js b/platform/src/api/sitereminder.js new file mode 100644 index 0000000..6f6b71b --- /dev/null +++ b/platform/src/api/sitereminder.js @@ -0,0 +1,89 @@ +import request from "@/utils/request"; + +/** 获取站内信配置 */ +export function getSiteReminderConfig() { + return request({ + url: "/platform/sitereminder/config", + method: "get", + }); +} + +/** 保存站内信配置 */ +export function saveSiteReminderConfig(data) { + return request({ + url: "/platform/sitereminder/config", + method: "post", + data, + }); +} + +/** 发送站内信 */ +export function sendSiteReminder(data) { + return request({ + url: "/platform/sitereminder/send", + method: "post", + data, + }); +} + +/** 获取我的消息列表 */ +export function getMySiteReminders(params) { + return request({ + url: "/platform/sitereminder/myList", + method: "get", + params, + }); +} + +/** 标记消息为已读 */ +export function readSiteReminder(id) { + return request({ + url: "/platform/sitereminder/read", + method: "post", + data: { id }, + }); +} + +/** 一键全部已读 */ +export function readAllSiteReminders() { + return request({ + url: "/platform/sitereminder/readall", + method: "post", + }); +} + +/** 删除消息 */ +export function deleteSiteReminder(id) { + return request({ + url: "/platform/sitereminder/delete", + method: "post", + data: { id }, + }); +} + +/** 获取已发送的站内信列表(按 batch_id 分组) */ +export function getSentSiteReminders(params) { + return request({ + url: "/platform/sitereminder/sentList", + method: "get", + params, + }); +} + +/** 编辑/更新已发送的站内信 */ +export function updateSentSiteReminder(data) { + return request({ + url: "/platform/sitereminder/updateSent", + method: "post", + data, + }); +} + +/** 删除已发送的站内信批次 */ +export function deleteSentSiteReminderBatch(batch_id) { + return request({ + url: "/platform/sitereminder/deleteSent", + method: "post", + data: { batch_id }, + }); +} diff --git a/platform/src/components/CommonHeader.vue b/platform/src/components/CommonHeader.vue index 45d1ad6..d7e2664 100644 --- a/platform/src/components/CommonHeader.vue +++ b/platform/src/components/CommonHeader.vue @@ -24,18 +24,48 @@ :title="currentTheme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'" /> - + - - - - - + + + + + + + @@ -66,6 +96,12 @@
+ +
@@ -77,8 +113,10 @@ const emit = defineEmits(['collapse']); import { useAllDataStore, useMenuStore, useTabsStore } from "@/stores"; import { useAuthStore } from "@/stores/auth"; import { logout, getCurrentUser } from "@/api/login"; -import { User, SwitchButton, Sunny, Moon, Refresh, Bell, HomeFilled, Expand } from '@element-plus/icons-vue'; +import { User, SwitchButton, Sunny, Moon, Refresh, Bell, HomeFilled, Expand, Loading, Message } from '@element-plus/icons-vue'; import { ElMessage } from 'element-plus'; +import { getMySiteReminders, readAllSiteReminders } from "@/api/sitereminder"; +import MessageDetailDialog from "@/views/basicSettings/sitereminder/components/detail.vue"; const router = useRouter(); const route = useRoute(); @@ -133,6 +171,87 @@ async function refreshCache() { } } +// 站内信消息中心逻辑 +const unreadCount = ref(0); +const messages = ref([]); +const loadingMessages = ref(false); +const detailVisible = ref(false); +const currentReminder = ref({}); + +const formatTime = (timeStr: any) => { + if (!timeStr) return "-"; + const date = new Date(timeStr); + return date.toLocaleString(); +}; + +const fetchUnreadCount = async () => { + if (!authStore.token) return; + try { + const res = await getMySiteReminders({ page: 1, pageSize: 1, isRead: 0 }); + if (res.code === 200) { + unreadCount.value = res.data.total || 0; + } + } catch (err) { + console.error("fetchUnreadCount failed", err); + } +}; + +const fetchMessages = async () => { + if (!authStore.token) return; + loadingMessages.value = true; + try { + const res = await getMySiteReminders({ page: 1, pageSize: 5 }); + if (res.code === 200) { + messages.value = res.data.list || []; + } + } catch (err) { + console.error("fetchMessages failed", err); + } finally { + loadingMessages.value = false; + } +}; + +const handleDropdownVisibleChange = (visible: boolean) => { + if (visible) { + fetchMessages(); + fetchUnreadCount(); + } +}; + +const handleMessageClick = (item: any) => { + currentReminder.value = item; + detailVisible.value = true; +}; + +const handleReadSuccess = (id: number) => { + const msg = messages.value.find(m => m.id === id); + if (msg && msg.is_read === 0) { + msg.is_read = 1; + unreadCount.value = Math.max(0, unreadCount.value - 1); + } + fetchUnreadCount(); +}; + +const handleMarkAllRead = async () => { + try { + const res = await readAllSiteReminders(); + if (res.code === 200) { + ElMessage.success("全部已读标记成功"); + unreadCount.value = 0; + messages.value.forEach(m => m.is_read = 1); + } + } catch (err) { + console.error(err); + } +}; + +let timer: any = null; + +const handleMessagesChanged = () => { + fetchUnreadCount(); + fetchMessages(); +}; + onMounted(async () => { await loadMenu(); if (!authStore.token) return; @@ -144,6 +263,10 @@ onMounted(async () => { } catch (e) { console.error("getCurrentUser failed", e); } + + fetchUnreadCount(); + timer = setInterval(fetchUnreadCount, 60000); + window.addEventListener('site-messages-changed', handleMessagesChanged); }); // 根据菜单列表和当前路径计算出的面包屑导航 @@ -346,6 +469,10 @@ onUnmounted(() => { if (mediaQuery && handleChange) { mediaQuery.removeEventListener('change', handleChange); } + if (timer) { + clearInterval(timer); + } + window.removeEventListener('site-messages-changed', handleMessagesChanged); }); @@ -565,4 +692,145 @@ onUnmounted(() => { :deep(.el-button) { margin-left: 0 !important; } + +/* 消息中心下拉列表样式 */ +.message-badge { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.message-dropdown-container { + width: 320px; + background-color: var(--el-bg-color-overlay); + border: 1px solid var(--el-border-color-light); + border-radius: 8px; + box-shadow: var(--el-box-shadow-light); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.message-dropdown-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--el-border-color-lighter); + background-color: var(--el-fill-color-blank); + + .title { + font-size: 14px; + font-weight: 600; + color: var(--el-text-color-primary); + } + + .mark-all-btn { + font-size: 12px; + } +} + +.loading-state, +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 30px 0; + color: var(--el-text-color-secondary); + font-size: 13px; + gap: 8px; + + .el-icon { + font-size: 20px; + } +} + +.message-list { + display: flex; + flex-direction: column; +} + +.message-item { + padding: 12px 16px; + cursor: pointer; + transition: background-color 0.2s; + border-bottom: 1px solid var(--el-border-color-extra-light); + text-align: left; /* Ensure it is left-aligned */ + + &:hover { + background-color: var(--el-fill-color-light); + } + + &.unread { + background-color: var(--el-color-primary-light-9); + + &:hover { + background-color: var(--el-color-primary-light-8); + } + + .message-title { + font-weight: 600; + color: var(--el-text-color-primary); + position: relative; + padding-left: 10px; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 6px; + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--el-color-danger); + } + } + } +} + +.message-item-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 4px; + gap: 8px; + + .message-title { + font-size: 13px; + color: var(--el-text-color-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + text-align: left; + } + + .message-time { + font-size: 11px; + color: var(--el-text-color-secondary); + white-space: nowrap; + } +} + +.message-item-brief { + font-size: 12px; + color: var(--el-text-color-regular); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} + +.message-dropdown-footer { + padding: 8px; + border-top: 1px solid var(--el-border-color-lighter); + text-align: center; + background-color: var(--el-fill-color-blank); + + .el-button { + font-size: 13px; + width: 100%; + } +} diff --git a/platform/src/views/basicSettings/sitereminder/components/detail.vue b/platform/src/views/basicSettings/sitereminder/components/detail.vue new file mode 100644 index 0000000..9cf4878 --- /dev/null +++ b/platform/src/views/basicSettings/sitereminder/components/detail.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/platform/src/views/basicSettings/sitereminder/components/edit.vue b/platform/src/views/basicSettings/sitereminder/components/edit.vue new file mode 100644 index 0000000..b14fd81 --- /dev/null +++ b/platform/src/views/basicSettings/sitereminder/components/edit.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/platform/src/views/basicSettings/sitereminder/index.vue b/platform/src/views/basicSettings/sitereminder/index.vue new file mode 100644 index 0000000..88a178e --- /dev/null +++ b/platform/src/views/basicSettings/sitereminder/index.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/platform/src/views/system/email/index.vue b/platform/src/views/system/email/index.vue index 0a97105..a280a71 100644 --- a/platform/src/views/system/email/index.vue +++ b/platform/src/views/system/email/index.vue @@ -75,6 +75,16 @@ /> + + + + { emailForm.password = item.password || ""; emailForm.encryption = item.encryption || "ssl"; emailForm.timeout = item.timeout != null ? item.timeout : 30; + emailForm.status = item.status != null ? item.status : 1; } else { hasSavedConfig.value = false; } @@ -211,6 +223,7 @@ const handleReset = () => { emailForm.password = ""; emailForm.encryption = "ssl"; emailForm.timeout = 30; + emailForm.status = 1; testEmail.value = ""; emailFormRef.value?.clearValidate(); }; diff --git a/platform/src/views/system/platformsettings/components/notificationSettings.vue b/platform/src/views/system/platformsettings/components/notificationSettings.vue index 53af57c..9889001 100644 --- a/platform/src/views/system/platformsettings/components/notificationSettings.vue +++ b/platform/src/views/system/platformsettings/components/notificationSettings.vue @@ -156,7 +156,8 @@ - + + 站内信功能为系统核心功能,必须开启 @@ -393,6 +394,7 @@ import { } from "@element-plus/icons-vue"; import { getSmsInfo, editSmsInfo, sendTestSms } from "@/api/sms"; import { getEmailInfo, editEmailInfo, sendTestEmail } from "@/api/email"; +import { getSiteReminderConfig, saveSiteReminderConfig } from "@/api/sitereminder"; const STORAGE_KEY = "notification_settings_draft"; const activeSubTab = ref("email"); @@ -422,7 +424,7 @@ const formData = reactive({ testPhone: "", }, sitemsg: { - enabled: false, + enabled: true, retention_days: 30, auto_read: false, }, @@ -491,7 +493,7 @@ const loadEmailConfig = async () => { formData.email.password = item.password || ""; formData.email.encryption = item.encryption || "ssl"; formData.email.timeout = item.timeout != null ? item.timeout : 30; - formData.email.enabled = Number(item.enabled ?? 0) === 1; + formData.email.enabled = Number(item.status ?? item.enabled ?? 0) === 1; } } catch (error) { console.error("加载邮箱配置失败:", error); @@ -511,6 +513,19 @@ const loadSmsConfig = async () => { } }; +const loadSiteReminderConfig = async () => { + try { + const res = await getSiteReminderConfig(); + if (res.code === 200 && res.data) { + formData.sitemsg.retention_days = res.data.retention_days ?? 30; + formData.sitemsg.auto_read = res.data.auto_read === 1; + formData.sitemsg.enabled = true; + } + } catch (error) { + console.error("加载站内信配置失败:", error); + } +}; + const saveDraft = () => { localStorage.setItem(STORAGE_KEY, JSON.stringify(formData)); }; @@ -519,7 +534,7 @@ const handleReset = () => { const defaults = { email: { enabled: false, fromAddress: "", fromName: "", host: "", port: "", password: "", encryption: "ssl", timeout: 30, testEmail: "" }, sms: { enabled: false, provider: "aliyun", access_key: "", secret_key: "", sign_name: "", template_code: "", backendUrl: "", apiKey: "", testPhone: "" }, - sitemsg: { enabled: false, retention_days: 30, auto_read: false }, + sitemsg: { enabled: true, retention_days: 30, auto_read: false }, dingtalk: { enabled: false, webhook_url: "", secret: "" }, webhook: { enabled: false, url: "", method: "POST", token: "" }, feishu: { enabled: false, webhook_url: "", secret: "" }, @@ -612,7 +627,7 @@ const handleSubmit = async () => { password: formData.email.password, encryption: formData.email.encryption, timeout: formData.email.timeout, - enabled: formData.email.enabled ? 1 : 0 + status: formData.email.enabled ? 1 : 0 }); if (res.code === 200) { ElMessage.success("邮箱配置保存成功"); @@ -634,6 +649,16 @@ const handleSubmit = async () => { } else { throw new Error(res.msg || "保存自定义短信配置失败"); } + } else if (activeSubTab.value === 'sitemsg') { + const res = await saveSiteReminderConfig({ + retention_days: formData.sitemsg.retention_days, + auto_read: formData.sitemsg.auto_read ? 1 : 0 + }); + if (res.code === 200) { + ElMessage.success("站内信配置保存成功"); + } else { + throw new Error(res.msg || "保存站内信配置失败"); + } } saveDraft(); @@ -649,6 +674,7 @@ onMounted(() => { loadDraft(); loadSmsConfig(); loadEmailConfig(); + loadSiteReminderConfig(); }); diff --git a/sql/upgrade_yz_system_reminderlist.sql b/sql/upgrade_yz_system_reminderlist.sql new file mode 100644 index 0000000..792b6be --- /dev/null +++ b/sql/upgrade_yz_system_reminderlist.sql @@ -0,0 +1,12 @@ +-- 升级已有的 yz_system_reminderlist 表,添加 batch_id 以及发送目标相关字段 +ALTER TABLE `yz_system_reminderlist` + ADD COLUMN `batch_id` varchar(64) NOT NULL DEFAULT '' COMMENT '批次号/分组ID' AFTER `delete_time`, + ADD COLUMN `target_type` varchar(32) NOT NULL DEFAULT '' COMMENT '发送目标类型: platform, tenant_all, role, tenant' AFTER `batch_id`, + ADD COLUMN `target_role_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '目标角色ID' AFTER `target_type`, + ADD COLUMN `target_tenant_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '目标租户ID' AFTER `target_role_id`, + ADD INDEX `idx_batch` (`batch_id`); + +-- 初始化已有记录的 batch_id (如果存在空值,使用 create_time 和 sender_id 进行分组初始化) +UPDATE `yz_system_reminderlist` +SET `batch_id` = CONCAT(UNIX_TIMESTAMP(COALESCE(`create_time`, NOW())), '_', `sender_id`) +WHERE `batch_id` = '' OR `batch_id` IS NULL; diff --git a/sql/yz_system_sitereminder.sql b/sql/yz_system_sitereminder.sql new file mode 100644 index 0000000..e609d58 --- /dev/null +++ b/sql/yz_system_sitereminder.sql @@ -0,0 +1,36 @@ +-- 站内信配置表 +CREATE TABLE IF NOT EXISTS `yz_system_sitereminder` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', + `retention_days` int(11) NOT NULL DEFAULT '30' COMMENT '消息保留天数', + `auto_read` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否自动标记已读: 0-否, 1-是', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='站内信配置表'; + +-- 默认插入一条配置数据 +INSERT INTO `yz_system_sitereminder` (`id`, `retention_days`, `auto_read`, `create_time`, `update_time`) +VALUES (1, 30, 0, NOW(), NOW()) +ON DUPLICATE KEY UPDATE `update_time` = NOW(); + +-- 站内信消息列表表 +CREATE TABLE IF NOT EXISTS `yz_system_reminderlist` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', + `title` varchar(255) NOT NULL COMMENT '标题', + `content` text NOT NULL COMMENT '内容', + `sender_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '发送者ID (0为系统)', + `sender_type` varchar(32) NOT NULL DEFAULT 'system' COMMENT '发送者类型: system, platform, tenant', + `receiver_id` bigint(20) unsigned NOT NULL COMMENT '接收者ID', + `receiver_type` varchar(32) NOT NULL DEFAULT 'receiver' COMMENT '接收者类型: platform, tenant', + `is_read` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否已读: 0-未读, 1-已读', + `read_time` datetime DEFAULT NULL COMMENT '已读时间', + `create_time` datetime DEFAULT NULL COMMENT '创建时间/发送时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间', + `batch_id` varchar(64) NOT NULL DEFAULT '' COMMENT '批次号/分组ID', + `target_type` varchar(32) NOT NULL DEFAULT '' COMMENT '发送目标类型: platform, tenant_all, role, tenant', + `target_role_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '目标角色ID', + `target_tenant_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '目标租户ID', + PRIMARY KEY (`id`), + KEY `idx_receiver` (`receiver_type`, `receiver_id`, `is_read`), + KEY `idx_batch` (`batch_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='站内信消息列表表';