消息模块、爬虫

This commit is contained in:
2025-11-13 19:00:27 +08:00
parent 2982d53800
commit e20a7755f8
85 changed files with 8637 additions and 201 deletions

View File

@@ -0,0 +1,326 @@
<template>
<div v-loading="loading" class="message-detail">
<div v-if="messageData" class="detail-container">
<!-- 返回按钮 -->
<el-button
v-if="showBackButton"
type="text"
@click="handleBack"
class="back-button"
>
<el-icon><ArrowLeft /></el-icon>
{{ backButtonText }}
</el-button>
<!-- 消息头部 -->
<div class="message-header">
<div class="header-left">
<h2 class="message-title">{{ messageData.title }}</h2>
<div class="message-meta">
<el-tag :type="getMessageTypeTag(messageData.messageType || '')" size="large">
{{ getMessageTypeLabel(messageData.messageType || '') }}
</el-tag>
<MessagePriorityBadge :priority="messageData.priority || ''" size="large" />
<MessageStatusBadge :status="messageData.status||''" size="large" />
</div>
</div>
</div>
<el-divider />
<!-- 消息信息 -->
<div class="message-info">
<el-descriptions :column="2" border>
<el-descriptions-item label="创建时间">
<el-icon><Clock /></el-icon>
{{ messageData.createTime }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
<el-icon><Clock /></el-icon>
{{ messageData.updateTime }}
</el-descriptions-item>
<el-descriptions-item label="计划发送时间">
<el-icon><Timer /></el-icon>
{{ messageData.scheduledTime || '立即发送' }}
</el-descriptions-item>
<el-descriptions-item label="实际发送时间">
<el-icon><Timer /></el-icon>
{{ messageData.actualSendTime || '未发送' }}
</el-descriptions-item>
<el-descriptions-item label="发送方式" :span="2">
<el-tag
v-for="method in messageData.sendMethods"
:key="method"
style="margin-right: 8px"
>
{{ getSendMethodLabel(method) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最大重试次数">
{{ messageData.maxRetryCount }}
</el-descriptions-item>
<el-descriptions-item label="当前重试次数">
{{ messageData.retryCount || 0 }}
</el-descriptions-item>
</el-descriptions>
</div>
<el-divider content-position="left">消息内容</el-divider>
<!-- 消息内容 -->
<div class="message-content">
<el-card shadow="never" class="content-card">
<div class="content-text">{{ messageData.content }}</div>
</el-card>
</div>
<!-- 发送统计如果有统计数据 -->
<template v-if="showStatistics && hasStatistics">
<el-divider content-position="left">发送统计</el-divider>
<MessageStatistic :statistics="messageData" />
</template>
<!-- 操作按钮如果提供 -->
<div v-if="showActions" class="message-actions">
<slot name="actions" :message="messageData" />
</div>
</div>
<el-empty v-else-if="!loading" description="消息不存在" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import { ArrowLeft, Clock, Timer } from '@element-plus/icons-vue';
import { messageApi } from '@/apis/message';
import { MessagePriorityBadge, MessageStatusBadge } from '@/components/message';
import { MessageStatistic } from '@/views/admin/manage/message/components';
import type { MessageVO } from '@/types';
interface Props {
messageId: string;
showBackButton?: boolean;
backButtonText?: string;
showStatistics?: boolean;
showActions?: boolean;
autoMarkRead?: boolean;
}
interface Emits {
(e: 'back'): void;
(e: 'loaded', message: MessageVO): void;
(e: 'error', error: any): void;
}
const props = withDefaults(defineProps<Props>(), {
showBackButton: false,
backButtonText: '返回',
showStatistics: false,
showActions: false,
autoMarkRead: false
});
const emit = defineEmits<Emits>();
const loading = ref(false);
const messageData = ref<MessageVO | null>(null);
/** 是否有统计数据 */
const hasStatistics = computed(() => {
if (!messageData.value) return false;
return (
messageData.value.targetUserCount !== undefined &&
messageData.value.targetUserCount > 0
);
});
/** 加载消息详情 */
async function loadMessage() {
if (!props.messageId) {
ElMessage.warning('消息ID不能为空');
return;
}
loading.value = true;
try {
const result = await messageApi.getMessageDetail(props.messageId);
if (result.success && result.data) {
messageData.value = result.data;
emit('loaded', result.data);
// 自动标记已读(用户端)
if (props.autoMarkRead) {
markAsRead();
}
} else {
ElMessage.error(result.message || '加载失败');
emit('error', new Error(result.message || '加载失败'));
}
} catch (error) {
console.error('加载消息详情失败:', error);
ElMessage.error('加载失败');
emit('error', error);
} finally {
loading.value = false;
}
}
/** 标记已读 */
async function markAsRead() {
if (!props.messageId) return;
try {
await messageApi.markAsRead(props.messageId);
} catch (error) {
console.error('标记已读失败:', error);
}
}
/** 返回 */
function handleBack() {
emit('back');
}
/** 获取消息类型标签 */
function getMessageTypeTag(type: string): string {
const map: Record<string, string> = {
notice: '',
announcement: 'success',
warning: 'warning',
system: 'info'
};
return map[type] || '';
}
/** 获取消息类型文本 */
function getMessageTypeLabel(type: string): string {
const map: Record<string, string> = {
notice: '通知',
announcement: '公告',
warning: '警告',
system: '系统消息'
};
return map[type] || type;
}
/** 获取发送方式文本 */
function getSendMethodLabel(method: string): string {
const map: Record<string, string> = {
system: '系统消息',
email: '邮件通知',
sms: '短信通知'
};
return map[method] || method;
}
/** 刷新数据 */
function refresh() {
loadMessage();
}
/** 监听messageId变化 */
watch(() => props.messageId, () => {
loadMessage();
}, { immediate: false });
onMounted(() => {
loadMessage();
});
// 暴露方法给父组件
defineExpose({
refresh,
messageData
});
</script>
<style lang="scss" scoped>
.message-detail {
.detail-container {
background: #fff;
border-radius: 8px;
padding: 24px;
}
.back-button {
margin-bottom: 16px;
font-size: 14px;
color: #409eff;
padding: 0;
&:hover {
color: #66b1ff;
}
}
.message-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
.header-left {
flex: 1;
}
.message-title {
font-size: 24px;
font-weight: 600;
color: #303133;
margin: 0 0 12px 0;
line-height: 1.4;
}
.message-meta {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
}
.message-info {
margin-bottom: 24px;
:deep(.el-descriptions__label) {
width: 140px;
font-weight: 500;
}
.el-icon {
margin-right: 6px;
}
}
.message-content {
margin-bottom: 24px;
.content-card {
background: #f9f9f9;
:deep(.el-card__body) {
padding: 20px;
}
}
.content-text {
font-size: 15px;
line-height: 1.8;
color: #606266;
white-space: pre-wrap;
word-wrap: break-word;
}
}
.message-actions {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #ebeef5;
display: flex;
justify-content: flex-end;
gap: 12px;
}
}
</style>

View File

@@ -0,0 +1,7 @@
/**
* @description 公共消息组件导出
* @author Claude
* @since 2025-11-13
*/
export { default as MessageDetail } from './MessageDetail.vue';