Merge branch 'master' of https://git.yunzer.cn/yunzerwebsite/yunzerwebsiteallinone
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
<el-config-provider :locale="zhCn">
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/** 提醒列表 */
|
||||
export function getReminderList(params) {
|
||||
return request({
|
||||
url: "/platform/reminder/list",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 提醒详情 */
|
||||
export function getReminderDetail(id) {
|
||||
return request({
|
||||
url: `/platform/reminder/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/** 新增提醒 */
|
||||
export function createReminder(data) {
|
||||
return request({
|
||||
url: "/platform/reminder",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 更新提醒 */
|
||||
export function updateReminder(id, data) {
|
||||
return request({
|
||||
url: `/platform/reminder/${id}`,
|
||||
method: "put",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除提醒 */
|
||||
export function deleteReminder(id) {
|
||||
return request({
|
||||
url: `/platform/reminder/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量删除提醒 */
|
||||
export function batchDeleteReminder(ids) {
|
||||
return request({
|
||||
url: "/platform/reminder/batchDelete",
|
||||
method: "post",
|
||||
data: { ids },
|
||||
});
|
||||
}
|
||||
|
||||
/** 测试提醒渠道 */
|
||||
export function testReminder(data) {
|
||||
return request({
|
||||
url: "/platform/reminder/test",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 结束提醒 */
|
||||
export function finishReminder(id) {
|
||||
return request({
|
||||
url: `/platform/reminder/${id}/finish`,
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -150,4 +150,41 @@ export function saveStorageConfig(data) {
|
||||
method: "post",
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Bark 配置
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function getBarkConfig() {
|
||||
return request({
|
||||
url: "/platform/bark/info",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Bark 配置
|
||||
* @param {Object} data 要保存的数据
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function saveBarkConfig(data) {
|
||||
return request({
|
||||
url: "/platform/bark/editinfo",
|
||||
method: "post",
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送测试 Bark 推送
|
||||
* @param {Object} data 测试数据
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function sendTestBark(data) {
|
||||
return request({
|
||||
url: "/platform/bark/sendtest",
|
||||
method: "post",
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
@@ -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-button circle class="message-btn" title="消息中心">
|
||||
<el-icon>
|
||||
<Bell />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,849 @@
|
||||
<template>
|
||||
<div class="notification-settings">
|
||||
<div class="settings-content">
|
||||
<el-tabs v-model="activeSubTab" tab-position="left" class="sub-tabs-container">
|
||||
|
||||
<!-- 邮箱 -->
|
||||
<el-tab-pane name="email">
|
||||
<template #label>
|
||||
<span class="tab-label-item">
|
||||
<el-icon><Message /></el-icon>
|
||||
<span>邮箱配置</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="tab-pane-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>邮箱通知配置</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="formData.email" label-width="140px" label-position="right">
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="formData.email.enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="发件人邮箱" required>
|
||||
<el-input v-model="formData.email.fromAddress" placeholder="例如:noreply@example.com" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="发件人名称">
|
||||
<el-input v-model="formData.email.fromName" placeholder="例如:官方网站" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="SMTP 服务器" required>
|
||||
<el-input v-model="formData.email.host" placeholder="例如:smtp.qq.com" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="SMTP 端口" required>
|
||||
<el-input v-model="formData.email.port" placeholder="例如:465 或 587" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="授权码/密码" required>
|
||||
<el-input v-model="formData.email.password" type="password" show-password placeholder="邮箱授权码或登录密码" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="加密方式">
|
||||
<el-radio-group v-model="formData.email.encryption">
|
||||
<el-radio label="ssl">SSL</el-radio>
|
||||
<el-radio label="tls">TLS</el-radio>
|
||||
<el-radio label="none">无加密</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="超时时间(秒)">
|
||||
<el-input-number v-model="formData.email.timeout" :min="1" :max="300" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item label="测试收件邮箱">
|
||||
<el-input v-model="formData.email.testEmail" placeholder="输入邮箱地址用于测试发信" clearable />
|
||||
<div style="margin-top: 8px;">
|
||||
<el-button type="success" plain size="small" :loading="emailTestLoading" @click="handleSendEmailTest">
|
||||
发送测试邮件
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 短信 -->
|
||||
<el-tab-pane name="sms">
|
||||
<template #label>
|
||||
<span class="tab-label-item">
|
||||
<el-icon><Cellphone /></el-icon>
|
||||
<span>短信配置</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="tab-pane-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>短信配置</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="formData.sms" label-width="140px" label-position="right">
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="formData.sms.enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务商">
|
||||
<el-radio-group v-model="formData.sms.provider">
|
||||
<el-radio label="aliyun">阿里云短信</el-radio>
|
||||
<el-radio label="tencent">腾讯云短信</el-radio>
|
||||
<el-radio label="custom">自定义</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 阿里云/腾讯云服务商配置 -->
|
||||
<template v-if="formData.sms.provider !== 'custom'">
|
||||
<el-form-item label="AccessKey ID">
|
||||
<el-input v-model="formData.sms.access_key" placeholder="请输入 AccessKey ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="AccessKey Secret">
|
||||
<el-input v-model="formData.sms.secret_key" type="password" show-password placeholder="请输入 AccessKey Secret" />
|
||||
</el-form-item>
|
||||
<el-form-item label="短信签名">
|
||||
<el-input v-model="formData.sms.sign_name" placeholder="例如:云泽科技" />
|
||||
</el-form-item>
|
||||
<el-form-item label="短信模板 Code">
|
||||
<el-input v-model="formData.sms.template_code" placeholder="例如:SMS_200300400" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 自定义网关配置 -->
|
||||
<template v-else>
|
||||
<el-divider content-position="left">自定义短信网关</el-divider>
|
||||
<el-form-item label="短信网关地址" required>
|
||||
<el-input
|
||||
v-model="formData.sms.backendUrl"
|
||||
placeholder="例如:https://yzsms.yunzer.cn"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="短信API KEY" required>
|
||||
<el-input
|
||||
v-model="formData.sms.apiKey"
|
||||
placeholder="请输入 API_KEY"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="测试手机号">
|
||||
<el-input
|
||||
v-model="formData.sms.testPhone"
|
||||
placeholder="国际格式号码,例如 +8613712345678"
|
||||
clearable
|
||||
/>
|
||||
<div style="margin-top: 8px;">
|
||||
<el-button type="success" plain size="small" :loading="testLoading" @click="handleSendTest">
|
||||
发送测试短信
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
|
||||
<!-- 站内信配置 -->
|
||||
<el-tab-pane name="sitemsg">
|
||||
<template #label>
|
||||
<span class="tab-label-item">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span>站内信配置</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="tab-pane-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>站内信通知配置</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="formData.sitemsg" label-width="140px" label-position="right">
|
||||
<!--
|
||||
<el-form-item label="启用状态">
|
||||
<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" />
|
||||
<span class="form-tip-inline">天(过期消息将自动清理)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="自动标记已读">
|
||||
<el-switch v-model="formData.sitemsg.auto_read" />
|
||||
<div class="form-tip">用户打开消息中心列表时是否自动将所有消息标记为已读</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 钉钉配置 -->
|
||||
<el-tab-pane name="dingtalk">
|
||||
<template #label>
|
||||
<span class="tab-label-item">
|
||||
<el-icon><ChatDotSquare /></el-icon>
|
||||
<span>钉钉配置</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="tab-pane-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>钉钉群机器人配置</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="formData.dingtalk" label-width="140px" label-position="right">
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="formData.dingtalk.enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Webhook 地址">
|
||||
<el-input v-model="formData.dingtalk.webhook_url" type="textarea" :rows="3" placeholder="请输入钉钉机器人的 Webhook 地址 (https://oapi.dingtalk.com/robot/send?access_token=...)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="加签密钥">
|
||||
<el-input v-model="formData.dingtalk.secret" type="password" show-password placeholder="请输入安全配置中加签的密钥(可选)" />
|
||||
<div class="form-tip">如果机器人的安全配置开启了“加签”,请在此处填写对应的秘钥</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Webhook配置 -->
|
||||
<el-tab-pane name="WebHook配置">
|
||||
<template #label>
|
||||
<span class="tab-label-item">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<span>WebHook配置</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="tab-pane-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>HTTP Webhook 配置</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="formData.webhook" label-width="140px" label-position="right">
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="formData.webhook.enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="推送 URL">
|
||||
<el-input v-model="formData.webhook.url" type="textarea" :rows="2" placeholder="请输入通知推送的 Webhook 目标 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="请求方式">
|
||||
<el-select v-model="formData.webhook.method" style="width: 120px">
|
||||
<el-option label="POST" value="POST" />
|
||||
<el-option label="GET" value="GET" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="签名密钥 (Token)">
|
||||
<el-input v-model="formData.webhook.token" type="password" show-password placeholder="请输入用于验证的 Token 密钥" />
|
||||
<div class="form-tip">该 Token 将被包含在请求 Header 的 X-Webhook-Token 中以供接收方校验</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 飞书配置 -->
|
||||
<el-tab-pane name="feishu">
|
||||
<template #label>
|
||||
<span class="tab-label-item">
|
||||
<el-icon><Coordinate /></el-icon>
|
||||
<span>飞书配置</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="tab-pane-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>飞书机器人配置</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="formData.feishu" label-width="140px" label-position="right">
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="formData.feishu.enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Webhook 地址">
|
||||
<el-input v-model="formData.feishu.webhook_url" type="textarea" :rows="3" placeholder="请输入飞书机器人的 Webhook 地址 (https://open.feishu.cn/open-apis/bot/v2/hook/...)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="加签密钥">
|
||||
<el-input v-model="formData.feishu.secret" type="password" show-password placeholder="请输入安全配置中加签的密钥(可选)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Bark配置 -->
|
||||
<el-tab-pane name="bark">
|
||||
<template #label>
|
||||
<span class="tab-label-item">
|
||||
<el-icon><ChatRound /></el-icon>
|
||||
<span>Bark配置</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="tab-pane-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Bark 推送配置</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="formData.bark" label-width="140px" label-position="right">
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="formData.bark.enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="推送服务器">
|
||||
<el-input v-model="formData.bark.server_url" placeholder="默认官方服务:https://api.day.app" />
|
||||
<div class="form-tip">支持自建 Bark 服务,如:https://bark.yourdomain.com</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="设备 Key">
|
||||
<el-input v-model="formData.bark.device_key" placeholder="请输入您的 Bark Device Key" />
|
||||
</el-form-item>
|
||||
<el-form-item label="测试推送">
|
||||
<el-button type="success" plain size="small" :loading="barkTestLoading" @click="handleSendBarkTest">
|
||||
发送测试推送
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 微信配置 -->
|
||||
<el-tab-pane name="wechat">
|
||||
<template #label>
|
||||
<span class="tab-label-item">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<span>微信配置</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="tab-pane-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>微信公众号/企业微信推送</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="formData.wechat" label-width="140px" label-position="right">
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="formData.wechat.enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="AppID / CorpID">
|
||||
<el-input v-model="formData.wechat.app_id" placeholder="请输入公众号 AppID 或企业微信 CorpID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="AppSecret / Secret">
|
||||
<el-input v-model="formData.wechat.app_secret" type="password" show-password placeholder="请输入 AppSecret" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模版ID / AgentID">
|
||||
<el-input v-model="formData.wechat.template_id" placeholder="请输入模版消息 ID 或企业微信应用 AgentID" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Telegram配置 -->
|
||||
<el-tab-pane name="Telegram配置">
|
||||
<template #label>
|
||||
<span class="tab-label-item">
|
||||
<el-icon><Promotion /></el-icon>
|
||||
<span>Telegram配置</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="tab-pane-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Telegram Bot 推送</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="formData.telegram" label-width="140px" label-position="right">
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="formData.telegram.enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Bot Token">
|
||||
<el-input v-model="formData.telegram.bot_token" type="password" show-password placeholder="例如:123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Chat ID">
|
||||
<el-input v-model="formData.telegram.chat_id" placeholder="例如:123456789 或 @my_channel" />
|
||||
<div class="form-tip">支持个人 Chat ID、群组 ID (通常为负数) 或公开频道 Username</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<div class="footer-actions">
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
Message,
|
||||
Cellphone,
|
||||
Bell,
|
||||
ChatDotSquare,
|
||||
Connection,
|
||||
Coordinate,
|
||||
ChatRound,
|
||||
ChatDotRound,
|
||||
Promotion
|
||||
} 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";
|
||||
import { getBarkConfig, saveBarkConfig, sendTestBark } from "@/api/sitesettings";
|
||||
|
||||
const STORAGE_KEY = "notification_settings_draft";
|
||||
const activeSubTab = ref("email");
|
||||
const submitting = ref(false);
|
||||
const barkTestLoading = ref(false);
|
||||
|
||||
const formData = reactive({
|
||||
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: 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: "",
|
||||
},
|
||||
bark: {
|
||||
enabled: false,
|
||||
server_url: "https://api.day.app",
|
||||
device_key: "",
|
||||
},
|
||||
wechat: {
|
||||
enabled: false,
|
||||
app_id: "",
|
||||
app_secret: "",
|
||||
template_id: "",
|
||||
},
|
||||
telegram: {
|
||||
enabled: false,
|
||||
bot_token: "",
|
||||
chat_id: "",
|
||||
}
|
||||
});
|
||||
|
||||
const testLoading = ref(false);
|
||||
|
||||
const emailTestLoading = ref(false);
|
||||
|
||||
const loadDraft = () => {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
for (const key in data) {
|
||||
if (formData[key] && typeof data[key] === 'object') {
|
||||
Object.assign(formData[key], data[key]);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("加载本地通知配置失败", e);
|
||||
}
|
||||
};
|
||||
|
||||
const loadEmailConfig = async () => {
|
||||
try {
|
||||
const res = await getEmailInfo();
|
||||
if (res.code === 200 && Array.isArray(res.data) && res.data.length > 0) {
|
||||
const item = res.data[0];
|
||||
formData.email.fromAddress = item.from_address || "";
|
||||
formData.email.fromName = item.from_name || "";
|
||||
formData.email.host = item.host || "";
|
||||
formData.email.port = item.port != null ? String(item.port) : "";
|
||||
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.status ?? item.enabled ?? 0) === 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载邮箱配置失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSmsConfig = async () => {
|
||||
try {
|
||||
const res = await getSmsInfo();
|
||||
if (res.code === 200 && Array.isArray(res.data) && res.data.length > 0) {
|
||||
const item = res.data[0];
|
||||
formData.sms.backendUrl = item.backend_url || item.backendUrl || "";
|
||||
formData.sms.apiKey = item.api_key || item.apiKey || "";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载自定义短信配置失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
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: 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: "" },
|
||||
bark: { enabled: false, server_url: "https://api.day.app", device_key: "" },
|
||||
wechat: { enabled: false, app_id: "", app_secret: "", template_id: "" },
|
||||
telegram: { enabled: false, bot_token: "", chat_id: "" },
|
||||
};
|
||||
|
||||
const currentTab = activeSubTab.value;
|
||||
Object.assign(formData[currentTab], defaults[currentTab]);
|
||||
saveDraft();
|
||||
ElMessage.success("已重置当前配置");
|
||||
};
|
||||
|
||||
const handleSendEmailTest = async () => {
|
||||
if (!formData.email.testEmail) {
|
||||
ElMessage.warning("请先输入测试收件邮箱");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
emailTestLoading.value = true;
|
||||
const payload = {
|
||||
fromAddress: formData.email.fromAddress,
|
||||
fromName: formData.email.fromName,
|
||||
host: formData.email.host,
|
||||
port: formData.email.port,
|
||||
password: formData.email.password,
|
||||
encryption: formData.email.encryption,
|
||||
timeout: formData.email.timeout,
|
||||
testEmail: formData.email.testEmail
|
||||
};
|
||||
const res = await sendTestEmail(payload);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("测试邮件发送成功");
|
||||
} else {
|
||||
ElMessage.error(res.msg || "测试邮件发送失败,请稍后重试");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "测试邮件发送失败,请稍后重试");
|
||||
} finally {
|
||||
emailTestLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendTest = async () => {
|
||||
const phone = (formData.sms.testPhone || "").trim();
|
||||
if (!phone) {
|
||||
ElMessage.warning("请先输入测试收件手机号");
|
||||
return;
|
||||
}
|
||||
if (!/^\+\d{6,15}$/.test(phone)) {
|
||||
ElMessage.warning("请使用国际格式号码(以 + 开头,后面为数字)");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
testLoading.value = true;
|
||||
const res = await sendTestSms({
|
||||
backendUrl: formData.sms.backendUrl,
|
||||
apiKey: formData.sms.apiKey,
|
||||
phone
|
||||
});
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg || "短信测试任务入队成功");
|
||||
} else {
|
||||
ElMessage.error(res.msg || "短信测试失败,请稍后重试");
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "短信测试失败,请稍后重试");
|
||||
} finally {
|
||||
testLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (activeSubTab.value === 'email') {
|
||||
if (!formData.email.fromAddress || !formData.email.host || !formData.email.port) {
|
||||
ElMessage.error("发件人邮箱、SMTP 服务器和 SMTP 端口不能为空");
|
||||
submitting.value = false;
|
||||
return;
|
||||
}
|
||||
const res = await editEmailInfo({
|
||||
fromAddress: formData.email.fromAddress,
|
||||
fromName: formData.email.fromName,
|
||||
host: formData.email.host,
|
||||
port: formData.email.port,
|
||||
password: formData.email.password,
|
||||
encryption: formData.email.encryption,
|
||||
timeout: formData.email.timeout,
|
||||
status: formData.email.enabled ? 1 : 0
|
||||
});
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("邮箱配置保存成功");
|
||||
} else {
|
||||
throw new Error(res.msg || "保存邮箱配置失败");
|
||||
}
|
||||
} else if (activeSubTab.value === 'sms' && formData.sms.provider === 'custom') {
|
||||
if (!formData.sms.backendUrl || !formData.sms.apiKey) {
|
||||
ElMessage.error("自定义短信网关地址和API KEY不能为空");
|
||||
submitting.value = false;
|
||||
return;
|
||||
}
|
||||
const res = await editSmsInfo({
|
||||
backendUrl: formData.sms.backendUrl,
|
||||
apiKey: formData.sms.apiKey
|
||||
});
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("自定义短信网关配置保存成功");
|
||||
} 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 || "保存站内信配置失败");
|
||||
}
|
||||
} else if (activeSubTab.value === 'bark') {
|
||||
const res = await saveBarkConfig({
|
||||
enabled: formData.bark.enabled,
|
||||
server_url: formData.bark.server_url,
|
||||
device_key: formData.bark.device_key
|
||||
});
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("Bark 配置保存成功");
|
||||
} else {
|
||||
throw new Error(res.msg || "保存 Bark 配置失败");
|
||||
}
|
||||
}
|
||||
|
||||
saveDraft();
|
||||
ElMessage.success("保存配置成功");
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "保存配置失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadBarkConfig = async () => {
|
||||
try {
|
||||
const res = await getBarkConfig();
|
||||
if (res.code === 200 && res.data) {
|
||||
formData.bark.enabled = res.data.enabled;
|
||||
formData.bark.server_url = res.data.server_url || "https://api.day.app";
|
||||
formData.bark.device_key = res.data.device_key || "";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载 Bark 配置失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendBarkTest = async () => {
|
||||
const key = (formData.bark.device_key || "").trim();
|
||||
if (!key) {
|
||||
ElMessage.warning("请先输入 Bark 设备 Key");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
barkTestLoading.value = true;
|
||||
const res = await sendTestBark({
|
||||
server_url: formData.bark.server_url,
|
||||
device_key: key
|
||||
});
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg || "测试推送发送成功");
|
||||
} else {
|
||||
ElMessage.error(res.msg || "测试推送失败,请稍后重试");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "测试推送失败,请稍后重试");
|
||||
} finally {
|
||||
barkTestLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadDraft();
|
||||
loadSmsConfig();
|
||||
loadEmailConfig();
|
||||
loadSiteReminderConfig();
|
||||
loadBarkConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
background-color: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sub-tabs-container {
|
||||
/* height: 520px; */
|
||||
}
|
||||
|
||||
/* Make tabs look extra sleek */
|
||||
:deep(.el-tabs--left .el-tabs__header.is-left) {
|
||||
margin-right: 0;
|
||||
border-right: 1px solid var(--el-border-color-light);
|
||||
background-color: var(--el-fill-color-blank);
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs--left .el-tabs__nav-wrap.is-left) {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item) {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
font-weight: 500;
|
||||
padding: 0 20px !important;
|
||||
color: var(--el-text-color-regular);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item:hover) {
|
||||
color: var(--el-color-primary);
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item.is-active) {
|
||||
color: var(--el-color-primary);
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__active-bar) {
|
||||
width: 3px !important;
|
||||
}
|
||||
|
||||
.tab-label-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-label-item .el-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tab-pane-content {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
padding: 0 0 16px 0;
|
||||
border-bottom: 1px dashed var(--el-border-color-light);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form-tip-inline {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -23,6 +23,14 @@
|
||||
v-if="activeTab === 'storage'"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 通知配置 -->
|
||||
<el-tab-pane label="通知配置" name="notification">
|
||||
<notificationSettings
|
||||
ref="notificationSettingsRef"
|
||||
v-if="activeTab === 'notification'"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,6 +40,7 @@
|
||||
import { ref } from "vue";
|
||||
import platformSettings from "./components/platformSettings.vue";
|
||||
import storageSettings from "./components/storageSettings.vue";
|
||||
import notificationSettings from "./components/notificationSettings.vue";
|
||||
|
||||
const activeTab = ref("platform");
|
||||
</script>
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* 记事本模块 API 测试脚本
|
||||
* 在浏览器控制台中运行此脚本测试 API
|
||||
*/
|
||||
|
||||
// 测试配置
|
||||
const API_BASE = '/platform/notebook';
|
||||
|
||||
// 辅助函数:发送请求
|
||||
async function request(url, options = {}) {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
const data = await response.json();
|
||||
console.log(`✅ ${options.method || 'GET'} ${url}`, data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`❌ ${options.method || 'GET'} ${url}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试函数
|
||||
const NotebookTest = {
|
||||
// 1. 创建笔记
|
||||
async create() {
|
||||
return await request(`${API_BASE}/create`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: '测试笔记 - ' + new Date().toLocaleString(),
|
||||
content: '<p>这是一条测试笔记</p><p>内容包含<strong>富文本</strong>格式</p>',
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
// 2. 获取笔记列表
|
||||
async list(params = {}) {
|
||||
const query = new URLSearchParams({
|
||||
page: params.page || 1,
|
||||
pageSize: params.pageSize || 20,
|
||||
keyword: params.keyword || '',
|
||||
}).toString();
|
||||
return await request(`${API_BASE}/list?${query}`);
|
||||
},
|
||||
|
||||
// 3. 获取笔记详情
|
||||
async detail(id) {
|
||||
return await request(`${API_BASE}/detail/${id}`);
|
||||
},
|
||||
|
||||
// 4. 更新笔记
|
||||
async update(id, data) {
|
||||
return await request(`${API_BASE}/update/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
// 5. 删除笔记
|
||||
async delete(id) {
|
||||
return await request(`${API_BASE}/delete/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
// 完整测试流程
|
||||
async runFullTest() {
|
||||
console.log('🚀 开始测试记事本模块 API...\n');
|
||||
|
||||
try {
|
||||
// 1. 创建笔记
|
||||
console.log('📝 测试1: 创建笔记');
|
||||
const createResult = await this.create();
|
||||
if (createResult.code !== 200) {
|
||||
throw new Error('创建笔记失败');
|
||||
}
|
||||
const noteId = createResult.data.id;
|
||||
console.log(`✅ 创建成功,笔记ID: ${noteId}\n`);
|
||||
|
||||
// 2. 获取笔记列表
|
||||
console.log('📋 测试2: 获取笔记列表');
|
||||
const listResult = await this.list();
|
||||
if (listResult.code !== 200) {
|
||||
throw new Error('获取列表失败');
|
||||
}
|
||||
console.log(`✅ 获取成功,共 ${listResult.data.total} 条笔记\n`);
|
||||
|
||||
// 3. 获取笔记详情
|
||||
console.log('🔍 测试3: 获取笔记详情');
|
||||
const detailResult = await this.detail(noteId);
|
||||
if (detailResult.code !== 200) {
|
||||
throw new Error('获取详情失败');
|
||||
}
|
||||
console.log(`✅ 获取成功,标题: ${detailResult.data.title}\n`);
|
||||
|
||||
// 4. 更新笔记
|
||||
console.log('✏️ 测试4: 更新笔记');
|
||||
const updateResult = await this.update(noteId, {
|
||||
title: '更新后的标题 - ' + new Date().toLocaleString(),
|
||||
content: '<p>这是更新后的内容</p>',
|
||||
});
|
||||
if (updateResult.code !== 200) {
|
||||
throw new Error('更新笔记失败');
|
||||
}
|
||||
console.log(`✅ 更新成功\n`);
|
||||
|
||||
// 5. 搜索笔记
|
||||
console.log('🔎 测试5: 搜索笔记');
|
||||
const searchResult = await this.list({ keyword: '更新' });
|
||||
if (searchResult.code !== 200) {
|
||||
throw new Error('搜索失败');
|
||||
}
|
||||
console.log(`✅ 搜索成功,找到 ${searchResult.data.total} 条匹配笔记\n`);
|
||||
|
||||
// 6. 删除笔记
|
||||
console.log('🗑️ 测试6: 删除笔记');
|
||||
const deleteResult = await this.delete(noteId);
|
||||
if (deleteResult.code !== 200) {
|
||||
throw new Error('删除笔记失败');
|
||||
}
|
||||
console.log(`✅ 删除成功\n`);
|
||||
|
||||
console.log('🎉 所有测试通过!');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ 测试失败:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 导出到全局
|
||||
window.NotebookTest = NotebookTest;
|
||||
|
||||
console.log('📚 记事本模块测试工具已加载');
|
||||
console.log('使用方法:');
|
||||
console.log(' NotebookTest.create() - 创建笔记');
|
||||
console.log(' NotebookTest.list() - 获取列表');
|
||||
console.log(' NotebookTest.detail(id) - 获取详情');
|
||||
console.log(' NotebookTest.update(id, data) - 更新笔记');
|
||||
console.log(' NotebookTest.delete(id) - 删除笔记');
|
||||
console.log(' NotebookTest.runFullTest() - 运行完整测试');
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" title="日程提醒详情" size="560px" destroy-on-close>
|
||||
<div v-if="detail" class="detail-wrap" v-loading="loading">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="ID" label-width="120px">
|
||||
{{ detail.id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="日程发生时间" label-width="120px">
|
||||
{{ detail.schedule_time || '—' }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="日程标题" :span="2" label-width="120px">
|
||||
{{ detail.title }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="提醒渠道" :span="2" label-width="120px">
|
||||
<div class="channel-tags">
|
||||
<el-tag
|
||||
v-for="ch in (detail.remind_channels || [])"
|
||||
:key="ch"
|
||||
:type="channelTagType(ch)"
|
||||
size="small"
|
||||
class="ch-tag"
|
||||
>
|
||||
{{ channelText(ch) }}
|
||||
</el-tag>
|
||||
<span v-if="!(detail.remind_channels?.length)">无渠道</span>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="提前提醒时间" :span="2" label-width="120px">
|
||||
<el-tag type="info" size="small">提前 {{ detail.advance_minutes ?? 0 }} 分钟</el-tag>
|
||||
</el-descriptions-item>
|
||||
|
||||
<template v-if="hasRepeatChannel">
|
||||
<el-descriptions-item label="重复间隔" label-width="120px">
|
||||
{{ detail.repeat_interval_minutes || 0 }} 分钟
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最大发送次数" label-width="120px">
|
||||
{{ detail.max_send_count || 1 }} 次
|
||||
</el-descriptions-item>
|
||||
</template>
|
||||
|
||||
<el-descriptions-item label="日程内容" :span="2" label-width="120px">
|
||||
<div class="content-text">{{ detail.content || '—' }}</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="empty-wrap">
|
||||
<el-skeleton :rows="8" animated />
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-wrap">
|
||||
<el-empty description="暂无数据" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getReminderDetail } from '@/api/reminder'
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const detail = ref(null)
|
||||
|
||||
const channelTextMap = {
|
||||
SMS: '短信',
|
||||
EMAIL: '邮件',
|
||||
BARK: 'Bark 推送',
|
||||
SITE_MSG: '站内信',
|
||||
}
|
||||
|
||||
const channelColorMap = {
|
||||
SMS: 'success',
|
||||
EMAIL: 'warning',
|
||||
BARK: 'danger',
|
||||
SITE_MSG: 'primary',
|
||||
}
|
||||
|
||||
function channelText(ch) {
|
||||
return channelTextMap[ch] || ch
|
||||
}
|
||||
|
||||
function channelTagType(ch) {
|
||||
return channelColorMap[ch] || 'info'
|
||||
}
|
||||
|
||||
const hasRepeatChannel = computed(() => {
|
||||
return detail.value?.remind_channels?.some(ch => ['EMAIL', 'BARK'].includes(ch))
|
||||
})
|
||||
|
||||
async function open(id) {
|
||||
detail.value = null
|
||||
visible.value = true
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getReminderDetail(id)
|
||||
if (res?.code === 200 && res.data) {
|
||||
detail.value = res.data
|
||||
} else {
|
||||
ElMessage.error(res?.msg || '加载失败')
|
||||
visible.value = false
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.detail-wrap {
|
||||
padding: 4px 0 20px;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.empty-wrap {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.channel-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
:title="isAdd ? '新增日程提醒' : '编辑日程提醒'"
|
||||
size="560px"
|
||||
destroy-on-close
|
||||
@closed="onClosed"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="120px"
|
||||
v-loading="loading"
|
||||
label-position="right"
|
||||
class="reminder-form"
|
||||
>
|
||||
<el-form-item label="日程内容" prop="content">
|
||||
<el-input
|
||||
v-model="form.content"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="请输入日程详细内容"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="日程发生时间" prop="schedule_time">
|
||||
<el-date-picker
|
||||
v-model="form.schedule_time"
|
||||
type="datetime"
|
||||
placeholder="请选择发生时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="提醒渠道" prop="remind_channels">
|
||||
<el-checkbox-group v-model="form.remind_channels">
|
||||
<el-checkbox value="SMS">短信 (SMS)</el-checkbox>
|
||||
<el-checkbox value="EMAIL">邮件 (EMAIL)</el-checkbox>
|
||||
<el-checkbox value="BARK">Bark 推送</el-checkbox>
|
||||
<el-checkbox value="SITE_MSG">站内信 (SITE_MSG)</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="提前提醒分钟" prop="advance_minutes">
|
||||
<el-input-number
|
||||
v-model="form.advance_minutes"
|
||||
:min="0"
|
||||
:max="1440"
|
||||
style="width: 100%"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="form-tip">提前多少分钟开始发送第一次提醒</span>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 仅在勾选了邮件或Bark时展示重复配置 -->
|
||||
<template v-if="hasRepeatChannel">
|
||||
<el-divider content-position="left">重复发送设置 (仅EMAIL/BARK生效)</el-divider>
|
||||
|
||||
<el-form-item label="重复间隔(分钟)" prop="repeat_interval_minutes">
|
||||
<el-input-number
|
||||
v-model="form.repeat_interval_minutes"
|
||||
:min="1"
|
||||
:max="1440"
|
||||
style="width: 100%"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="form-tip">未确认前,每隔多少分钟重新发送一次</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="最大发送次数" prop="max_send_count">
|
||||
<el-input-number
|
||||
v-model="form.max_send_count"
|
||||
:min="1"
|
||||
:max="100"
|
||||
style="width: 100%"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="form-tip">防骚扰兜底,发送达到该次数后自动停止</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button
|
||||
type="warning"
|
||||
:loading="testing"
|
||||
:disabled="!form.remind_channels.length"
|
||||
@click="handleTest"
|
||||
>
|
||||
测试通道
|
||||
</el-button>
|
||||
<div style="flex: 1;"></div>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getReminderDetail, createReminder, updateReminder, testReminder } from '@/api/reminder'
|
||||
|
||||
const emit = defineEmits(['saved'])
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const isAdd = ref(true)
|
||||
const formRef = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
content: '',
|
||||
schedule_time: '',
|
||||
remind_channels: [],
|
||||
advance_minutes: 0,
|
||||
repeat_interval_minutes: 10,
|
||||
max_send_count: 5,
|
||||
})
|
||||
|
||||
const rules = {
|
||||
content: [{ required: true, message: '请输入日程内容', trigger: 'blur' }],
|
||||
schedule_time: [{ required: true, message: '请选择日程发生时间', trigger: 'change' }],
|
||||
remind_channels: [{ type: 'array', required: true, message: '请选择至少一个提醒渠道', trigger: 'change' }],
|
||||
repeat_interval_minutes: [{ required: true, message: '请输入重复提醒间隔', trigger: 'blur' }],
|
||||
max_send_count: [{ required: true, message: '请输入最大发送次数', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
// 是否包含了需要确认/重复发送的渠道
|
||||
const hasRepeatChannel = computed(() => {
|
||||
return form.remind_channels.includes('EMAIL') || form.remind_channels.includes('BARK')
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.id = 0
|
||||
form.content = ''
|
||||
form.schedule_time = ''
|
||||
form.remind_channels = []
|
||||
form.advance_minutes = 0
|
||||
form.repeat_interval_minutes = 10
|
||||
form.max_send_count = 5
|
||||
}
|
||||
|
||||
async function open(id) {
|
||||
resetForm()
|
||||
isAdd.value = !id
|
||||
visible.value = true
|
||||
await nextTick()
|
||||
formRef.value?.clearValidate?.()
|
||||
|
||||
if (id) {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getReminderDetail(id)
|
||||
if (res?.code !== 200 || !res.data) {
|
||||
ElMessage.error(res?.msg || '加载失败')
|
||||
visible.value = false
|
||||
return
|
||||
}
|
||||
const d = res.data
|
||||
form.id = d.id
|
||||
form.content = d.content || ''
|
||||
form.schedule_time = d.schedule_time || ''
|
||||
form.remind_channels = d.remind_channels || []
|
||||
form.advance_minutes = d.advance_minutes ?? 0
|
||||
form.repeat_interval_minutes = d.repeat_interval_minutes ?? 10
|
||||
form.max_send_count = d.max_send_count ?? 5
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onClosed() {
|
||||
resetForm()
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
if (form.remind_channels.length === 0) {
|
||||
ElMessage.warning('请先选择至少一个提醒渠道')
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
try {
|
||||
const res = await testReminder({
|
||||
title: '日程提醒',
|
||||
content: form.content || '这是一条验证日程提醒配置的测试通知。',
|
||||
remind_channels: form.remind_channels,
|
||||
})
|
||||
|
||||
if (res?.code === 200 && Array.isArray(res.data)) {
|
||||
const lines = res.data.map(item => {
|
||||
const name = { SMS: '短信', EMAIL: '邮件', BARK: 'Bark推送', SITE_MSG: '站内信' }[item.channel] || item.channel
|
||||
const status = item.success
|
||||
? '<span style="color: #67C23A; font-weight: bold;">发送成功</span>'
|
||||
: `<span style="color: #F56C6C; font-weight: bold;">发送失败 (${item.msg})</span>`
|
||||
return `<p style="margin: 8px 0;"><strong>${name}</strong>: ${status}</p>`
|
||||
}).join('')
|
||||
|
||||
await ElMessageBox.alert(
|
||||
`<div style="font-size: 14px; line-height: 1.6; padding: 10px 0;">${lines}</div>`,
|
||||
'渠道测试结果',
|
||||
{ dangerouslyUseHTMLString: true, confirmButtonText: '确定' }
|
||||
)
|
||||
} else {
|
||||
ElMessage.error(res?.msg || '测试发送失败')
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error('测试出错:' + (err.message || err))
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!formRef.value) return
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
content: form.content,
|
||||
schedule_time: form.schedule_time,
|
||||
remind_channels: form.remind_channels,
|
||||
advance_minutes: Number(form.advance_minutes || 0),
|
||||
repeat_interval_minutes: hasRepeatChannel.value ? Number(form.repeat_interval_minutes || 0) : 0,
|
||||
max_send_count: hasRepeatChannel.value ? Number(form.max_send_count || 1) : 1,
|
||||
}
|
||||
|
||||
let res
|
||||
if (isAdd.value) {
|
||||
res = await createReminder(payload)
|
||||
} else {
|
||||
res = await updateReminder(form.id, payload)
|
||||
}
|
||||
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success(isAdd.value ? '新增成功' : '保存成功')
|
||||
visible.value = false
|
||||
emit('saved')
|
||||
} else {
|
||||
ElMessage.error(res?.msg || '操作失败')
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.reminder-form {
|
||||
padding: 10px 20px 40px 0;
|
||||
}
|
||||
.form-tip {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.el-divider {
|
||||
margin: 24px 0 16px;
|
||||
}
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>日程提醒管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="editRef.open()">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增提醒
|
||||
</el-button>
|
||||
<el-button @click="fetchList" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<el-form :inline="true" :model="searchForm" class="search-form">
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="内容"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 批量操作 -->
|
||||
<div v-if="selectedIds.length > 0" class="batch-bar">
|
||||
<span class="selected-tip">已选 {{ selectedIds.length }} 条</span>
|
||||
<el-button type="danger" size="small" @click="handleBatchDelete">批量删除</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 列表表格 -->
|
||||
<el-table
|
||||
:data="list"
|
||||
v-loading="loading"
|
||||
border
|
||||
style="width: 100%"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="50" align="center" :selectable="checkSelectable" />
|
||||
<el-table-column prop="id" label="ID" width="75" align="center" />
|
||||
<el-table-column prop="content" label="日程提醒内容" min-width="280" show-overflow-tooltip />
|
||||
<el-table-column prop="schedule_time" label="日程发生时间" width="170" align="center" />
|
||||
<el-table-column prop="is_finished" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_finished ? 'info' : 'success'" size="small">
|
||||
{{ row.is_finished ? '已结束' : '提醒中' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remind_channels" label="提醒渠道" min-width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="channel-tags">
|
||||
<el-tag
|
||||
v-for="ch in (row.remind_channels || [])"
|
||||
:key="ch"
|
||||
:type="channelTagType(ch)"
|
||||
size="small"
|
||||
class="ch-tag"
|
||||
>
|
||||
{{ channelText(ch) }}
|
||||
</el-tag>
|
||||
<span v-if="!(row.remind_channels?.length)" style="color: #909399;">无</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="advance_minutes" label="提前提醒" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="info" size="small">提前 {{ row.advance_minutes ?? 0 }} 分</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="handleViewDetail(row)">详情</el-button>
|
||||
<el-button
|
||||
v-if="!row.is_finished"
|
||||
text
|
||||
type="warning"
|
||||
size="small"
|
||||
@click="handleFinish(row)"
|
||||
>
|
||||
结束
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="!row.is_finished"
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="editRef.open(row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="!row.is_finished"
|
||||
text
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="fetchList"
|
||||
@size-change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑抽屉 -->
|
||||
<ReminderEdit ref="editRef" @saved="fetchList" />
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<ReminderDetail ref="detailRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { getReminderList, deleteReminder, batchDeleteReminder, finishReminder } from '@/api/reminder'
|
||||
import ReminderEdit from './components/edit.vue'
|
||||
import ReminderDetail from './components/detail.vue'
|
||||
|
||||
const editRef = ref(null)
|
||||
const detailRef = ref(null)
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const selectedIds = ref([])
|
||||
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const channelTextMap = {
|
||||
SMS: '短信',
|
||||
EMAIL: '邮件',
|
||||
BARK: 'Bark',
|
||||
SITE_MSG: '站内信',
|
||||
}
|
||||
|
||||
const channelColorMap = {
|
||||
SMS: 'success',
|
||||
EMAIL: 'warning',
|
||||
BARK: 'danger',
|
||||
SITE_MSG: 'primary',
|
||||
}
|
||||
|
||||
function channelText(ch) {
|
||||
return channelTextMap[ch] || ch
|
||||
}
|
||||
|
||||
function channelTagType(ch) {
|
||||
return channelColorMap[ch] || 'info'
|
||||
}
|
||||
|
||||
function checkSelectable(row) {
|
||||
return !row.is_finished
|
||||
}
|
||||
|
||||
// 获取列表
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
}
|
||||
const res = await getReminderList(params)
|
||||
if (res?.code === 200 && res.data) {
|
||||
list.value = res.data.list || []
|
||||
pagination.total = res.data.total ?? 0
|
||||
} else {
|
||||
list.value = []
|
||||
ElMessage.error(res?.msg || '加载失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
function resetSearch() {
|
||||
searchForm.keyword = ''
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 多选变化
|
||||
function handleSelectionChange(rows) {
|
||||
selectedIds.value = rows.map((r) => r.id)
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
function handleViewDetail(row) {
|
||||
detailRef.value?.open(row.id)
|
||||
}
|
||||
|
||||
// 结束提醒
|
||||
async function handleFinish(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定结束该日程提醒吗?结束后将不再发送任何提醒。', '提示', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定结束',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const res = await finishReminder(row.id)
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success('已结束')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res?.msg || '结束失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除单条
|
||||
async function handleDelete(row) {
|
||||
if (row.is_finished) {
|
||||
ElMessage.warning('已结束的日程无法删除')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该日程及其所有关联提醒吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const res = await deleteReminder(row.id)
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success('已删除')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res?.msg || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
async function handleBatchDelete() {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定批量删除选中的 ${selectedIds.value.length} 个日程吗?`, '提示', {
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const res = await batchDeleteReminder(selectedIds.value)
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success('已批量删除')
|
||||
selectedIds.value = []
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res?.msg || '批量删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 12px;
|
||||
background: #ecf5ff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #b3d8ff;
|
||||
|
||||
.selected-tip {
|
||||
color: #409eff;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.channel-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ch-tag {
|
||||
margin: 2px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user