fix: 完成: 消息中心

This commit is contained in:
2025-04-12 17:03:31 +08:00
parent 06b8e9cf00
commit 9755463cdc
10 changed files with 2177 additions and 136 deletions

View File

@@ -44,6 +44,7 @@
"@vueuse/core": "catalog:",
"ant-design-vue": "catalog:",
"dayjs": "catalog:",
"markdown-it": "^14.1.0",
"pinia": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:"

View File

@@ -91,11 +91,11 @@ const getNoticeList = async () => {
color: typeColors[item.type],
isRead: item.status
}));
} else {
notifications.value = [];
}
} catch (error) {
console.error('获取消息列表失败:', error);
} finally {
}
};
getNoticeList();

View File

@@ -8,6 +8,12 @@ const prefix = 'notice/';
export async function getNoticeListApi(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/**
* 获取接受消息用户下拉列表
*/
export async function getUserOptionApi() {
return requestClient.get<any>(`${prefix}user-option`);
}
/**
* 查询通知详情
* @param id
@@ -46,3 +52,12 @@ export async function readAllApi() {
);
return result;
}
/**
* 群发消息
* @param data
*/
export async function sendNoticeApi(data: any) {
return requestClient.post<any>(`${prefix}send`, data);
}

View File

@@ -0,0 +1,311 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import {Button} from 'ant-design-vue'
import {
ArrowLeft,
Bell,
CalendarClock,
Clock,
Megaphone,
User,
} from 'lucide-vue-next';
import { formatTimeToRelative } from '#/util/tool';
import { getNoticeDetailApi } from '#/views/notice/api';
const router = useRouter();
const route = useRoute();
const noticeId = ref(route.params.id);
const notice = ref(null);
const loading = ref(true);
const error = ref(null);
const { setTabTitle } = useTabs();
// 消息类型定义
const messageTypes = [
{
id: 0,
name: '系统公告',
icon: Megaphone,
color: 'text-sky-500',
darkColor: 'dark:text-sky-400',
bgColor: 'bg-sky-50',
darkBgColor: 'dark:bg-sky-900/30',
borderColor: 'border-sky-200',
darkBorderColor: 'dark:border-sky-800',
},
{
id: 1,
name: '系统通知',
icon: Bell,
color: 'text-amber-500',
darkColor: 'dark:text-amber-400',
bgColor: 'bg-amber-50',
darkBgColor: 'dark:bg-amber-900/30',
borderColor: 'border-amber-200',
darkBorderColor: 'dark:border-amber-800',
},
{
id: 2,
name: '周报提醒',
icon: CalendarClock,
color: 'text-emerald-500',
darkColor: 'dark:text-emerald-400',
bgColor: 'bg-emerald-50',
darkBgColor: 'dark:bg-emerald-900/30',
borderColor: 'border-emerald-200',
darkBorderColor: 'dark:border-emerald-800',
},
];
// 获取通知详情
const getNoticeDetail = async () => {
loading.value = true;
error.value = null;
try {
const res = await getNoticeDetailApi(noticeId.value);
notice.value = res;
setTabTitle(`通知详情-【${res.id}`);
loading.value = false;
} catch (error_) {
console.error('获取通知详情失败:', error_);
error.value = '获取通知详情失败,请稍后重试';
loading.value = false;
}
};
// 返回列表
const goBack = () => {
router.push('/notice');
};
// 获取类型信息
const getTypeInfo = (typeId) => {
return messageTypes.find((t) => t.id === typeId) || messageTypes[0];
};
// 格式化内容,将换行符转换为<br>
const formatContent = (content) => {
return content ? content.replaceAll('\n', '<br>') : '';
};
onMounted(() => {
getNoticeDetail();
});
const showUnreadList = () => {
alert('未实现')
}
</script>
<template>
<Page>
<template #title>
<div class="flex items-center gap-2">
<button
class="flex h-8 w-8 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300"
@click="goBack"
>
<ArrowLeft class="h-5 w-5" />
</button>
<h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100">
通知详情
</h1>
</div>
</template>
<div class="mx-auto max-w-3xl">
<!-- 加载状态 -->
<div v-if="loading" class="flex justify-center py-20">
<div
class="h-10 w-10 animate-spin rounded-full border-4 border-gray-200 border-t-sky-500 dark:border-gray-700 dark:border-t-sky-400"
></div>
</div>
<!-- 错误状态 -->
<div
v-else-if="error"
class="rounded-lg border border-red-200 bg-red-50 p-4 text-center text-red-600 transition-colors dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
>
{{ error }}
<button
class="mt-2 rounded-md bg-red-100 px-3 py-1 text-sm font-medium text-red-700 transition-colors hover:bg-red-200 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50"
@click="getNoticeDetail"
>
重试
</button>
</div>
<!-- 通知详情 -->
<div v-else-if="notice" class="space-y-6">
<!-- 标题和类型 -->
<div
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-colors dark:border-gray-700 dark:bg-gray-800"
>
<div class="mb-4 flex items-start justify-between">
<h2
class="text-xl font-bold text-gray-900 transition-colors dark:text-gray-100"
>
{{ notice.title }}
</h2>
<div
:class="[
getTypeInfo(notice.type).bgColor,
getTypeInfo(notice.type).darkBgColor,
]"
class="flex items-center gap-1.5 rounded-full px-3 py-1 transition-colors"
>
<component
:is="getTypeInfo(notice.type).icon"
:class="[
getTypeInfo(notice.type).color,
getTypeInfo(notice.type).darkColor,
]"
class="h-4 w-4"
/>
<span
:class="[
getTypeInfo(notice.type).color,
getTypeInfo(notice.type).darkColor,
]"
class="text-xs font-medium"
>
{{ getTypeInfo(notice.type).name }}
</span>
</div>
</div>
<!-- 元信息 -->
<div
class="mb-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-500 transition-colors dark:text-gray-400"
>
<div class="flex items-center gap-1.5">
来自:
<User class="h-4 w-4" />
<span>萧康云医</span>
</div>
<div class="flex items-center gap-1.5">
收件人:
<User class="h-4 w-4" />
<span>{{ notice.admin?.nick_name || '系统' }}</span>
</div>
</div>
<div
class="mb-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-500 transition-colors dark:text-gray-400"
>
<div v-html="formatContent(notice.detail)"></div>
<div class="flex items-center gap-1.5" style="margin-left: auto">
<Clock class="h-4 w-4" />
<span>{{ notice.created_at }}({{
formatTimeToRelative(notice.created_at)
}})</span>
</div>
</div>
<!-- 分隔线 -->
<div
class="mb-6 h-px w-full bg-gray-100 transition-colors dark:bg-gray-700"
></div>
<!-- 内容 -->
<div
class="rounded-lg bg-gray-50 p-4 transition-colors dark:bg-gray-700"
>
<div
v-if="notice.edit_type === 0"
class="prose prose-sm dark:prose-invert max-w-none text-gray-700 transition-colors dark:text-gray-300"
v-html="formatContent(notice.content)"
></div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex justify-between">
<button
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
@click="goBack"
>
<ArrowLeft class="h-4 w-4" />
返回列表
</button>
<div class="flex gap-2">
<!-- 这里可以添加其他操作按钮如删除转发等 -->
<button
v-if="notice.is_my === 1"
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
@click="showUnreadList"
>
{{
notice.is_all_read === 1
? '全部已读'
: `${notice.unread_count} 人未读`
}}
</button>
</div>
</div>
</div>
<!-- 通知不存在 -->
<div
v-else
class="rounded-lg border border-gray-200 bg-white p-8 text-center shadow-sm transition-colors dark:border-gray-700 dark:bg-gray-800"
>
<Bell class="mx-auto mb-4 h-12 w-12 text-gray-300 dark:text-gray-600" />
<h3
class="mb-2 text-lg font-medium text-gray-900 transition-colors dark:text-gray-100"
>
通知不存在
</h3>
<p class="mb-4 text-gray-500 transition-colors dark:text-gray-400">
该通知可能已被删除或您没有权限查看
</p>
<button
class="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-600 dark:bg-sky-600 dark:hover:bg-sky-500"
@click="goBack"
>
返回通知列表
</button>
</div>
</div>
</Page>
</template>
<style scoped>
/* 平滑过渡效果 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 确保内容中的换行正确显示 */
:deep(.prose) {
white-space: pre-line;
}
/* 暗黑模式特有样式 */
.dark .bg-gray-750 {
background-color: #1e293b;
}
.dark .bg-gray-650 {
background-color: #334155;
}
</style>

View File

@@ -1,108 +1,128 @@
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { useRouter } from "vue-router"
import { useRoute } from "vue-router"
import { Page } from "@vben/common-ui"
import { ArrowLeft, Bell, CalendarClock, Clock, Megaphone, User } from "lucide-vue-next"
import { formatTimeToRelative } from "#/util/tool"
import { getNoticeDetailApi } from "#/views/notice/api"
import { useTabs } from "@vben/hooks"
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const router = useRouter()
const route = useRoute()
const noticeId = ref(route.params.id)
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
const notice = ref(null)
const loading = ref(true)
const error = ref(null)
import {Button} from 'ant-design-vue'
const { setTabTitle } = useTabs()
import {
ArrowLeft,
Bell,
CalendarClock,
Clock,
Megaphone,
User,
} from 'lucide-vue-next';
import { formatTimeToRelative } from '#/util/tool';
import { getNoticeDetailApi } from '#/views/notice/api';
// 新增 Markdown 解析相关依赖
// eslint-disable-next-line n/no-extraneous-import
import { marked } from 'marked';
// eslint-disable-next-line n/no-extraneous-import
import DOMPurify from 'dompurify';
const router = useRouter();
const route = useRoute();
const noticeId = ref(route.params.id);
const notice = ref(null);
const loading = ref(true);
const error = ref(null);
const { setTabTitle } = useTabs();
// 消息类型定义
const messageTypes = [
{
id: 0,
name: "系统公告",
name: '系统公告',
icon: Megaphone,
color: "text-sky-500",
darkColor: "dark:text-sky-400",
bgColor: "bg-sky-50",
darkBgColor: "dark:bg-sky-900/30",
borderColor: "border-sky-200",
darkBorderColor: "dark:border-sky-800",
color: 'text-sky-500',
darkColor: 'dark:text-sky-400',
bgColor: 'bg-sky-50',
darkBgColor: 'dark:bg-sky-900/30',
borderColor: 'border-sky-200',
darkBorderColor: 'dark:border-sky-800',
},
{
id: 1,
name: "系统通知",
name: '系统通知',
icon: Bell,
color: "text-amber-500",
darkColor: "dark:text-amber-400",
bgColor: "bg-amber-50",
darkBgColor: "dark:bg-amber-900/30",
borderColor: "border-amber-200",
darkBorderColor: "dark:border-amber-800",
color: 'text-amber-500',
darkColor: 'dark:text-amber-400',
bgColor: 'bg-amber-50',
darkBgColor: 'dark:bg-amber-900/30',
borderColor: 'border-amber-200',
darkBorderColor: 'dark:border-amber-800',
},
{
id: 2,
name: "周报提醒",
name: '周报提醒',
icon: CalendarClock,
color: "text-emerald-500",
darkColor: "dark:text-emerald-400",
bgColor: "bg-emerald-50",
darkBgColor: "dark:bg-emerald-900/30",
borderColor: "border-emerald-200",
darkBorderColor: "dark:border-emerald-800",
color: 'text-emerald-500',
darkColor: 'dark:text-emerald-400',
bgColor: 'bg-emerald-50',
darkBgColor: 'dark:bg-emerald-900/30',
borderColor: 'border-emerald-200',
darkBorderColor: 'dark:border-emerald-800',
},
]
];
// 获取通知详情
const getNoticeDetail = async () => {
loading.value = true
error.value = null
loading.value = true;
error.value = null;
try {
const res = await getNoticeDetailApi(noticeId.value)
notice.value = res
setTabTitle(`通知详情-【${res.id}`)
loading.value = false
} catch (err) {
console.error("获取通知详情失败:", err)
error.value = "获取通知详情失败,请稍后重试"
loading.value = false
const res = await getNoticeDetailApi(noticeId.value);
notice.value = res;
setTabTitle(`通知详情-【${res.id}`);
loading.value = false;
} catch (error_) {
console.error('获取通知详情失败:', error_);
error.value = '获取通知详情失败,请稍后重试';
loading.value = false;
}
}
// 标记通知为已读
const markAsRead = async () => {
try {
// 实际项目中应该调用API
// await markNoticeReadApi(noticeId);
if (notice.value) {
notice.value.status = 1
}
} catch (err) {
console.error("标记已读失败:", err)
}
}
};
// 返回列表
const goBack = () => {
router.push("/notice")
}
router.push('/notice');
};
// 获取类型信息
const getTypeInfo = (typeId) => {
return messageTypes.find((t) => t.id === typeId) || messageTypes[0]
}
return messageTypes.find((t) => t.id === typeId) || messageTypes[0];
};
// 新增 Markdown 解析方法
const parseMarkdown = (content) => {
if (!content) return '';
// 配置 marked可选
marked.setOptions({
gfm: true, // 启用 GitHub Flavored Markdown
breaks: false, // 转换换行符为 <br>
});
// 安全净化 HTML
return DOMPurify.sanitize(marked.parse(content));
};
// 格式化内容,将换行符转换为<br>
const formatContent = (content) => {
return content ? content.replace(/\n/g, "<br>") : ""
}
return content ? content.replaceAll('\n', '<br>') : '';
};
onMounted(() => {
getNoticeDetail()
})
getNoticeDetail();
});
const showUnreadList = () => {
alert('未实现')
}
</script>
<template>
@@ -110,27 +130,34 @@ onMounted(() => {
<template #title>
<div class="flex items-center gap-2">
<button
class="flex h-8 w-8 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300"
@click="goBack"
class="flex h-8 w-8 items-center justify-center rounded-full text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
>
<ArrowLeft class="h-5 w-5" />
</button>
<h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100">通知详情</h1>
<h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100">
通知详情
</h1>
</div>
</template>
<div class="mx-auto max-w-3xl">
<!-- 加载状态 -->
<div v-if="loading" class="flex justify-center py-20">
<div class="h-10 w-10 animate-spin rounded-full border-4 border-gray-200 dark:border-gray-700 border-t-sky-500 dark:border-t-sky-400"></div>
<div
class="h-10 w-10 animate-spin rounded-full border-4 border-gray-200 border-t-sky-500 dark:border-gray-700 dark:border-t-sky-400"
></div>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-4 text-center text-red-600 dark:text-red-400 transition-colors">
<div
v-else-if="error"
class="rounded-lg border border-red-200 bg-red-50 p-4 text-center text-red-600 transition-colors dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
>
{{ error }}
<button
class="mt-2 rounded-md bg-red-100 px-3 py-1 text-sm font-medium text-red-700 transition-colors hover:bg-red-200 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50"
@click="getNoticeDetail"
class="mt-2 rounded-md bg-red-100 dark:bg-red-900/30 px-3 py-1 text-sm font-medium text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors"
>
重试
</button>
@@ -139,9 +166,15 @@ onMounted(() => {
<!-- 通知详情 -->
<div v-else-if="notice" class="space-y-6">
<!-- 标题和类型 -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm transition-colors">
<div
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-colors dark:border-gray-700 dark:bg-gray-800"
>
<div class="mb-4 flex items-start justify-between">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 transition-colors">{{ notice.title }}</h2>
<h2
class="text-xl font-bold text-gray-900 transition-colors dark:text-gray-100"
>
{{ notice.title }}
</h2>
<div
:class="[
getTypeInfo(notice.type).bgColor,
@@ -157,17 +190,22 @@ onMounted(() => {
]"
class="h-4 w-4"
/>
<span :class="[
<span
:class="[
getTypeInfo(notice.type).color,
getTypeInfo(notice.type).darkColor,
]" class="text-xs font-medium">
]"
class="text-xs font-medium"
>
{{ getTypeInfo(notice.type).name }}
</span>
</div>
</div>
<!-- 元信息 -->
<div class="mb-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-500 dark:text-gray-400 transition-colors">
<div
class="mb-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-500 transition-colors dark:text-gray-400"
>
<div class="flex items-center gap-1.5">
来自:
<User class="h-4 w-4" />
@@ -180,28 +218,47 @@ onMounted(() => {
<span>{{ notice.admin?.nick_name || '系统' }}</span>
</div>
</div>
<div class="mb-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-500 dark:text-gray-400 transition-colors">
<div
class="mb-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-500 transition-colors dark:text-gray-400"
>
<div v-html="formatContent(notice.detail)"></div>
<div class="flex items-center gap-1.5" style="margin-left: auto;">
<div class="flex items-center gap-1.5" style="margin-left: auto">
<Clock class="h-4 w-4" />
<span>{{ notice.created_at }}({{ formatTimeToRelative(notice.created_at) }})</span>
<span>{{ notice.created_at }}({{
formatTimeToRelative(notice.created_at)
}})</span>
</div>
</div>
<!-- 分隔线 -->
<div class="mb-6 h-px w-full bg-gray-100 dark:bg-gray-700 transition-colors"></div>
<div
class="mb-6 h-px w-full bg-gray-100 transition-colors dark:bg-gray-700"
></div>
<!-- 内容 -->
<div class="rounded-lg bg-gray-50 dark:bg-gray-700 p-4 transition-colors">
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-700 dark:text-gray-300 transition-colors" v-html="formatContent(notice.content)"></div>
<div
class="rounded-lg bg-gray-50 p-4 transition-colors dark:bg-gray-700"
>
<div
v-if="notice.edit_type === 0"
class="prose prose-sm dark:prose-invert max-w-none text-gray-700 transition-colors dark:text-gray-300"
v-html="formatContent(notice.content)"
></div>
<div
v-else-if="notice.edit_type === 1"
class="markdown-body prose-sm dark:prose-invert max-w-none text-gray-700 transition-colors dark:text-gray-300"
v-html="parseMarkdown(notice.content)"
></div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex justify-between">
<button
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
@click="goBack"
class="flex items-center gap-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<ArrowLeft class="h-4 w-4" />
返回列表
@@ -209,18 +266,39 @@ onMounted(() => {
<div class="flex gap-2">
<!-- 这里可以添加其他操作按钮如删除转发等 -->
<button
v-if="notice.is_my === 1"
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
@click="showUnreadList"
>
{{
notice.is_all_read === 1
? '全部已读'
: `${notice.unread_count} 人未读`
}}
</button>
</div>
</div>
</div>
<!-- 通知不存在 -->
<div v-else class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-8 text-center shadow-sm transition-colors">
<div
v-else
class="rounded-lg border border-gray-200 bg-white p-8 text-center shadow-sm transition-colors dark:border-gray-700 dark:bg-gray-800"
>
<Bell class="mx-auto mb-4 h-12 w-12 text-gray-300 dark:text-gray-600" />
<h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-gray-100 transition-colors">通知不存在</h3>
<p class="mb-4 text-gray-500 dark:text-gray-400 transition-colors">该通知可能已被删除或您没有权限查看</p>
<h3
class="mb-2 text-lg font-medium text-gray-900 transition-colors dark:text-gray-100"
>
通知不存在
</h3>
<p class="mb-4 text-gray-500 transition-colors dark:text-gray-400">
该通知可能已被删除或您没有权限查看
</p>
<button
class="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-600 dark:bg-sky-600 dark:hover:bg-sky-500"
@click="goBack"
class="rounded-md bg-sky-500 dark:bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-600 dark:hover:bg-sky-500 transition-colors"
>
返回通知列表
</button>
@@ -230,6 +308,12 @@ onMounted(() => {
</template>
<style scoped>
/* 新增 Markdown 样式(如果使用 GitHub 风格) */
/* 调整 Markdown 容器样式 */
.markdown-body {
padding: 20px;
}
/* 平滑过渡效果 */
.fade-enter-active,
.fade-leave-active {

View File

@@ -0,0 +1,344 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { QuillEditor } from '@vueup/vue-quill';
import {
Button,
Card,
Input,
message,
Select,
SelectOption,
Textarea,
} from 'ant-design-vue';
import { uploadFile } from '#/api/core/upload';
import { getUserOptionApi, sendNoticeApi } from '#/views/notice/api';
// eslint-disable-next-line n/no-extraneous-import
import '@vueup/vue-quill/dist/vue-quill.snow.css';
// 基础数据定义
const title = ref('');
const detail = ref('');
const content = ref('');
const type = ref(0);
const userIds = ref([]);
const typeOption = ref([
{
label: '公告',
value: 0,
},
{
label: '周报',
value: 2,
},
]);
// 用户选项数据
const userOption = ref([]);
// 创建对QuillEditor的引用用于后续获取Quill实例
const quillEditorRef = ref(null);
// 获取用户选项数据的函数
const getUserOption = () => {
getUserOptionApi().then((res) => {
userOption.value = res;
});
};
getUserOption();
// 发送消息的函数
const send = () => {
sendNoticeApi({
title: title.value,
type: type.value,
detail: detail.value,
content: content.value,
user_ids: userIds.value,
}).then(() => {
detail.value = '';
content.value = '';
userIds.value = [];
title.value = '';
// 清除富文本编辑器内容
quillEditorRef.value?.getQuill().setContents([]);
message.success('发送成功');
});
};
const isUpload = ref(false);
// 富文本编辑器配置选项
const editorOptions = {
theme: 'snow',
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'],
[{ header: 1 }, { header: 2 }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ script: 'sub' }, { script: 'super' }],
[{ indent: '-1' }, { indent: '+1' }],
[{ direction: 'rtl' }],
[{ size: ['small', false, 'large', 'huge'] }],
[{ header: [1, 2, 3, 4, 5, 6, false] }],
[{ color: [] }, { background: [] }],
[{ font: [] }],
[{ align: [] }],
['clean'],
['link', 'image', 'video'],
],
},
placeholder: '请输入消息内容',
};
/**
* 图片上传函数
* @param {File} file - 要上传的图片文件
* @returns {Promise<string>} - 返回上传后的图片URL
*/
const uploadImage = async (file: File): Promise<string> => {
try {
// 创建FormData对象用于发送文件数据
const formData = new FormData();
formData.append('file', file);
// 显示上传中的提示
message.loading({ content: '图片上传中...', key: 'imageUpload' });
return uploadFile({
file,
}).then((data: any) => {
message.success({ content: '图片上传成功', key: 'imageUpload', duration: 2 });
isUpload.value = false;
return data.url;
});
} catch (error) {
// 处理上传错误
console.error('图片上传错误:', error);
message.error({ content: '图片上传失败', key: 'imageUpload', duration: 2 });
return '';
}
};
/**
* 从剪贴板数据中提取图片文件
* @param {ClipboardEvent} event - 剪贴板事件
* @returns {File|null} - 返回图片文件或null
*/
const getImageFromClipboard = (event: ClipboardEvent): File | null => {
const clipboardData = event.clipboardData;
if (!clipboardData) return null;
// 遍历剪贴板中的所有项目
const items = clipboardData.items;
for (const item of items) {
// 检查是否是图片类型
if (item.type.includes('image')) {
return item.getAsFile();
}
}
return null;
};
/**
* 在编辑器中插入图片
* @param {any} quill - Quill编辑器实例
* @param {string} imageUrl - 图片URL
*/
const insertImageToEditor = (quill: any, imageUrl: string): void => {
if (!quill) return;
// 获取当前光标位置
const range = quill.getSelection();
if (range) {
// 在光标位置插入图片
quill.insertEmbed(range.index, 'image', imageUrl);
// 将光标移动到图片后面
quill.setSelection(range.index + 1);
} else {
// 如果没有选择范围,则在文档末尾插入
const length = quill.getLength();
quill.insertEmbed(length - 1, 'image', imageUrl);
quill.setSelection(length);
}
};
// 组件挂载后设置剪贴板事件监听
onMounted(() => {
// 等待DOM更新后获取编辑器元素
setTimeout(() => {
// 获取Quill编辑器实例
const quill = quillEditorRef.value?.getQuill();
if (!quill) {
console.error('无法获取Quill编辑器实例');
return;
}
// 为编辑器添加粘贴事件监听器
const editorElement = document.querySelector('.ql-editor');
if (editorElement) {
editorElement.addEventListener('paste', async (event) => {
// 从剪贴板获取图片
const imageFile = getImageFromClipboard(event as ClipboardEvent);
if (imageFile) {
// 阻止默认粘贴行为,以便我们可以自定义处理
event.preventDefault();
if (isUpload.value === true) {
return;
}
isUpload.value = true;
alert('2')
// 上传图片到服务器
const imageUrl = await uploadImage(imageFile);
// 如果成功获取到图片URL则将图片插入到编辑器中
if (imageUrl) {
insertImageToEditor(quill, imageUrl);
}
}
// 如果不是图片,则让默认粘贴行为继续
});
console.log('已设置编辑器粘贴事件监听器');
} else {
console.error('无法找到编辑器元素');
}
}, 500); // 给予足够的时间让编辑器渲染完成
});
/**
* 直接处理编辑器的粘贴事件
* 这是一个备用方法通过Quill的clipboard模块直接处理粘贴事件
*/
const setupQuillPasteHandler = () => {
// 确保编辑器已经挂载
if (!quillEditorRef.value) return;
const quill = quillEditorRef.value.getQuill();
if (!quill) return;
// 获取Quill的clipboard模块
const clipboard = quill.getModule('clipboard');
// 保存原始的粘贴处理函数
const originalMatchers = clipboard.matchers;
// 重写粘贴处理函数
clipboard.addMatcher('img', (node: any, delta: any) => {
// 这里可以处理HTML中的img标签
// 但对于直接粘贴的图片文件,这个方法不会被触发
return delta;
});
// 监听编辑器的paste事件
quill.root.addEventListener('paste', async (e: ClipboardEvent) => {
const imageFile = getImageFromClipboard(e);
if (imageFile) {
e.preventDefault();
if (isUpload.value === true) {
return;
}
isUpload.value = true;
const imageUrl = await uploadImage(imageFile);
if (imageUrl) {
insertImageToEditor(quill, imageUrl);
}
}
});
};
</script>
<template>
<Page title="通知群发">
<Card class="w-1/2" style="margin: 0 auto" title="发送消息">
<!-- 消息标题输入框 -->
<Input v-model:value="title" placeholder="请输入消息标题" />
<!-- 消息类型选择器 -->
<Select
v-model:value="type"
class="mt-5 w-full"
placeholder="请选择消息类型"
>
<template v-for="item in typeOption" :key="item.value">
<SelectOption :value="item.value">{{ item.label }}</SelectOption>
</template>
</Select>
<!-- 用户选择器仅在类型为2时显示 -->
<Select
v-if="type === 2"
v-model:value="userIds"
class="mt-5 w-full"
mode="multiple"
placeholder="请选择接受消息的用户"
>
<template v-for="item in userOption" :key="item.value">
<SelectOption :value="item.id">{{ item.nick_name }}</SelectOption>
</template>
</Select>
<!-- 消息简介输入框 -->
<Textarea
v-model:value="detail"
class="mt-5"
placeholder="请输入消息简介"
/>
<!-- 富文本编辑器 -->
<div class="mt-5">
<!--
使用ref属性获取编辑器实例
content-type="html"表示内容以HTML格式存储
theme="snow"使用雪主题样式
-->
<QuillEditor
ref="quillEditorRef"
v-model:content="content"
:options="editorOptions"
class="editor-container"
content-type="html"
theme="snow"
@ready="setupQuillPasteHandler"
/>
</div>
<!-- 发送按钮 -->
<Button class="mt-5 w-full" type="primary" @click="send">发送</Button>
</Card>
</Page>
</template>
<style scoped>
/* 编辑器容器样式 */
.editor-container {
height: 250px;
margin-bottom: 40px;
/* 确保编辑器有足够的空间显示工具栏和内容区域 */
}
/* 可以添加额外的样式来自定义编辑器外观 */
:deep(.ql-editor) {
min-height: 200px;
font-size: 14px;
line-height: 1.6;
}
/* 确保图片在编辑器中显示正常 */
:deep(.ql-editor img) {
max-width: 100%;
height: auto;
}
</style>

View File

@@ -0,0 +1,456 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { QuillEditor } from '@vueup/vue-quill';
import {
Button,
Card,
Input,
message,
Radio,
RadioGroup,
Select,
SelectOption,
Textarea,
} from 'ant-design-vue';
import { uploadFile } from '#/api/core/upload';
import { getUserOptionApi, sendNoticeApi } from '#/views/notice/api';
// Import Markdown editor (using a popular Vue 3 Markdown editor)
import { config, MdEditor } from 'md-editor-v3';
import 'md-editor-v3/lib/style.css';
// eslint-disable-next-line n/no-extraneous-import
import '@vueup/vue-quill/dist/vue-quill.snow.css';
// 基础数据定义
const title = ref('');
const detail = ref('');
const content = ref('');
const markdownContent = ref(''); // For 1 content
const type = ref(0);
const userIds = ref([]);
const editorMode = ref(0); // 0 or 1
const typeOption = ref([
{
label: '公告',
value: 0,
},
{
label: '周报',
value: 2,
},
]);
// 用户选项数据
const userOption = ref([]);
// 创建对QuillEditor的引用用于后续获取Quill实例
const quillEditorRef = ref(null);
// 获取用户选项数据的函数
const getUserOption = () => {
getUserOptionApi().then((res) => {
userOption.value = res;
});
};
getUserOption();
// Watch for editor mode changes to convert content between formats
watch(editorMode, (newMode, oldMode) => {
if (newMode === oldMode) return;
if (newMode === 1 && oldMode === 0) {
// Convert HTML to Markdown (simplified conversion)
// In a real app, you would use a proper HTML-to-Markdown converter library
try {
// This is a placeholder - you should use a proper library like turndown
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content.value;
markdownContent.value = tempDiv.textContent || '';
message.info('已切换到Markdown编辑模式');
} catch (error) {
console.error('转换HTML到Markdown失败:', error);
}
} else if (newMode === 0 && oldMode === 1) {
// Convert Markdown to HTML (simplified conversion)
// In a real app, you would use a proper Markdown-to-HTML converter library
try {
// This is a placeholder - you should use a proper library like marked
content.value = markdownContent.value;
message.info('已切换到富文本编辑模式');
} catch (error) {
console.error('转换Markdown到HTML失败:', error);
}
}
});
// 发送消息的函数
const send = () => {
// Use the appropriate content based on editor mode
const finalContent =
editorMode.value === 1 ? markdownContent.value : content.value;
sendNoticeApi({
title: title.value,
type: type.value,
edit_type: editorMode.value,
detail: detail.value,
content: finalContent,
content_type: editorMode.value, // Add content type to API
user_ids: userIds.value,
}).then(() => {
detail.value = '';
content.value = '';
markdownContent.value = '';
userIds.value = [];
title.value = '';
// 清除富文本编辑器内容
if (quillEditorRef.value?.getQuill()) {
quillEditorRef.value.getQuill().setContents([]);
}
message.success('发送成功');
});
};
const isUpload = ref(false);
// 富文本编辑器配置选项
const editorOptions = {
theme: 'snow',
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'],
[{ header: 1 }, { header: 2 }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ script: 'sub' }, { script: 'super' }],
[{ indent: '-1' }, { indent: '+1' }],
[{ direction: 'rtl' }],
[{ size: ['small', false, 'large', 'huge'] }],
[{ header: [1, 2, 3, 4, 5, 6, false] }],
[{ color: [] }, { background: [] }],
[{ font: [] }],
[{ align: [] }],
['clean'],
['link', 'image', 'video'],
],
},
placeholder: '请输入消息内容',
};
/**
* 图片上传函数
* @param {File} file - 要上传的图片文件
* @returns {Promise<string>} - 返回上传后的图片URL
*/
const uploadImage = async (file: File): Promise<string> => {
try {
// 创建FormData对象用于发送文件数据
const formData = new FormData();
formData.append('file', file);
// 显示上传中的提示
message.loading({ content: '图片上传中...', key: 'imageUpload' });
return uploadFile({
file,
}).then((data: any) => {
message.success({
content: '图片上传成功',
key: 'imageUpload',
duration: 2,
});
isUpload.value = false;
return data.url;
});
} catch (error) {
// 处理上传错误
console.error('图片上传错误:', error);
message.error({ content: '图片上传失败', key: 'imageUpload', duration: 2 });
return '';
}
};
/**
* 从剪贴板数据中提取图片文件
* @param {ClipboardEvent} event - 剪贴板事件
* @returns {File|null} - 返回图片文件或null
*/
const getImageFromClipboard = (event: ClipboardEvent): File | null => {
const clipboardData = event.clipboardData;
if (!clipboardData) return null;
// 遍历剪贴板中的所有项目
const items = clipboardData.items;
for (const item of items) {
// 检查是否是图片类型
if (item.type.includes('image')) {
return item.getAsFile();
}
}
return null;
};
/**
* 在编辑器中插入图片
* @param {any} quill - Quill编辑器实例
* @param {string} imageUrl - 图片URL
*/
const insertImageToEditor = (quill: any, imageUrl: string): void => {
if (!quill) return;
// 获取当前光标位置
const range = quill.getSelection();
if (range) {
// 在光标位置插入图片
quill.insertEmbed(range.index, 'image', imageUrl);
// 将光标移动到图片后面
quill.setSelection(range.index + 1);
} else {
// 如果没有选择范围,则在文档末尾插入
const length = quill.getLength();
quill.insertEmbed(length - 1, 'image', imageUrl);
quill.setSelection(length);
}
};
// 处理Markdown编辑器的图片上传
const handleMdImageUpload = async (
files: FileList,
callback: (urls: string[]) => void,
) => {
if (!files || files.length === 0) return;
const urls = [];
for (const file of files) {
if (isUpload.value === true) continue;
isUpload.value = true;
const url = await uploadImage(file);
if (url) urls.push(url);
isUpload.value = false;
}
callback(urls);
};
// 组件挂载后设置剪贴板事件监听
onMounted(() => {
// 等待DOM更新后获取编辑器元素
setTimeout(() => {
// 获取Quill编辑器实例
const quill = quillEditorRef.value?.getQuill();
if (!quill) {
console.error('无法获取Quill编辑器实例');
return;
}
// 为编辑器添加粘贴事件监听器
const editorElement = document.querySelector('.ql-editor');
if (editorElement) {
editorElement.addEventListener('paste', async (event) => {
// 从剪贴板获取图片
const imageFile = getImageFromClipboard(event as ClipboardEvent);
if (imageFile) {
// 阻止默认粘贴行为,以便我们可以自定义处理
event.preventDefault();
if (isUpload.value === true) {
return;
}
isUpload.value = true;
// 上传图片到服务器
const imageUrl = await uploadImage(imageFile);
// 如果成功获取到图片URL则将图片插入到编辑器中
if (imageUrl) {
insertImageToEditor(quill, imageUrl);
}
}
// 如果不是图片,则让默认粘贴行为继续
});
console.log('已设置编辑器粘贴事件监听器');
} else {
console.error('无法找到编辑器元素');
}
}, 500); // 给予足够的时间让编辑器渲染完成
});
/**
* 直接处理编辑器的粘贴事件
* 这是一个备用方法通过Quill的clipboard模块直接处理粘贴事件
*/
const setupQuillPasteHandler = () => {
// 确保编辑器已经挂载
if (!quillEditorRef.value) return;
const quill = quillEditorRef.value.getQuill();
if (!quill) return;
// 获取Quill的clipboard模块
const clipboard = quill.getModule('clipboard');
// 保存原始的粘贴处理函数
const originalMatchers = clipboard.matchers;
// 重写粘贴处理函数
clipboard.addMatcher('img', (node: any, delta: any) => {
// 这里可以处理HTML中的img标签
// 但对于直接粘贴的图片文件,这个方法不会被触发
return delta;
});
// 监听编辑器的paste事件
quill.root.addEventListener('paste', async (e: ClipboardEvent) => {
const imageFile = getImageFromClipboard(e);
if (imageFile) {
e.preventDefault();
if (isUpload.value === true) {
return;
}
isUpload.value = true;
const imageUrl = await uploadImage(imageFile);
if (imageUrl) {
insertImageToEditor(quill, imageUrl);
}
}
});
};
</script>
<template>
<Page title="通知群发">
<Card class="w-1/2" style="margin: 0 auto" title="发送消息">
<!-- 消息标题输入框 -->
<Input v-model:value="title" placeholder="请输入消息标题" />
<!-- 消息类型选择器 -->
<Select
v-model:value="type"
class="mt-5 w-full"
placeholder="请选择消息类型"
>
<template v-for="item in typeOption" :key="item.value">
<SelectOption :value="item.value">{{ item.label }}</SelectOption>
</template>
</Select>
<!-- 用户选择器仅在类型为2时显示 -->
<Select
v-if="type === 2"
v-model:value="userIds"
class="mt-5 w-full"
mode="multiple"
placeholder="请选择接受消息的用户"
>
<template v-for="item in userOption" :key="item.value">
<SelectOption :value="item.id">{{ item.nick_name }}</SelectOption>
</template>
</Select>
<!-- 消息简介输入框 -->
<Textarea
v-model:value="detail"
class="mt-5"
placeholder="请输入消息简介"
/>
<!-- 编辑器类型选择 -->
<div class="mt-5">
<RadioGroup v-model:value="editorMode" button-style="solid">
<Radio :value="0">富文本编辑器</Radio>
<Radio :value="1">Markdown编辑器</Radio>
</RadioGroup>
</div>
<!-- 富文本编辑器 - 仅在富文本模式下显示 -->
<div v-if="editorMode === 0" class="mt-5">
<QuillEditor
ref="quillEditorRef"
v-model:content="content"
:options="editorOptions"
class="editor-container"
content-type="html"
theme="snow"
@ready="setupQuillPasteHandler"
/>
</div>
<!-- Markdown编辑器 - 仅在Markdown模式下显示 -->
<div v-if="editorMode === 1" class="mt-5">
<!-- <MdEditor-->
<!-- v-model="markdownContent"-->
<!-- :theme="prefersDarkMode ? 'dark' : 'light'"-->
<!-- :toolbars-exclude="['github']"-->
<!-- class="md-editor-container"-->
<!-- editor-id="notice-md-editor"-->
<!-- preview-only-->
<!-- @on-upload-img="handleMdImageUpload"-->
<!-- />-->
<MdEditor
v-model="markdownContent"
:toolbars-exclude="['github']"
class="md-editor-container"
editor-id="notice-md-editor"
preview-only
theme="dark"
@on-upload-img="handleMdImageUpload"
/>
</div>
<!-- 发送按钮 -->
<Button class="mt-5 w-full" type="primary" @click="send">发送</Button>
</Card>
</Page>
</template>
<style scoped>
/* 编辑器容器样式 */
.editor-container {
height: 250px;
margin-bottom: 40px;
/* 确保编辑器有足够的空间显示工具栏和内容区域 */
}
/* Markdown编辑器样式 */
.md-editor-container {
height: 350px;
margin-bottom: 40px;
}
/* 可以添加额外的样式来自定义编辑器外观 */
:deep(.ql-editor) {
min-height: 200px;
font-size: 14px;
line-height: 1.6;
}
/* 确保图片在编辑器中显示正常 */
:deep(.ql-editor img) {
max-width: 100%;
height: auto;
}
/* 自定义 Markdown 编辑器样式 */
:deep(.md-editor) {
border-radius: 4px;
}
:deep(.md-editor-dark) {
--md-bk-color: #1e1e1e;
--md-border-color: #333;
}
</style>

View File

@@ -1,7 +1,10 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { notification, Tag } from 'ant-design-vue';
import {
Bell,
CalendarClock,
@@ -15,8 +18,8 @@ import {
Search,
X,
} from 'lucide-vue-next';
import { getNoticeListApi, readAllApi, readApi } from "#/views/notice/api";
import { notification } from "ant-design-vue";
import { getNoticeListApi, readAllApi, readApi } from '#/views/notice/api';
const router = useRouter();
@@ -52,7 +55,7 @@ const getNoticeList = async () => {
title: searchQuery.value || undefined,
});
messages.value = res.items.map(item => ({
messages.value = res.items.map((item) => ({
...item,
// 将API返回的数据映射到组件需要的格式
type_txt: getMessageTypeName(item.type),
@@ -71,7 +74,7 @@ const getNoticeList = async () => {
// 根据类型ID获取类型名称
const getMessageTypeName = (typeId) => {
const type = messageTypes.find(t => t.id === typeId);
const type = messageTypes.find((t) => t.id === typeId);
return type ? type.name : '未知类型';
};
@@ -93,7 +96,7 @@ onMounted(() => {
// 未读消息数量
const unreadCount = computed(() => {
return messages.value.filter(msg => msg.status === 0).length;
return messages.value.filter((msg) => msg.status === 0).length;
});
// 样式相关
@@ -147,7 +150,7 @@ const markAsRead = async (event, item) => {
const markAllAsRead = async () => {
try {
await readAllApi();
messages.value.forEach(msg => {
messages.value.forEach((msg) => {
msg.status = 1;
});
// 成功后可以重新获取数据或者直接更新本地状态
@@ -186,7 +189,7 @@ const goToPage = (page) => {
// 获取当前选中的筛选类型名称
const currentFilterName = computed(() => {
const type = messageTypes.find(t => t.id === filterType.value);
const type = messageTypes.find((t) => t.id === filterType.value);
return type ? type.name : '全部消息';
});
@@ -205,7 +208,7 @@ const toggleExpand = (event, id) => {
<span class="text-gray-500 dark:text-gray-400"> {{ total }} 条消息</span>
<span
v-if="unreadCount > 0"
class="rounded-full bg-red-500 dark:bg-red-600 px-2 py-0.5 text-xs font-medium text-white"
class="rounded-full bg-red-500 px-2 py-0.5 text-xs font-medium text-white dark:bg-red-600"
>
{{ unreadCount }} 未读
</span>
@@ -220,7 +223,7 @@ const toggleExpand = (event, id) => {
<!-- 筛选下拉菜单 -->
<div class="relative">
<button
class="flex items-center gap-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-sky-500 dark:focus:ring-sky-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 transition-colors"
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:focus:ring-sky-400 dark:focus:ring-offset-gray-900"
@click="toggleFilterDropdown"
>
<Filter class="h-4 w-4" />
@@ -230,7 +233,7 @@ const toggleExpand = (event, id) => {
<div
v-if="showFilterDropdown"
class="absolute left-0 top-full z-10 mt-1 w-56 origin-top-left rounded-md bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 dark:ring-white dark:ring-opacity-10 focus:outline-none"
class="absolute left-0 top-full z-10 mt-1 w-56 origin-top-left rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-gray-800 dark:ring-white dark:ring-opacity-10"
>
<div class="py-1">
<button
@@ -238,10 +241,10 @@ const toggleExpand = (event, id) => {
:key="type.id"
:class="
filterType === type.id
? 'bg-gray-50 dark:bg-gray-700 font-medium text-sky-600 dark:text-sky-400'
? 'bg-gray-50 font-medium text-sky-600 dark:bg-gray-700 dark:text-sky-400'
: 'text-gray-700 dark:text-gray-200'
"
class="flex w-full items-center gap-2 px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
class="flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
@click="selectFilterType(type.id)"
>
<component
@@ -265,7 +268,7 @@ const toggleExpand = (event, id) => {
</div>
<input
v-model="searchQuery"
class="block w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 py-2 pl-10 pr-10 text-sm text-gray-700 dark:text-gray-200 focus:border-sky-500 dark:focus:border-sky-400 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:focus:ring-sky-400 transition-colors"
class="block w-full rounded-lg border border-gray-200 bg-white py-2 pl-10 pr-10 text-sm text-gray-700 transition-colors focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:focus:border-sky-400 dark:focus:ring-sky-400"
placeholder="搜索消息..."
type="text"
/>
@@ -274,14 +277,16 @@ const toggleExpand = (event, id) => {
class="absolute inset-y-0 right-0 flex items-center pr-3"
@click="clearSearch"
>
<X class="h-4 w-4 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" />
<X
class="h-4 w-4 text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
/>
</button>
</div>
<!-- 标记全部已读按钮 -->
<button
v-if="unreadCount > 0"
class="flex items-center gap-1 rounded-lg bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm ring-1 ring-gray-200 dark:ring-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
class="flex items-center gap-1 rounded-lg bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm ring-1 ring-gray-200 transition-colors hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-200 dark:ring-gray-700 dark:hover:bg-gray-700"
@click="markAllAsRead"
>
<CheckCircle2 class="h-4 w-4 text-gray-500 dark:text-gray-400" />
@@ -291,11 +296,13 @@ const toggleExpand = (event, id) => {
<!-- 消息列表 -->
<div
class="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm"
class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800"
>
<!-- 加载状态 -->
<div v-if="loading" class="flex justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 dark:border-gray-700 border-t-sky-500 dark:border-t-sky-400"></div>
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-sky-500 dark:border-gray-700 dark:border-t-sky-400"
></div>
</div>
<!-- 空状态 -->
@@ -318,7 +325,11 @@ const toggleExpand = (event, id) => {
<li
v-for="item in messages"
:key="item.id"
:class="[item.status === 0 ? 'bg-gray-50 dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-800']"
:class="[
item.status === 0
? 'bg-gray-50 dark:bg-gray-900'
: 'bg-gray-50 dark:bg-gray-800',
]"
class="group relative cursor-pointer transition-all duration-200"
@click="handleItemClick(item)"
>
@@ -327,16 +338,17 @@ const toggleExpand = (event, id) => {
<div
:class="[
item.status === 1
? 'bg-gray-100 dark:bg-gray-700 '
? 'bg-gray-100 dark:bg-gray-700'
: typeBgColors[item.type],
]"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full transition-all duration-200"
style="margin: auto 0;"
>
<component
:is="typeIcons[item.type]"
:class="[
item.status === 1
? 'text-gray-400 dark:text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-300'
? 'text-gray-400 group-hover:text-gray-600 dark:text-gray-500 dark:group-hover:text-gray-300'
: typeColors[item.type],
]"
class="h-5 w-5 transition-all duration-200"
@@ -350,7 +362,7 @@ const toggleExpand = (event, id) => {
:class="[
item.status === 0
? 'font-semibold text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white',
: 'text-gray-700 group-hover:text-gray-900 dark:text-gray-300 dark:group-hover:text-white',
]"
class="text-sm font-medium transition-all duration-200 sm:text-base"
>
@@ -358,14 +370,14 @@ const toggleExpand = (event, id) => {
</h3>
<span
class="shrink-0 whitespace-nowrap text-xs text-gray-400 dark:text-gray-500"
>{{ item.date }}</span>
>{{ item.date }}</span>
</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<span
:class="[
item.status === 1
? 'border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400'
? 'border-gray-200 text-gray-500 dark:border-gray-700 dark:text-gray-400'
: `${typeColors[item.type]} ${typeBorderColors[item.type]}`,
]"
class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium transition-all duration-200"
@@ -377,7 +389,9 @@ const toggleExpand = (event, id) => {
<div class="mt-1 w-full">
<p
:class="[
item.status === 1 ? 'text-gray-500 dark:text-gray-400' : 'text-gray-700 dark:text-gray-300',
item.status === 1
? 'text-gray-500 dark:text-gray-400'
: 'text-gray-700 dark:text-gray-300',
expandedMessage === item.id ? '' : 'line-clamp-1',
]"
class="text-xs transition-all duration-200"
@@ -387,14 +401,21 @@ const toggleExpand = (event, id) => {
<!-- 展开/收起按钮 -->
<button
v-if="item.detail && item.detail.length > 50"
class="mt-1 flex items-center gap-1 text-xs text-sky-500 dark:text-sky-400 hover:text-sky-600 dark:hover:text-sky-300 transition-colors"
v-if="item.detail && item.detail.length > 70"
class="mt-1 flex items-center gap-1 text-xs text-sky-500 transition-colors hover:text-sky-600 dark:text-sky-400 dark:hover:text-sky-300"
@click="toggleExpand($event, item.id)"
>
<Eye class="h-3 w-3" />
{{ expandedMessage === item.id ? '收起' : '展开' }}
</button>
</div>
<span
v-if="item.is_my === 1"
style="position: absolute; bottom: 10px; right: 10px"
>
我发布的 {{ item.is_all_read === 1 ? '全部已读' : `${item.unread_count} 人未读` }}
</span>
</div>
</div>
@@ -402,14 +423,14 @@ const toggleExpand = (event, id) => {
<div class="flex shrink-0 items-center gap-2">
<button
v-if="item.status === 0"
class="rounded-full p-1.5 text-gray-400 dark:text-gray-500 transition-all duration-200 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300"
class="rounded-full p-1.5 text-gray-400 transition-all duration-200 hover:bg-gray-100 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300"
title="标记为已读"
@click="markAsRead($event, item)"
>
<CheckCircle2 class="h-5 w-5" />
</button>
<ChevronRight
class="h-5 w-5 text-gray-300 dark:text-gray-600 transition-all duration-200 group-hover:text-gray-400 dark:group-hover:text-gray-500"
class="h-5 w-5 text-gray-300 transition-all duration-200 group-hover:text-gray-400 dark:text-gray-600 dark:group-hover:text-gray-500"
/>
</div>
</div>
@@ -426,17 +447,17 @@ const toggleExpand = (event, id) => {
<!-- 分页控件 -->
<div
v-if="totalPages > 1"
class="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-750 px-4 py-3 sm:px-6"
class="dark:bg-gray-750 flex items-center justify-between border-t border-gray-200 bg-gray-50 px-4 py-3 sm:px-6 dark:border-gray-700"
>
<div class="flex flex-1 justify-between sm:hidden">
<button
:class="[
currentPage === 1
? 'text-gray-300 dark:text-gray-600'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700',
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700',
]"
:disabled="currentPage === 1"
class="relative inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-2 text-sm font-medium transition-colors"
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium transition-colors dark:border-gray-600 dark:bg-gray-800"
@click="goToPage(currentPage - 1)"
>
上一页
@@ -445,10 +466,10 @@ const toggleExpand = (event, id) => {
:class="[
currentPage === totalPages
? 'text-gray-300 dark:text-gray-600'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700',
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700',
]"
:disabled="currentPage === totalPages"
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-2 text-sm font-medium transition-colors"
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium transition-colors dark:border-gray-600 dark:bg-gray-800"
@click="goToPage(currentPage + 1)"
>
下一页
@@ -461,12 +482,12 @@ const toggleExpand = (event, id) => {
<p class="text-sm text-gray-700 dark:text-gray-300">
显示第
<span class="font-medium">{{
(currentPage - 1) * pageSize + 1
}}</span>
(currentPage - 1) * pageSize + 1
}}</span>
<span class="font-medium">{{
Math.min(currentPage * pageSize, total)
}}</span>
Math.min(currentPage * pageSize, total)
}}</span>
<span class="font-medium">{{ total }}</span>
@@ -484,7 +505,7 @@ const toggleExpand = (event, id) => {
: 'hover:text-gray-500 dark:hover:text-gray-300'
"
:disabled="currentPage === 1"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 dark:text-gray-500 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 focus:z-20 focus:outline-offset-0 transition-colors"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 transition-colors hover:bg-gray-50 focus:z-20 focus:outline-offset-0 dark:text-gray-500 dark:ring-gray-600 dark:hover:bg-gray-700"
@click="goToPage(currentPage - 1)"
>
<span class="sr-only">上一页</span>
@@ -502,8 +523,8 @@ const toggleExpand = (event, id) => {
"
:class="[
page === currentPage
? 'z-10 bg-sky-500 dark:bg-sky-600 text-white focus:z-20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 dark:focus-visible:outline-sky-400'
: 'text-gray-900 dark:text-gray-200 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 focus:z-20 focus:outline-offset-0',
? 'z-10 bg-sky-500 text-white focus:z-20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 dark:bg-sky-600 dark:focus-visible:outline-sky-400'
: 'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 dark:text-gray-200 dark:ring-gray-600 dark:hover:bg-gray-700',
]"
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold transition-colors"
@click="goToPage(page)"
@@ -517,7 +538,7 @@ const toggleExpand = (event, id) => {
(page === 2 && currentPage > 3) ||
(page === totalPages - 1 && currentPage < totalPages - 2)
"
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 ring-1 ring-inset ring-gray-300 dark:ring-gray-600"
class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300 dark:text-gray-300 dark:ring-gray-600"
>
...
</span>
@@ -530,7 +551,7 @@ const toggleExpand = (event, id) => {
: 'hover:text-gray-500 dark:hover:text-gray-300'
"
:disabled="currentPage === totalPages"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 dark:text-gray-500 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 focus:z-20 focus:outline-offset-0 transition-colors"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 transition-colors hover:bg-gray-50 focus:z-20 focus:outline-offset-0 dark:text-gray-500 dark:ring-gray-600 dark:hover:bg-gray-700"
@click="goToPage(currentPage + 1)"
>
<span class="sr-only">下一页</span>

View File

@@ -116,9 +116,16 @@
]
},
"dependencies": {
"@types/dompurify": "^3.2.0",
"@types/marked": "^6.0.0",
"@vueup/vue-quill": "^1.2.0",
"dompurify": "^3.2.5",
"github-markdown-css": "^5.8.1",
"html2canvas": "^1.4.1",
"js-base64": "^3.7.7",
"lodash-es": "^4.17.21",
"lucide-vue-next": "^0.487.0"
"lucide-vue-next": "^0.487.0",
"marked": "^15.0.8",
"md-editor-v3": "^5.4.5"
}
}

802
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff