fix: 完成: 消息中心
This commit is contained in:
@@ -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:"
|
||||
|
||||
@@ -91,11 +91,11 @@ const getNoticeList = async () => {
|
||||
color: typeColors[item.type],
|
||||
isRead: item.status
|
||||
}));
|
||||
} else {
|
||||
notifications.value = [];
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取消息列表失败:', error);
|
||||
} finally {
|
||||
}
|
||||
};
|
||||
getNoticeList();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
311
apps/web-antd/src/views/notice/compoents/detail-o.vue
Normal file
311
apps/web-antd/src/views/notice/compoents/detail-o.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
344
apps/web-antd/src/views/notice/compoents/send-o.vue
Normal file
344
apps/web-antd/src/views/notice/compoents/send-o.vue
Normal 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>
|
||||
456
apps/web-antd/src/views/notice/compoents/send.vue
Normal file
456
apps/web-antd/src/views/notice/compoents/send.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
802
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user