视图路径修改

This commit is contained in:
2025-10-27 17:29:25 +08:00
parent 5fa4e1cd42
commit 0033ac10ec
69 changed files with 162 additions and 1199 deletions

View File

@@ -0,0 +1,889 @@
<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 { ResourceCategory, Resource, ResourceVO, LearningRecord, TbLearningHistory } from '@/types';
defineOptions({
name: 'ArticleShowView'
});
interface Props {
modelValue?: boolean; // Dialog 模式下的显示状态
asDialog?: boolean; // 是否作为 Dialog 使用
title?: string; // Dialog 标题
width?: string; // Dialog 宽度
articleData?: Resource; // 文章数据Dialog 模式使用)
resourceID?: string; // 资源ID路由模式使用
categoryList?: Array<ResourceCategory>; // 分类列表
showEditButton?: boolean; // 是否显示编辑按钮
showBackButton?: boolean; // 是否显示返回按钮(路由模式)
backButtonText?: string; // 返回按钮文本
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
asDialog: false, // 默认作为路由页面使用
title: '文章预览',
width: '900px',
articleData: () => ({}),
categoryList: () => [],
showEditButton: false,
showBackButton: true,
backButtonText: '返回'
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'close': [];
'edit': [];
'back': [];
}>();
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 = 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);
}
}
// 创建学习记录
async function createLearningRecord(resourceID: string) {
if (!userInfo.value?.id) return;
try {
const taskId = route.query.taskId as string;
const res = await learningRecordApi.createRecord({
userID: userInfo.value.id,
resourceType: 1, // 资源类型:文章
resourceID: resourceID,
taskID: taskId || undefined,
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 {
await learningRecordApi.markComplete({
id: learningRecord.value.id,
taskID: route.query.taskId as string,
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) {
// 没有视频,默认阅读即完成
hasVideoCompleted.value = true;
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})`);
// 立即保存学习进度并标记完成
saveLearningProgress();
}
} else {
ElMessage.info(`视频 ${videoIndex + 1} 播放完成 (${completedCount}/${totalVideos.value})`);
}
}
// ==================== 学习历史记录功能 ====================
// 创建学习历史记录
async function createHistoryRecord(resourceID: string) {
if (!userInfo.value?.id) return;
try {
const res = await learningHistoryApi.recordResourceView(
userInfo.value.id,
resourceID,
0 // 初始时长为0
);
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();
}, 30000); // 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()
};
// 调用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 = 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 {
min-height: 100vh;
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: 'PingFang 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: 'PingFang 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: 'PingFang 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>