Files
schoolNews/schoolNewsWeb/src/views/public/article/components/ArticleShow.vue
2025-12-01 18:37:43 +08:00

924 lines
24 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<!-- Dialog 模式 -->
<el-dialog
v-if="asDialog"
v-model="visible"
:title="title"
:width="width"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="article-show-container">
<div v-if="loading" class="loading-state">
<el-skeleton :rows="8" animated />
</div>
<div v-else-if="currentArticleData">
<!-- 文章头部信息 -->
<div class="article-header">
<h1 class="article-title">{{ currentArticleData.title }}</h1>
<div class="article-meta-info">
<div class="meta-item" v-if="currentArticleData.publishTime || currentArticleData.createTime">
发布时间{{ formatDateSimple(currentArticleData.publishTime || currentArticleData.createTime || '') }}
</div>
<div class="meta-item" v-if="currentArticleData.viewCount !== undefined">
浏览次数{{ currentArticleData.viewCount }}
</div>
<div class="meta-item" v-if="currentArticleData.source">
来源{{ currentArticleData.source }}
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="separator"></div>
<!-- 文章内容 -->
<div class="article-content ql-editor" v-html="currentArticleData.content"></div>
</div>
<el-empty v-else description="加载文章失败" />
</div>
<template #footer>
<el-button @click="handleClose">关闭</el-button>
<el-button v-if="showEditButton" type="primary" @click="handleEdit">编辑</el-button>
</template>
</el-dialog>
<!-- 路由页面模式 -->
<div v-else class="article-page-view">
<!-- 返回按钮 -->
<div v-if="showBackButton" class="back-header">
<el-button @click="handleBack" :icon="ArrowLeft">{{ backButtonText }}</el-button>
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading-container">
<el-skeleton :rows="10" animated />
</div>
<!-- 文章内容 -->
<div v-else-if="currentArticleData" class="article-wrapper">
<div class="article-show-container">
<!-- 文章头部信息 -->
<div class="article-header">
<h1 class="article-title">{{ currentArticleData.title }}</h1>
<div class="article-meta-info">
<div class="meta-item" v-if="currentArticleData.publishTime || currentArticleData.createTime">
发布时间{{ formatDateSimple(currentArticleData.publishTime || currentArticleData.createTime || '') }}
</div>
<div class="meta-item" v-if="currentArticleData.viewCount !== undefined">
浏览次数{{ currentArticleData.viewCount }}
</div>
<div class="meta-item" v-if="currentArticleData.source">
来源{{ currentArticleData.source }}
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="separator"></div>
<!-- 文章内容 -->
<div class="article-content ql-editor" v-html="currentArticleData.content"></div>
</div>
</div>
<!-- 加载失败 -->
<div v-else class="error-container">
<el-empty description="加载文章失败" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { ElMessage } from 'element-plus';
import { ArrowLeft } from '@element-plus/icons-vue';
import { resourceApi } from '@/apis/resource';
import { learningRecordApi, learningHistoryApi } from '@/apis/study';
import { useStore } from 'vuex';
import type { Resource, ResourceVO, LearningRecord, TbLearningHistory } from '@/types';
defineOptions({
name: 'ArticleShow'
});
interface Props {
modelValue?: boolean; // Dialog 模式下的显示状态
asDialog?: boolean; // 是否作为 Dialog 使用
title?: string; // Dialog 标题
width?: string; // Dialog 宽度
articleData?: Resource; // 文章数据Dialog 模式使用)
resourceID?: string; // 资源ID路由模式使用
taskId?: string; // 任务ID路由模式使用
showEditButton?: boolean; // 是否显示编辑按钮
showBackButton?: boolean; // 是否显示返回按钮(路由模式)
backButtonText?: string; // 返回按钮文本
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
asDialog: false, // 默认作为路由页面使用
title: '文章预览',
width: '900px',
articleData: () => ({}),
showEditButton: false,
showBackButton: true,
backButtonText: '返回'
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'close': [];
'edit': [];
'back': [];
'videos-completed': []; // 所有视频播放完成事件
}>();
const router = useRouter();
const route = useRoute();
const store = useStore();
const loading = ref(false);
const loadedArticleData = ref<Resource | null>(null);
// 学习记录相关
const learningRecord = ref<LearningRecord | null>(null);
const learningStartTime = ref(0);
const learningTimer = ref<number | null>(null);
const hasVideoCompleted = ref(false);
const totalVideos = ref(0); // 视频总数
const completedVideos = ref<Set<number>>(new Set()); // 已完成的视频索引
const userInfo = computed(() => store.getters['auth/user']);
// 学习历史记录相关
const learningHistory = ref<TbLearningHistory | null>(null);
const historyStartTime = ref(0);
const historyTimer = ref<number | null>(null);
// 当前显示的文章数据
const currentArticleData = computed(() => {
// Dialog 模式使用传入的 articleData
if (props.asDialog) {
return props.articleData;
}
// 路由模式:优先使用传入的 articleData否则使用加载的数据
if (props.articleData && Object.keys(props.articleData).length > 0) {
return props.articleData;
}
return loadedArticleData.value;
});
// Dialog 显示状态
const visible = computed({
get: () => props.asDialog ? props.modelValue : false,
set: (val) => props.asDialog ? emit('update:modelValue', val) : undefined
});
onMounted(() => {
// 路由模式下,从路由参数加载文章
if (!props.asDialog) {
const articleId = route.query.articleId as string;
const taskId = props.taskId || (route.query.taskId as string);
// 如果传入了 articleData则不需要从路由加载
if (props.articleData && Object.keys(props.articleData).length > 0) {
loadedArticleData.value = props.articleData;
const resourceID = props.articleData.resourceID;
if (resourceID) {
// 创建学习历史记录(每次进入都创建新记录)
createHistoryRecord(resourceID);
// 如果有taskId还要创建学习记录
if (taskId || route.query.taskId) {
loadLearningRecord(resourceID);
}
}
// 初始化视频监听
nextTick().then(() => {
setTimeout(() => {
initVideoListeners();
}, 300);
});
} else if (articleId) {
// 从路由参数加载
loadArticle(articleId);
// 如果有 taskId表示是任务学习需要创建/加载学习记录
if (taskId) {
loadLearningRecord(articleId);
}
} else {
// 既没有传入数据,也没有路由参数,显示错误
ElMessage.error('文章ID不存在');
}
}
});
onBeforeUnmount(() => {
// 组件销毁前保存学习进度
if (learningRecord.value && !learningRecord.value.isComplete) {
saveLearningProgress();
}
stopLearningTimer();
// 保存学习历史记录
if (learningHistory.value) {
saveHistoryRecord();
}
stopHistoryTimer();
});
// 监听 articleData 变化(用于 ResourceArticle 切换文章)
watch(() => props.articleData, async (newData, oldData) => {
if (!props.asDialog) {
// 如果从有数据变成null或者切换到新文章都需要保存当前历史
if (learningHistory.value && (oldData && (!newData || oldData.resourceID !== newData.resourceID))) {
await saveHistoryRecord();
stopHistoryTimer();
}
// 加载新文章数据
if (newData && Object.keys(newData).length > 0) {
loadedArticleData.value = newData;
// 为新文章创建学习历史记录
const resourceID = newData.resourceID;
if (resourceID) {
await createHistoryRecord(resourceID);
}
}
// 重新初始化视频监听
nextTick().then(() => {
setTimeout(() => {
initVideoListeners();
}, 300);
});
}
}, { deep: true });
// 加载文章数据
async function loadArticle(resourceID: string) {
loading.value = true;
try {
const res = await resourceApi.getResourceById(resourceID);
if (res.success && res.data) {
// ResourceVO 包含 resource 对象
const resourceVO = res.data as ResourceVO;
loadedArticleData.value = resourceVO.resource || res.data as Resource;
// 增加浏览次数
await resourceApi.incrementViewCount(resourceID);
// 创建学习历史记录(每次进入都创建新记录)
await createHistoryRecord(resourceID);
// 等待 DOM 更新后监听视频(增加延迟确保 DOM 完全渲染)
await nextTick();
setTimeout(() => {
initVideoListeners();
}, 300); // 延迟 300ms 确保 DOM 完全渲染
} else {
ElMessage.error(res.message || '加载文章失败');
loadedArticleData.value = null;
}
} catch (error) {
console.error('加载文章失败:', error);
ElMessage.error('加载文章失败');
loadedArticleData.value = null;
} finally {
loading.value = false;
}
}
// 加载学习记录
async function loadLearningRecord(resourceID: string) {
if (!userInfo.value?.id) return;
try {
const res = await learningRecordApi.getRecordList({
userID: userInfo.value.id,
resourceType: 1, // 资源类型:文章
resourceID: resourceID
});
if (res.success && res.dataList && res.dataList.length > 0) {
learningRecord.value = res.dataList[0];
// 如果已完成,不需要启动计时器
if (learningRecord.value.isComplete) {
return;
}
} else {
// 没有记录,创建新的
await createLearningRecord(resourceID);
}
// 开始学习计时
startLearningTimer();
} catch (error) {
console.error('加载学习记录失败:', error);
}
}
// 简单的字符串哈希函数
function hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
// 转换为16进制字符串并确保长度一致
return Math.abs(hash).toString(16).padStart(8, '0') + str.substring(0, 8).replace(/[^a-zA-Z0-9]/g, '');
}
// 创建学习记录
async function createLearningRecord(resourceID: string) {
if (!userInfo.value?.id) return;
try {
const taskId = props.taskId || (route.query.taskId as string);
// 如果没有taskId生成一个自学任务ID
const effectiveTaskId = taskId
const res = await learningRecordApi.createRecord({
userID: userInfo.value.id,
resourceType: 1, // 资源类型:文章
resourceID: resourceID,
taskID: effectiveTaskId,
duration: 0,
progress: 0,
isComplete: false
});
if (res.success && res.data) {
learningRecord.value = res.data;
ElMessage.success('开始学习');
}
} catch (error) {
console.error('创建学习记录失败:', error);
}
}
// 开始学习计时
function startLearningTimer() {
learningStartTime.value = Date.now();
// 每10秒保存一次学习进度
learningTimer.value = window.setInterval(() => {
// 如果文章已完成,停止定时器
if (learningRecord.value?.isComplete) {
stopLearningTimer();
return;
}
saveLearningProgress();
}, 10000);
}
// 停止学习计时
function stopLearningTimer() {
if (learningTimer.value) {
clearInterval(learningTimer.value);
learningTimer.value = null;
}
}
// 保存学习进度
async function saveLearningProgress() {
if (!userInfo.value?.id || !learningRecord.value) return;
// 如果文章已完成,不再保存进度
if (learningRecord.value.isComplete) {
return;
}
const currentTime = Date.now();
const duration = Math.floor((currentTime - learningStartTime.value) / 1000);
try {
const updatedRecord = {
id: learningRecord.value.id,
userID: userInfo.value.id,
resourceType: 1,
resourceID: route.query.articleId as string,
duration: (learningRecord.value.duration || 0) + duration,
progress: hasVideoCompleted.value ? 100 : 50, // 如果视频播放完成进度100%
isComplete: hasVideoCompleted.value
};
await learningRecordApi.updateRecord(updatedRecord);
// 更新本地记录
learningRecord.value.duration = updatedRecord.duration;
learningRecord.value.progress = updatedRecord.progress;
learningRecord.value.isComplete = updatedRecord.isComplete;
// 重置开始时间
learningStartTime.value = currentTime;
// 如果已完成,标记完成
if (hasVideoCompleted.value) {
await markArticleComplete();
}
} catch (error) {
console.error('保存学习进度失败:', error);
}
}
// 标记文章完成
async function markArticleComplete() {
if (!userInfo.value?.id || !learningRecord.value) return;
try {
// 使用learningRecord中保存的taskID可能是真实任务ID或生成的自学ID
const taskId = learningRecord.value.taskID || props.taskId || (route.query.taskId as string);
await learningRecordApi.markComplete({
id: learningRecord.value.id,
taskID: taskId,
userID: userInfo.value.id,
resourceType: 1,
resourceID: route.query.articleId as string,
isComplete: true
});
ElMessage.success('文章学习完成!');
stopLearningTimer();
} catch (error) {
console.error('标记完成失败:', error);
}
}
// 初始化视频监听器
function initVideoListeners() {
// 尝试多种选择器查找文章内容区域
const selectors = [
'.article-show-container .article-content.ql-editor',
'.article-wrapper .article-content.ql-editor',
'.article-content.ql-editor',
'.article-show-container .article-content',
'.article-wrapper .article-content',
'.article-content'
];
let articleContent: Element | null = null;
for (const selector of selectors) {
articleContent = document.querySelector(selector);
if (articleContent) {
break;
}
}
if (!articleContent) {
return;
}
const videos = articleContent.querySelectorAll('video');
if (videos.length === 0) {
// 没有视频默认阅读即完成不emit事件依赖父组件的滚动检测
hasVideoCompleted.value = true;
console.log(' 文章中没有视频,完成条件:滚动到底部');
return;
}
// 初始化视频数量和完成状态
totalVideos.value = videos.length;
completedVideos.value.clear();
// 监听所有视频的播放结束事件
videos.forEach((video, index) => {
const videoElement = video as HTMLVideoElement;
// 移除旧的监听器
videoElement.removeEventListener('ended', () => handleVideoEnded(index));
// 添加新的监听器,传递视频索引
videoElement.addEventListener('ended', () => handleVideoEnded(index));
});
}
// 处理视频播放结束
function handleVideoEnded(videoIndex: number) {
// 标记该视频已完成
completedVideos.value.add(videoIndex);
const completedCount = completedVideos.value.size;
console.log(`✅ 视频 ${videoIndex + 1} 播放完成 (${completedCount}/${totalVideos.value})`);
// 检查是否所有视频都已完成
if (completedCount >= totalVideos.value) {
if (!hasVideoCompleted.value) {
hasVideoCompleted.value = true;
ElMessage.success(`所有视频播放完成 (${totalVideos.value}/${totalVideos.value})`);
// 如果作为课程子组件使用没有learningRecord通知父组件
if (!learningRecord.value) {
console.log(' ArticleShow作为子组件使用通知父组件视频播放完成');
emit('videos-completed');
} else {
// 独立使用时,保存学习进度
saveLearningProgress();
}
}
} else {
ElMessage.info(`视频 ${videoIndex + 1} 播放完成 (${completedCount}/${totalVideos.value})`);
}
}
// ==================== 学习历史记录功能 ====================
// 创建学习历史记录
async function createHistoryRecord(resourceID: string) {
if (!userInfo.value?.id) return;
try {
const taskId = props.taskId || (route.query.taskId as string);
// 直接创建学习历史对象,包含 taskID
const historyData: TbLearningHistory = {
userID: userInfo.value.id,
resourceType: 1, // 1资源/新闻
resourceID: resourceID,
duration: 0,
deviceType: 'web',
taskID: taskId || undefined // 如果没有 taskId传 undefined
};
const res = await learningHistoryApi.recordLearningHistory(historyData);
if (res.success && res.data) {
learningHistory.value = res.data;
console.log('✅ 学习历史记录创建成功:', learningHistory.value);
// 开始计时
startHistoryTimer();
}
} catch (error) {
console.error('❌ 创建学习历史记录失败:', error);
}
}
// 开始学习历史计时
function startHistoryTimer() {
historyStartTime.value = Date.now();
// 每30秒保存一次学习历史
historyTimer.value = window.setInterval(() => {
saveHistoryRecord();
}, 10000); // 30秒
}
// 停止学习历史计时
function stopHistoryTimer() {
if (historyTimer.value) {
clearInterval(historyTimer.value);
historyTimer.value = null;
}
}
// 保存学习历史记录
async function saveHistoryRecord() {
if (!userInfo.value?.id || !learningHistory.value) return;
const currentTime = Date.now();
const duration = Math.floor((currentTime - historyStartTime.value) / 1000); // 秒
// 如果时长太短小于1秒不保存
if (duration < 1) return;
try {
const updatedHistory: TbLearningHistory = {
...learningHistory.value,
duration: (learningHistory.value.duration || 0) + duration,
endTime: new Date().toISOString(),
taskID: learningHistory.value.taskID // 保持原有的 taskID
};
// 调用API更新学习历史
const res = await learningHistoryApi.recordLearningHistory(updatedHistory);
if (res.success && res.data) {
learningHistory.value = res.data;
console.log(`💾 学习历史已保存 - 累计时长: ${learningHistory.value.duration}`);
}
// 重置开始时间
historyStartTime.value = currentTime;
} catch (error) {
console.error('❌ 保存学习历史失败:', error);
}
}
// 格式化日期简单格式YYYY-MM-DD
function formatDateSimple(date: string | Date): string {
if (!date) return '';
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 关闭处理
function handleClose() {
// 非Dialog模式下关闭时保存学习历史
if (!props.asDialog && learningHistory.value) {
saveHistoryRecord();
stopHistoryTimer();
}
if (props.asDialog) {
visible.value = false;
}
emit('close');
}
// 编辑处理
function handleEdit() {
emit('edit');
}
// 返回处理
function handleBack() {
// 返回前保存学习进度
if (learningRecord.value && !learningRecord.value.isComplete) {
saveLearningProgress();
}
stopLearningTimer();
const taskId = props.taskId || (route.query.taskId as string);
// 如果有 taskId返回任务详情
if (taskId) {
router.push({
path: '/study-plan/task-detail',
query: { taskId }
});
} else {
emit('back');
}
}
// 暴露方法
defineExpose({
open: () => {
if (props.asDialog) {
visible.value = true;
}
},
close: handleClose
});
</script>
<style lang="scss" scoped>
// 路由页面模式样式
.article-page-view {
// background: #f5f7fa;
padding-bottom: 60px;
}
.back-header {
position: sticky;
top: 0;
z-index: 100;
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
margin-bottom: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.loading-container {
max-width: 900px;
margin: 0 auto;
padding: 40px 24px;
background: #fff;
border-radius: 8px;
}
.article-wrapper {
max-width: 900px;
margin: 0 auto;
padding: 40px 24px;
background: #fff;
border-radius: 8px;
// box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.error-container {
padding: 80px 24px;
text-align: center;
}
.loading-state {
padding: 20px 0;
}
// Dialog 和路由模式共用的文章内容样式
.article-show-container {
max-width: 100%;
margin: 0 auto;
background: #fff;
}
.article-header {
margin-bottom: 36px;
}
.article-title {
font-family: "Source Han Sans SC";
font-weight: 600;
font-size: 28px;
line-height: 28px;
color: #141F38;
margin: 0 0 30px 0;
text-align: center;
}
.article-meta-info {
display: flex;
align-items: center;
gap: 48px;
margin-top: 30px;
justify-content: center;
}
.meta-item {
font-family: "Source Han Sans SC";
font-weight: 400;
font-size: 14px;
line-height: 28px;
color: #979797;
}
.separator {
width: 100%;
height: 1px;
background: #E9E9E9;
margin-bottom: 40px;
}
.article-cover {
margin-bottom: 24px;
text-align: center;
}
.cover-image {
max-width: 100%;
max-height: 400px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.article-content {
font-family: "Source Han Sans SC";
font-weight: 400;
font-size: 16px;
line-height: 30px;
color: #334155;
// 继承富文本编辑器的样式
:deep(img) {
max-width: 100%;
height: auto;
display: inline-block;
vertical-align: bottom;
}
:deep(video),
:deep(iframe) {
max-width: 100%;
height: auto;
display: inline-block;
vertical-align: bottom;
}
// 对齐方式样式 - 图片和视频分别处理
:deep(.ql-align-center) {
text-align: center !important;
// 视频始终居中显示
video, .custom-video {
display: block !important;
margin-left: auto !important;
margin-right: auto !important;
}
// 图片跟随文字对齐
img, .custom-image {
display: inline-block !important;
vertical-align: bottom !important;
}
}
:deep(.ql-align-right) {
text-align: right !important;
// 视频始终居中显示
video, .custom-video {
display: block !important;
margin-left: auto !important;
margin-right: auto !important;
}
// 图片跟随文字对齐
img, .custom-image {
display: inline-block !important;
vertical-align: bottom !important;
}
}
:deep(.ql-align-left) {
text-align: left !important;
// 视频始终居中显示
video, .custom-video {
display: block !important;
margin-left: auto !important;
margin-right: auto !important;
}
// 图片跟随文字对齐
img, .custom-image {
display: inline-block !important;
vertical-align: bottom !important;
}
}
// 其他富文本样式
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
margin: 24px 0 16px 0;
font-weight: bold;
color: #303133;
}
:deep(p) {
margin: 16px 0;
}
:deep(blockquote) {
margin: 16px 0;
padding: 16px;
background: #f5f7fa;
border-left: 4px solid #409eff;
border-radius: 4px;
}
:deep(code) {
background: #f5f7fa;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
:deep(pre) {
background: #f5f7fa;
padding: 16px;
border-radius: 4px;
overflow-x: auto;
margin: 16px 0;
code {
background: none;
padding: 0;
}
}
:deep(ul), :deep(ol) {
margin: 16px 0;
padding-left: 24px;
}
:deep(li) {
margin: 8px 0;
}
:deep(table) {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
:deep(th), :deep(td) {
border: 1px solid #ebeef5;
padding: 8px 12px;
text-align: left;
}
:deep(th) {
background: #f5f7fa;
font-weight: bold;
}
}
</style>