924 lines
24 KiB
Vue
924 lines
24 KiB
Vue
<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>
|