优化站内信模块

This commit is contained in:
扫地僧 2026-06-17 23:07:39 +08:00
parent d3886e2475
commit 22e0e75c35
25 changed files with 2538 additions and 39 deletions

View File

@ -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']
}

View File

@ -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",
});
}

View File

@ -24,18 +24,48 @@
:title="currentTheme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'" />
<!-- 消息中心 -->
<el-dropdown trigger="click">
<el-dropdown trigger="click" @visible-change="handleDropdownVisibleChange">
<span class="el-dropdown-link" style="cursor: pointer;">
<el-badge :value="unreadCount" :max="99" :hidden="unreadCount === 0" class="message-badge">
<el-button circle class="message-btn" title="消息中心">
<el-icon>
<Bell />
</el-icon>
</el-button>
</el-badge>
</span>
<template #dropdown>
<el-dropdown-menu class="message-menu" style="width: 260px;">
<el-dropdown-item disabled>暂无新消息</el-dropdown-item>
</el-dropdown-menu>
<div class="message-dropdown-container">
<div class="message-dropdown-header">
<span class="title">站内通知 ({{ unreadCount }}条未读)</span>
<el-link type="primary" :underline="false" class="mark-all-btn" v-if="unreadCount > 0" @click="handleMarkAllRead">全部已读</el-link>
</div>
<el-scrollbar max-height="300px">
<div v-if="loadingMessages" class="loading-state">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else-if="messages.length === 0" class="empty-state">
<el-icon><Message /></el-icon>
<span>暂无新消息</span>
</div>
<div v-else class="message-list">
<div
v-for="item in messages"
:key="item.id"
class="message-item"
:class="{ unread: item.is_read === 0 }"
@click="handleMessageClick(item)"
>
<div class="message-item-header">
<span class="message-title">{{ item.title }}</span>
<span class="message-time">{{ formatTime(item.create_time) }}</span>
</div>
<div class="message-item-brief">{{ item.content }}</div>
</div>
</div>
</el-scrollbar>
</div>
</template>
</el-dropdown>
@ -65,6 +95,12 @@
</div>
<div class="message-center">
<!-- 详情对话框 -->
<MessageDetailDialog
v-model="detailVisible"
:reminder="currentReminder"
@read-success="handleReadSuccess"
/>
</div>
</template>
@ -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<any[]>([]);
const loadingMessages = ref(false);
const detailVisible = ref(false);
const currentReminder = ref<any>({});
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);
});
</script>
@ -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;
}
</style>

View File

@ -0,0 +1,118 @@
<template>
<el-dialog
v-model="visible"
title="消息详情"
width="600px"
destroy-on-close
align-center
class="reminder-detail-dialog"
>
<div class="reminder-detail-container" v-loading="loading">
<h3 class="reminder-title">{{ reminder.title }}</h3>
<div class="reminder-meta">
<span class="meta-item">
<el-icon><User /></el-icon>
发送者{{ getSenderName(reminder) }}
</span>
<span class="meta-item">
<el-icon><Calendar /></el-icon>
时间{{ formatTime(reminder.create_time) }}
</span>
</div>
<el-divider />
<div class="reminder-content">{{ reminder.content }}</div>
</div>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { User, Calendar } from "@element-plus/icons-vue";
import { readSiteReminder } from "@/api/sitereminder";
const props = defineProps({
modelValue: Boolean,
reminder: {
type: Object,
default: () => ({})
},
viewOnly: {
type: Boolean,
default: false
}
});
const emit = defineEmits(["update:modelValue", "read-success"]);
const visible = computed({
get: () => props.modelValue,
set: (val) => emit("update:modelValue", val)
});
const loading = ref(false);
const getSenderName = (item: any) => {
if (!item.sender_type) return "-";
if (item.sender_type === "system") return "系统";
if (item.sender_type === "platform") return `平台管理员 (ID: ${item.sender_id})`;
if (item.sender_type === "tenant") return `租户用户 (ID: ${item.sender_id})`;
return item.sender_type;
};
const formatTime = (timeStr: any) => {
if (!timeStr) return "-";
const date = new Date(timeStr);
return date.toLocaleString();
};
watch(() => props.modelValue, async (val) => {
if (val && props.reminder && !props.viewOnly && props.reminder.is_read === 0) {
loading.value = true;
try {
const res = await readSiteReminder(props.reminder.id);
if (res.code === 200) {
emit("read-success", props.reminder.id);
}
} catch (err) {
console.error(err);
} finally {
loading.value = false;
}
}
});
</script>
<style scoped>
.reminder-detail-container {
padding: 10px 20px;
}
.reminder-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 12px 0;
color: var(--el-text-color-primary);
line-height: 1.4;
}
.reminder-meta {
display: flex;
gap: 20px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.reminder-content {
font-size: 15px;
line-height: 1.6;
color: var(--el-text-color-regular);
white-space: pre-wrap;
word-break: break-all;
min-height: 120px;
}
</style>

View File

@ -1,10 +1,3 @@
我完全懂了:
**我只帮你把「你的需求」优化润色成一段标准、清晰、可直接喂给AI的指令**不替它写代码、不改结构让AI根据你现有项目自己去改。
下面这段你**直接复制发给AI**即可:
---
# 【可直接投喂AI·优化版需求说明】
你好,我现在需要对我的项目进行**多租户二级域名绑定官网系统**的整体改造,请根据我的现有项目结构和需求,帮我完成所有代码修改。

View File

@ -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()
}

View File

@ -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

View File

@ -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()
}

View File

@ -66,6 +66,9 @@ func Init(_ string) {
new(CmsArticleCategory),
new(CmsArticle),
new(SystemSiteReminder),
new(SystemReminderList),
)
// 创建全局 Ormer

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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")

View File

@ -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")

View File

@ -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)

View File

@ -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
}

View File

@ -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']

View File

@ -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 },
});
}

View File

@ -24,18 +24,48 @@
:title="currentTheme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'" />
<!-- 消息中心 -->
<el-dropdown trigger="click">
<el-dropdown trigger="click" @visible-change="handleDropdownVisibleChange">
<span class="el-dropdown-link" style="cursor: pointer;">
<el-badge :value="unreadCount" :max="99" :hidden="unreadCount === 0" class="message-badge">
<el-button circle class="message-btn" title="消息中心">
<el-icon>
<Bell />
</el-icon>
</el-button>
</el-badge>
</span>
<template #dropdown>
<el-dropdown-menu class="message-menu" style="width: 260px;">
<el-dropdown-item disabled>暂无新消息</el-dropdown-item>
</el-dropdown-menu>
<div class="message-dropdown-container">
<div class="message-dropdown-header">
<span class="title">站内通知 ({{ unreadCount }}条未读)</span>
<el-link type="primary" :underline="false" class="mark-all-btn" v-if="unreadCount > 0" @click="handleMarkAllRead">全部已读</el-link>
</div>
<el-scrollbar max-height="300px">
<div v-if="loadingMessages" class="loading-state">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else-if="messages.length === 0" class="empty-state">
<el-icon><Message /></el-icon>
<span>暂无新消息</span>
</div>
<div v-else class="message-list">
<div
v-for="item in messages"
:key="item.id"
class="message-item"
:class="{ unread: item.is_read === 0 }"
@click="handleMessageClick(item)"
>
<div class="message-item-header">
<span class="message-title">{{ item.title }}</span>
<span class="message-time">{{ formatTime(item.create_time) }}</span>
</div>
<div class="message-item-brief">{{ item.content }}</div>
</div>
</div>
</el-scrollbar>
</div>
</template>
</el-dropdown>
@ -66,6 +96,12 @@
</div>
<div class="message-center">
<!-- 详情对话框 -->
<MessageDetailDialog
v-model="detailVisible"
:reminder="currentReminder"
@read-success="handleReadSuccess"
/>
</div>
</template>
@ -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<any[]>([]);
const loadingMessages = ref(false);
const detailVisible = ref(false);
const currentReminder = ref<any>({});
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);
});
</script>
@ -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%;
}
}
</style>

View File

@ -0,0 +1,118 @@
<template>
<el-dialog
v-model="visible"
title="消息详情"
width="600px"
destroy-on-close
align-center
class="reminder-detail-dialog"
>
<div class="reminder-detail-container" v-loading="loading">
<h3 class="reminder-title">{{ reminder.title }}</h3>
<div class="reminder-meta">
<span class="meta-item">
<el-icon><User /></el-icon>
发送者{{ getSenderName(reminder) }}
</span>
<span class="meta-item">
<el-icon><Calendar /></el-icon>
时间{{ formatTime(reminder.create_time) }}
</span>
</div>
<el-divider />
<div class="reminder-content">{{ reminder.content }}</div>
</div>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { User, Calendar } from "@element-plus/icons-vue";
import { readSiteReminder } from "@/api/sitereminder";
const props = defineProps({
modelValue: Boolean,
reminder: {
type: Object,
default: () => ({})
},
viewOnly: {
type: Boolean,
default: false
}
});
const emit = defineEmits(["update:modelValue", "read-success"]);
const visible = computed({
get: () => props.modelValue,
set: (val) => emit("update:modelValue", val)
});
const loading = ref(false);
const getSenderName = (item: any) => {
if (!item.sender_type) return "-";
if (item.sender_type === "system") return "系统";
if (item.sender_type === "platform") return `平台管理员 (ID: ${item.sender_id})`;
if (item.sender_type === "tenant") return `租户用户 (ID: ${item.sender_id})`;
return item.sender_type;
};
const formatTime = (timeStr: any) => {
if (!timeStr) return "-";
const date = new Date(timeStr);
return date.toLocaleString();
};
watch(() => props.modelValue, async (val) => {
if (val && props.reminder && !props.viewOnly && props.reminder.is_read === 0) {
loading.value = true;
try {
const res = await readSiteReminder(props.reminder.id);
if (res.code === 200) {
emit("read-success", props.reminder.id);
}
} catch (err) {
console.error(err);
} finally {
loading.value = false;
}
}
});
</script>
<style scoped>
.reminder-detail-container {
padding: 10px 20px;
}
.reminder-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 12px 0;
color: var(--el-text-color-primary);
line-height: 1.4;
}
.reminder-meta {
display: flex;
gap: 20px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.reminder-content {
font-size: 15px;
line-height: 1.6;
color: var(--el-text-color-regular);
white-space: pre-wrap;
word-break: break-all;
min-height: 120px;
}
</style>

View File

@ -0,0 +1,278 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑已发站内信' : '发布站内信'"
width="650px"
destroy-on-close
align-center
class="reminder-send-dialog"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
label-position="right"
v-loading="submitting"
>
<el-form-item label="消息标题" prop="title">
<el-input
v-model="form.title"
placeholder="请输入消息标题"
maxlength="100"
show-word-limit
clearable
/>
</el-form-item>
<el-form-item label="接收目标" prop="target_type">
<el-radio-group v-model="form.target_type" @change="handleTargetTypeChange">
<el-radio-button label="platform">平台端</el-radio-button>
<el-radio-button label="tenant_all">管理端 (所有租户)</el-radio-button>
<el-radio-button label="role">平台角色</el-radio-button>
<el-radio-button label="tenant">特定租户</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="form.target_type === 'role'"
label="选择角色"
prop="target_role_id"
:rules="[{ required: true, message: '请选择接收角色', trigger: 'change' }]"
>
<el-select
v-model="form.target_role_id"
placeholder="请选择平台角色"
v-loading="loadingRoles"
style="width: 100%;"
>
<el-option
v-for="item in rolesList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="form.target_type === 'tenant'"
label="选择租户"
prop="target_tenant_id"
:rules="[{ required: true, message: '请选择接收租户', trigger: 'change' }]"
>
<el-select
v-model="form.target_tenant_id"
placeholder="请输入租户名称搜索"
filterable
v-loading="loadingTenants"
style="width: 100%;"
>
<el-option
v-for="item in tenantsList"
:key="item.id"
:label="`${item.name} (${item.tenant_code})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="消息内容" prop="content">
<el-input
v-model="form.content"
type="textarea"
:rows="6"
placeholder="请输入详细的消息内容..."
maxlength="1000"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSend">
<el-icon><Promotion /></el-icon>
{{ isEdit ? '保存修改' : '发送' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
import { Promotion } from "@element-plus/icons-vue";
import { sendSiteReminder, updateSentSiteReminder } from "@/api/sitereminder";
import { getAllRoles } from "@/api/role";
import { getTenantList } from "@/api/tenant";
const props = defineProps({
modelValue: Boolean,
reminder: {
type: Object,
default: () => null
}
});
const emit = defineEmits(["update:modelValue", "success"]);
const visible = computed({
get: () => props.modelValue,
set: (val) => emit("update:modelValue", val)
});
const isEdit = computed(() => !!props.reminder);
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = ref({
title: "",
content: "",
target_type: "platform",
target_role_id: null as number | null,
target_tenant_id: null as number | null
});
const rules: FormRules = {
title: [{ required: true, message: "请输入消息标题", trigger: "blur" }],
content: [{ required: true, message: "请输入消息内容", trigger: "blur" }],
target_type: [{ required: true, message: "请选择接收目标", trigger: "change" }]
};
const rolesList = ref<any[]>([]);
const loadingRoles = ref(false);
const tenantsList = ref<any[]>([]);
const loadingTenants = ref(false);
const handleTargetTypeChange = (val: any) => {
form.value.target_role_id = null;
form.value.target_tenant_id = null;
if (val === "role") {
loadRoles();
} else if (val === "tenant") {
loadTenants();
}
};
const loadRoles = async () => {
if (rolesList.value.length > 0) return;
loadingRoles.value = true;
try {
const res = await getAllRoles();
if (res.code === 200) {
rolesList.value = (res.data || []).filter((r: any) => Number(r.cid) === 1);
}
} catch (err) {
console.error(err);
} finally {
loadingRoles.value = false;
}
};
const loadTenants = async () => {
if (tenantsList.value.length > 0) return;
loadingTenants.value = true;
try {
const res = await getTenantList({ page: 1, pageSize: 1000 });
if (res.code === 200) {
tenantsList.value = res.data?.list || res.data || [];
}
} catch (err) {
console.error(err);
} finally {
loadingTenants.value = false;
}
};
const handleSend = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
submitting.value = true;
try {
if (isEdit.value) {
const res = await updateSentSiteReminder({
batch_id: props.reminder.batch_id,
title: form.value.title,
content: form.value.content,
target_type: form.value.target_type,
target_role_id: form.value.target_role_id || 0,
target_tenant_id: form.value.target_tenant_id || 0
});
if (res.code === 200) {
ElMessage.success("修改成功");
emit("success");
visible.value = false;
} else {
ElMessage.error(res.msg || "修改失败");
}
} else {
const payload = {
title: form.value.title,
content: form.value.content,
target_type: form.value.target_type,
target_role_id: form.value.target_role_id || 0,
target_tenant_id: form.value.target_tenant_id || 0
};
const res = await sendSiteReminder(payload);
if (res.code === 200) {
ElMessage.success("站内信发布成功");
emit("success");
visible.value = false;
} else {
ElMessage.error(res.msg || "发布失败");
}
}
} catch (err: any) {
ElMessage.error(err.message || "操作失败");
} finally {
submitting.value = false;
}
};
watch(() => props.modelValue, (val) => {
if (val) {
if (props.reminder) {
form.value = {
title: props.reminder.title,
content: props.reminder.content,
target_type: props.reminder.target_type || "platform",
target_role_id: props.reminder.target_role_id || null,
target_tenant_id: props.reminder.target_tenant_id || null
};
if (form.value.target_type === "role") {
loadRoles();
} else if (form.value.target_type === "tenant") {
loadTenants();
}
} else {
form.value = {
title: "",
content: "",
target_type: "platform",
target_role_id: null,
target_tenant_id: null
};
rolesList.value = [];
tenantsList.value = [];
}
}
});
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@ -0,0 +1,280 @@
<template>
<div class="container-box">
<div class="header-bar">
<div class="header-title-wrapper">
<h2>站内信发送历史</h2>
<span class="header-subtitle">发布编辑以及管理已发送的站内信</span>
</div>
<div class="header-actions">
<el-button type="primary" @click="handleCompose">
<el-icon><Plus /></el-icon>
发布站内信
</el-button>
<el-button @click="fetchList">
<el-icon><Refresh /></el-icon>
刷新列表
</el-button>
</div>
</div>
<el-divider />
<!-- 列表 Table -->
<el-table
:data="reminderList"
stripe
v-loading="loading"
style="width: 100%; margin-top: 15px;"
row-key="id"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="handleView(row)" style="font-weight: 500;">
{{ row.title }}
</el-link>
</template>
</el-table-column>
<el-table-column label="接收目标" width="220" align="center">
<template #default="{ row }">
<el-tag :type="getTargetTagType(row.target_type)" size="small">
{{ getTargetLabel(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="发布时间" width="180" align="center">
<template #default="{ row }">
{{ formatTime(row.create_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 发布/编辑对话框 -->
<MessageComposeDialog
v-model="composeVisible"
:reminder="editReminder"
@success="handleSuccess"
/>
<!-- 详情对话框 -->
<MessageDetailDialog
v-model="detailVisible"
:reminder="currentReminder"
:view-only="true"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
Plus,
Refresh,
Edit,
Delete,
RefreshLeft
} from "@element-plus/icons-vue";
import {
getSentSiteReminders,
deleteSentSiteReminderBatch
} from "@/api/sitereminder";
import MessageComposeDialog from "./components/edit.vue";
import MessageDetailDialog from "./components/detail.vue";
const loading = ref(false);
const reminderList = ref<any[]>([]);
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
});
//
const composeVisible = ref(false);
const editReminder = ref<any>(null);
const detailVisible = ref(false);
const currentReminder = ref<any>({});
//
const getTargetTagType = (type: string) => {
switch (type) {
case "platform":
return "primary";
case "tenant_all":
return "success";
case "role":
return "warning";
case "tenant":
return "danger";
default:
return "info";
}
};
//
const getTargetLabel = (row: any) => {
switch (row.target_type) {
case "platform":
return "平台端";
case "tenant_all":
return "管理端 (所有租户)";
case "role":
return `平台角色 (ID: ${row.target_role_id})`;
case "tenant":
return `特定租户 (ID: ${row.target_tenant_id})`;
default:
return row.target_type || "未知";
}
};
const formatTime = (timeStr: any) => {
if (!timeStr) return "-";
const date = new Date(timeStr);
return date.toLocaleString();
};
//
const fetchList = async () => {
loading.value = true;
try {
const params = {
page: pagination.page,
pageSize: pagination.pageSize
};
const res = await getSentSiteReminders(params);
if (res.code === 200) {
reminderList.value = res.data.list || [];
pagination.total = res.data.total || 0;
} else {
ElMessage.error(res.msg || "获取历史列表失败");
}
} catch (err: any) {
ElMessage.error(err.message || "获取历史列表失败");
} finally {
loading.value = false;
}
};
//
const handleView = (row: any) => {
currentReminder.value = row;
detailVisible.value = true;
};
//
const handleEdit = (row: any) => {
editReminder.value = row;
composeVisible.value = true;
};
//
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm(
"确定删除该发布批次的站内信吗?删除后接收者将不再看到该消息且无法恢复。",
"提示",
{ type: "warning" }
);
const res = await deleteSentSiteReminderBatch(row.batch_id);
if (res.code === 200) {
ElMessage.success("删除成功");
fetchList();
window.dispatchEvent(new CustomEvent("site-messages-changed"));
} else {
ElMessage.error(res.msg || "删除失败");
}
} catch {
// ignore cancel
}
};
//
const handleSizeChange = (val: number) => {
pagination.pageSize = val;
pagination.page = 1;
fetchList();
};
//
const handleCurrentChange = (val: number) => {
pagination.page = val;
fetchList();
};
//
const handleCompose = () => {
editReminder.value = null;
composeVisible.value = true;
};
// ()
const handleSuccess = () => {
fetchList();
window.dispatchEvent(new CustomEvent("site-messages-changed"));
};
onMounted(() => {
fetchList();
});
</script>
<style scoped>
.container-box {
padding: 24px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-title-wrapper h2 {
margin: 0 0 6px 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.header-subtitle {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.header-actions {
display: flex;
gap: 12px;
}
.pagination-container {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
</style>

View File

@ -75,6 +75,16 @@
/>
</el-form-item>
<el-form-item label="启用状态" prop="status">
<el-switch
v-model="emailForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="关闭"
/>
</el-form-item>
<el-form-item label="测试收件邮箱">
<el-input
v-model="testEmail"
@ -115,7 +125,8 @@ const emailForm = reactive({
port: "",
password: "",
encryption: "ssl",
timeout: 30
timeout: 30,
status: 1
});
const validatePassword = (
@ -154,6 +165,7 @@ const loadEmailConfig = async () => {
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();
};

View File

@ -156,7 +156,8 @@
</template>
<el-form :model="formData.sitemsg" label-width="140px" label-position="right">
<el-form-item label="启用状态">
<el-switch v-model="formData.sitemsg.enabled" />
<el-switch v-model="formData.sitemsg.enabled" disabled />
<span class="form-tip-inline" style="margin-left: 10px;">站内信功能为系统核心功能必须开启</span>
</el-form-item>
<el-form-item label="保留天数">
<el-input-number v-model="formData.sitemsg.retention_days" :min="1" :max="365" controls-position="right" />
@ -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();
});
</script>

View File

@ -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;

View File

@ -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='站内信消息列表表';