course_system/src/views/CourseDetail.vue
2025-12-06 23:42:29 +08:00

1284 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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>
<div class="course-detail-page">
<!-- 页面加载中 -->
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<p>正在加载课程详情...</p>
</div>
<div v-else class="container">
<!-- 顶部区域课程信息 + 评分统计 -->
<div class="top-section">
<!-- 左侧课程基本信息 -->
<div class="info-card card">
<div class="card-header">
<h1 class="course-name">
{{ course.course_name }}
<div class="tags" v-if="course.titles && course.titles.length > 0">
<span v-for="title in course.titles"
:key="title.title"
class="tag"
:style="{ borderColor: title.color, color: title.color }">
{{ title.title }}
</span>
</div>
</h1>
</div>
<div class="info-grid">
<div class="info-item">
<span class="label">分类</span>
<span class="value">{{ getCategoryName(course.category_id) }}</span>
</div>
<div class="info-item">
<span class="label">开课院系</span>
<span class="value">{{ getCollegeName(course.college) }}</span>
</div>
<div class="info-item full-width">
<span class="label">教师团队</span>
<span class="value">{{ course.teachers }}</span>
</div>
</div>
<div class="action-area">
<button class="share-btn" @click="shareCourse(true)">
<span class="icon">🔗</span> 复制短链接
</button>
</div>
</div>
<!-- 右侧评分详情 -->
<div class="rating-card card">
<h3>课程评分</h3>
<div class="rating-overview">
<div class="total-score">
<span class="score">{{ totalRating }}</span>
<span class="count">({{ ratingCount }}人评分)</span>
</div>
<div class="user-rating-action">
<p>您的评分</p>
<div class="stars interactive" @mouseleave="resetStars">
<span
v-for="n in 5"
:key="n"
:class="{ 'active': n <= (hoverRating || userRating) }"
@mouseover="highlightStars(n)"
@click="confirmRating(n)"
></span>
</div>
</div>
</div>
<div class="rating-bars">
<div class="bar-row">
<span class="star-label">5</span>
<el-progress :percentage="fiveStarPercent" status="success" :show-text="false" :stroke-width="8"></el-progress>
<span class="percent">{{ fiveStarPercent.toFixed(0) }}%</span>
</div>
<div class="bar-row">
<span class="star-label">4</span>
<el-progress :percentage="fourStarPercent" status="success" :show-text="false" :stroke-width="8"></el-progress>
<span class="percent">{{ fourStarPercent.toFixed(0) }}%</span>
</div>
<div class="bar-row">
<span class="star-label">3</span>
<el-progress :percentage="threeStarPercent" status="warning" :show-text="false" :stroke-width="8"></el-progress>
<span class="percent">{{ threeStarPercent.toFixed(0) }}%</span>
</div>
<div class="bar-row">
<span class="star-label">2</span>
<el-progress :percentage="twoStarPercent" status="warning" :show-text="false" :stroke-width="8"></el-progress>
<span class="percent">{{ twoStarPercent.toFixed(0) }}%</span>
</div>
<div class="bar-row">
<span class="star-label">1</span>
<el-progress :percentage="oneStarPercent" status="exception" :show-text="false" :stroke-width="8"></el-progress>
<span class="percent">{{ oneStarPercent.toFixed(0) }}%</span>
</div>
</div>
</div>
</div>
<!-- 中部区域AI 总结 -->
<div class="ai-section card">
<div class="section-header">
<h2>
<span class="icon">🤖</span> AI 课程总结
<span class="beta-badge">Beta</span>
</h2>
<span class="ai-disclaimer">内容由 GPT-5 生成仅供参考</span>
</div>
<div class="ai-content">
<template v-if="displayedSummary">
<div class="summary-text">
{{ displayedSummary }}<span v-if="isTyping" class="cursor">|</span>
</div>
</template>
<template v-else>
<p class="empty-text">当前课程的评分和评论数据过少暂不能生成AI总结</p>
</template>
</div>
</div>
<!-- 底部区域评论区 -->
<div class="comments-section card">
<div class="section-header">
<h2>课程评论</h2>
<div class="sort-tabs">
<span :class="{ active: sortBy === 'like_count' }" @click="changeSort('like_count')">最热</span>
<span class="divider">|</span>
<span :class="{ active: sortBy === 'comment_time' }" @click="changeSort('comment_time')">最新</span>
</div>
</div>
<!-- 发表评论 -->
<div class="post-comment">
<div class="input-group">
<input v-model="nickname" placeholder="一句话概括您的评论" class="nickname-input" />
<textarea v-model="commentContent" placeholder="分享您的课程体验、学习难度、考核方式等..." class="comment-textarea"></textarea>
</div>
<button @click="submitComment" class="submit-btn">提交评论</button>
</div>
<!-- 评论列表 -->
<div v-if="filteredComments && filteredComments.length > 0" class="comments-list">
<div v-for="comment in comments" :key="comment.comment_id" class="comment-item">
<div class="comment-header">
<span class="comment-author">
{{ comment.nickname }}
<span v-if="comment.daren" class="daren-tag">评论达人</span>
</span>
<span class="comment-date">{{ formatTime(comment.comment_time) }}</span>
</div>
<div class="comment-body">
<span v-if="comment.top" class="premium-tag">优质</span>{{ comment.comment_content }}
</div>
<div class="comment-footer">
<div class="footer-top">
<div class="rating-tag">
评分: <span>{{ comment.rating }}</span>
</div>
<div class="actions">
<span class="like-btn" :class="{ liked: comment.is_liked }" @click="toggleLike(comment)">
{{ comment.like_count }}
</span>
<button v-if="comment.deleteable" @click="deleteComment(comment.comment_id)" class="action-link delete">
删除
</button>
<template v-else>
<button @click="openQuestionModal(comment)" class="action-link question-btn">
发起追问
</button>
<!-- <span class="help-icon" @click="openHelp" title="了解追问功能">
?
</span> -->
</template>
</div>
</div>
<!-- 管理员操作区 -->
<div v-if="isAdmin" class="admin-actions-row">
<span class="admin-label">管理员操作:</span>
<button @click="adminMarkTop(comment)" class="action-link admin-btn">
{{ comment.top ? '取消优质' : '设为优质' }}
</button>
<button @click="adminDeleteComment(comment.comment_id)" class="action-link admin-btn delete">
删除
</button>
</div>
</div>
</div>
</div>
<div v-else class="no-comments">
<p>暂无评论快来抢沙发吧</p>
</div>
</div>
</div>
<!-- 模态框组件 (复用原有的逻辑) -->
<!-- 分享成功提示 -->
<transition name="fade">
<div class="toast" v-if="showShareModal">
链接复制成功快去分享给其他小伙伴吧
</div>
</transition>
<!-- 错误提示 -->
<transition name="fade">
<div class="toast error" v-if="showErrorModal">
获取数据失败正在刷新页面...
</div>
</transition>
<!-- 聊天模态框 -->
<div v-if="isChatModalVisible" class="modal-overlay">
<div class="modal-box">
<span class="close-btn" @click="closeChatModal">×</span>
<h3>发起聊天</h3>
<textarea
v-model="chatMessage"
placeholder="请输入聊天内容至少6个字符"
class="modal-textarea"
></textarea>
<div class="modal-actions">
<button @click="submitChat" :disabled="chatMessage.length < 6" class="primary-btn">发起</button>
</div>
</div>
</div>
<!-- 会话已存在模态框 -->
<div v-if="isConversationExistsModalVisible" class="modal-overlay">
<div class="modal-box">
<span class="close-btn" @click="closeConversationExistsModal">×</span>
<h3>提示</h3>
<p>在此课程下您与此用户已有会话</p>
<div class="modal-actions">
<button @click="navigateToConversation(existingConversationId)" class="primary-btn">进入会话</button>
<button @click="closeConversationExistsModal" class="secondary-btn">取消</button>
</div>
</div>
</div>
<!-- 追问模态框 -->
<div v-if="showQuestionModal" class="modal-overlay" @click.self="closeQuestionModal">
<div class="modal-box question-modal">
<span class="close-btn" @click="closeQuestionModal">×</span>
<h3>发起追问</h3>
<div class="explanation-card">
请先 <a href="/qa" target="_blank" class="help-link">点击此处</a> 了解追问功能的用法友好交流~
</div>
<textarea
v-model="questionContent"
placeholder="请输入您的问题..."
class="modal-textarea"
style="resize: vertical; min-height: 100px;"
></textarea>
<div class="modal-actions">
<button @click="submitQuestion" :disabled="submittingQuestion || !questionContent.trim()" class="primary-btn">
{{ submittingQuestion ? '提交中...' : '发起追问' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'CourseDetail',
data() {
return {
courseId: null,
loading: true,
course: {},
token: null,
userRating: 0,
hoverRating: 0,
totalRating: 0,
ratingCount: 0, // Add ratingCount to data
fiveStarPercent: 0,
fourStarPercent: 0,
threeStarPercent: 0,
twoStarPercent: 0,
oneStarPercent: 0,
comments: [],
isAdmin: false,
nickname: '',
commentContent: '',
sortBy: 'like_count',
showShareModal: false,
cachedEmail: null,
isChatModalVisible: false,
isConversationExistsModalVisible: false,
chatMessage: '',
currentCommentId: null,
existingConversationId: null,
showErrorModal: false,
aiSummary: null,
displayedSummary: '',
isTyping: false,
shouldStopTyping: false,
showQuestionModal: false,
questionContent: '',
currentQuestionCommentId: null,
submittingQuestion: false,
};
},
computed: {
filteredComments() {
return this.comments.length > 0 ? this.comments : null;
}
},
async created() {
// 从路由获取 course_id
this.courseId = this.$route.params.course_id;
if (this.courseId) {
await this.loadCourseData();
if (this.course && this.course.course_name) {
document.title = this.course.course_name + " (" + this.course.teachers + ") " + " - NEU小站课程评分系统";
}
} else {
this.loading = false;
// 处理没有 ID 的情况,跳转到 404 页面
this.$router.replace('/404');
}
},
beforeUnmount() {
this.shouldStopTyping = true;
},
methods: {
async loadCourseData() {
this.loading = true;
this.course = {};
this.aiSummary = null;
this.displayedSummary = '';
this.isTyping = false;
this.shouldStopTyping = false;
try {
// 0. 获取Token (提前获取,因为后续接口需要)
this.getToken();
// 1. 获取课程基础详情 (合并了 AI Summary)
const response = await fetch(`https://coursesystem.xn--xhq44jb2fzpc.com/new/detail?course_id=${this.courseId}`, {
headers: this.token ? { 'Authorization': this.token } : {}
});
if (response.status === 404) {
this.$router.replace('/404');
return;
}
if (!response.ok) throw new Error('Failed to fetch course detail');
const data = await response.json();
this.course = data;
// 初始化 ratingCount
this.ratingCount = data.rating_count || 0;
// 如果有 ai_summary保存它
if (data.ai_summary) {
this.aiSummary = data.ai_summary;
}
await Promise.all([
this.fetchAndCacheEmail(),
this.fetchRatingData(),
this.fetchComments()
]);
} catch (error) {
console.error('Error loading course data:', error);
} finally {
this.loading = false;
if (this.aiSummary) {
this.$nextTick(() => {
this.typewriterEffect(this.aiSummary);
});
} else {
this.displayedSummary = '当前课程的评分和评论数据过少暂不能生成AI总结。';
}
}
},
getToken() {
const tokenCookie = document.cookie.split('; ').find(row => row.startsWith('token='));
if (tokenCookie) {
this.token = tokenCookie.split('=')[1];
} else {
this.token = null;
}
},
changeSort(type) {
if (this.sortBy !== type) {
this.sortBy = type;
this.fetchComments();
}
},
getCategoryName(categoryId) {
const categories = {
1: '选修课-通识选修类',
2: '选修课-人文选修类',
3: '选修课-专业方向类',
4: '选修课-体育类',
5: '选修课-学科基础类',
6: '选修课-暑期国际课',
7: '选修课-数学与自然科学类',
8: '选修课-重修专栏',
9: '必修课-数学与自然科学类',
10: '必修课-人文社会科学类',
11: '必修课-学科基础类',
12: '必修课-专业方向类',
13: '必修课-实践类'
};
return categories[categoryId] || '未知分类';
},
getCollegeName(collegeId) {
const collegeMap = {
59: '未填写', 1: '材料科学与工程学院', 2: '创新创业学院', 3: '档案馆', 4: '党委宣传部', 5: '党委组织部',
6: '发展规划与学科建设处', 7: '佛山研究生创新学院', 8: '工程训练中心', 9: '工会', 10: '工商管理学院',
11: '国防教育学院', 12: '国际教育学院', 13: '后勤服务中心', 14: '后勤管理处', 15: '浑南管委会',
16: '江河建筑学院', 17: '尖子班', 18: '教务处', 19: '基础学院', 20: '计划财经处',
21: '机器人科学与工程学院', 22: '计算机科学与工程学院', 23: '计算中心', 24: '纪委',
25: '机械工程与自动化学院', 26: '继续教育学院', 27: '科技产业集团', 28: '科学技术研究院',
29: '理学院', 30: '马克思主义学院', 31: '民族教育学院', 33: '人事处', 34: '人文选修课群',
35: '软件学院', 36: '生命科学与健康学院', 37: '体育部', 38: '体育场馆管理中心', 39: '团委',
40: '图书馆', 41: '外国语学院', 42: '外联处', 43: '网络教育学院', 44: '未来技术学院',
45: '文法学院', 46: '校长办公室', 47: '信息科学与工程学院', 48: '新知识课群', 49: '学生处',
50: '学生创新中心', 51: '学生指导服务中心', 52: '研究生院', 53: '冶金学院', 54: '艺术学院',
55: '医学与生物信息工程学院', 56: '医院', 57: '资产与实验室管理处', 58: '资源与土木工程学院'
};
return collegeMap[collegeId] || '未知院系';
},
async fetchRatingData() {
if (!this.token) return;
try {
const response = await fetch(`https://coursesystem.xn--xhq44jb2fzpc.com/new/rating?course_id=${this.courseId}`, {
headers: { 'Authorization': this.token }
});
if (response.ok) {
const result = await response.json();
this.totalRating = result.total_rating || 0;
this.fiveStarPercent = result.five_stars * 100;
this.fourStarPercent = result.four_stars * 100;
this.threeStarPercent = result.three_stars * 100;
this.twoStarPercent = result.two_stars * 100;
this.oneStarPercent = result.one_star * 100;
this.userRating = result.user_rating || 0;
this.ratingCount = result.rating_count || 0;
}
} catch (error) {
console.error('Fetch rating data failed:', error);
this.totalRating = 0;
this.fiveStarPercent = 0; this.fourStarPercent = 0; this.threeStarPercent = 0;
this.twoStarPercent = 0; this.oneStarPercent = 0;
this.userRating = 0;
}
},
highlightStars(star) { this.hoverRating = star; },
resetStars() { this.hoverRating = 0; },
confirmRating(star) {
if (confirm(`您确定要给课程 "${this.course.course_name}" 评分:${star}.0 吗?此评分将覆盖之前的评分。`)) {
this.submitRating(star);
}
},
async submitRating(rating) {
const response = await fetch('https://coursesystem.xn--xhq44jb2fzpc.com/submit-rating', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': this.token },
body: JSON.stringify({ course_id: this.courseId, rating: rating })
});
if (response.ok) {
this.fetchRatingData();
}
},
async typewriterEffect(text, speed = 10) {
this.isTyping = true;
this.displayedSummary = '';
this.shouldStopTyping = false;
for (let i = 0; i < text.length; i++) {
if (this.shouldStopTyping) break;
this.displayedSummary += text[i];
await new Promise(resolve => setTimeout(resolve, speed));
}
this.isTyping = false;
},
async fetchAISummary() {
// 已合并到 loadCourseData此方法废弃但保留空壳防止报错如果模板中有调用
// 实际上模板没有直接调用,只有 created 中调用。
// 为了安全起见,留空。
},
async fetchComments() {
try {
if (!this.token) { /* Handle no token silently or prompt login if needed, but allow view */ }
const response = await fetch(
`https://coursesystem.xn--xhq44jb2fzpc.com/get-comments?course_id=${this.courseId}&sort_by=${this.sortBy}`,
{ headers: { 'Authorization': this.token } }
);
if (response.status === 404) { this.comments = []; return; }
if (!response.ok) throw new Error('获取评论失败');
const result = await response.json();
this.comments = result.comments;
this.isAdmin = result.is_admin || false;
} catch (error) {
console.error(error);
}
},
async toggleLike(comment) {
const url = comment.is_liked ? 'unlike-comment' : 'like-comment';
await fetch(`https://coursesystem.xn--xhq44jb2fzpc.com/${url}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': this.token },
body: JSON.stringify({ comment_id: comment.comment_id })
});
this.fetchComments();
},
async submitComment() {
if (this.nickname.length < 2 || this.commentContent.length < 6) {
alert('一句话概括请至少包含2个字符评论请至少包含6个字符。');
return;
}
await fetch('https://coursesystem.xn--xhq44jb2fzpc.com/submit-comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': this.token },
body: JSON.stringify({ course_id: this.courseId, nickname: this.nickname, comment_content: this.commentContent })
});
this.fetchComments();
this.nickname = '';
this.commentContent = '';
},
async deleteComment(commentId) {
if(!confirm("确定删除评论吗?")) return;
const response = await fetch('https://coursesystem.xn--xhq44jb2fzpc.com/delete-comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': this.token },
body: JSON.stringify({ comment_id: commentId })
});
if(response.ok) this.fetchComments();
},
async adminMarkTop(comment) {
const action = comment.top ? '取消优质吗?' : '设为优质吗?优质评论将会被展示在评论列表顶部,并在搜索页优先作为每个课程的代表性评论。';
if (!confirm(`确定要将此评论${action}`)) return;
try {
const response = await fetch('https://coursesystem.xn--xhq44jb2fzpc.com/comment_mng/mark_top', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': this.token },
body: JSON.stringify({ comment_id: comment.comment_id })
});
if (response.ok) {
this.fetchComments();
} else {
const result = await response.json();
alert(result.error || '操作失败');
}
} catch (error) {
console.error('Admin mark top error:', error);
alert('操作失败,请稍后再试');
}
},
async adminDeleteComment(commentId) {
if (!confirm("管理员操作:确定要强制删除此评论吗?此操作不可恢复!")) return;
try {
const response = await fetch('https://coursesystem.xn--xhq44jb2fzpc.com/comment_mng/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': this.token },
body: JSON.stringify({ comment_id: commentId })
});
if (response.ok) {
this.fetchComments();
} else {
const result = await response.json();
alert(result.error || '删除失败');
}
} catch (error) {
console.error('Admin delete error:', error);
alert('删除失败,请稍后再试');
}
},
formatTime(time) {
const date = new Date(time);
return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`;
},
async fetchAndCacheEmail() {
if (this.cachedEmail || !this.token) return;
try {
const response = await fetch('https://userlogin.xn--xhq44jb2fzpc.com/verifyToken', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': this.token }
});
const result = await response.json();
if (response.ok && result.email) this.cachedEmail = result.email;
} catch(e) { console.error(e); }
},
base64Encode(value) { return btoa(value); },
shareCourse(useShortLink = false) {
let shareText;
// 检查是否显式请求短链接(避免事件对象被误判)
if (useShortLink === true) {
// 将 courseId 转为 Base64 URL (RFC 4648)
const base64Str = btoa(String(this.courseId));
const base64Url = base64Str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
shareText = `https://s.ember.ac.cn/c/${base64Url}`;
} else {
shareText = `https://course.xn--xhq44jb2fzpc.com/detail/${this.courseId}`;
}
this.copyToClipboard(shareText);
this.showShareModal = true;
setTimeout(() => { this.showShareModal = false; }, 3000);
},
copyToClipboard(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
},
openChatModal(commentId) {
this.currentCommentId = commentId;
this.chatMessage = '';
this.isChatModalVisible = true;
},
closeChatModal() { this.isChatModalVisible = false; },
closeConversationExistsModal() { this.isConversationExistsModalVisible = false; },
async submitChat() {
if (this.chatMessage.length < 6) { alert('聊天内容需至少6个字符'); return; }
try {
const response = await fetch('https://coursesystem.xn--xhq44jb2fzpc.com/chat/start-conversation', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: this.token },
body: JSON.stringify({ comment_id: this.currentCommentId, content: this.chatMessage })
});
const result = await response.json();
if (response.ok && result.message === 'Conversation started successfully') {
this.$router.push(`/chat?mid=${result.conversation_id}`);
} else if (response.ok && result.message === 'Conversation already exists') {
this.existingConversationId = result.conversation_id;
this.isConversationExistsModalVisible = true;
} else {
alert(result.error || '发起会话失败');
}
} catch (error) {
alert('发起会话失败,请稍后再试');
} finally {
this.closeChatModal();
}
},
navigateToConversation(conversationId) {
this.$router.push(`/chat?mid=${conversationId}`);
this.closeConversationExistsModal();
},
openHelp() {
window.open('/qa', '_blank');
},
openQuestionModal(comment) {
this.currentQuestionCommentId = comment.comment_id;
this.questionContent = '';
this.showQuestionModal = true;
},
closeQuestionModal() {
this.showQuestionModal = false;
this.questionContent = '';
this.currentQuestionCommentId = null;
},
async submitQuestion() {
if (!this.questionContent.trim()) return;
this.submittingQuestion = true;
try {
const response = await fetch('https://coursesystem.xn--xhq44jb2fzpc.com/qa/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': this.token
},
body: JSON.stringify({
comment_id: this.currentQuestionCommentId,
content: this.questionContent
})
});
const data = await response.json();
if (response.ok) {
alert('追问发起成功!');
this.closeQuestionModal();
} else {
alert(data.error || '发起追问失败');
}
} catch (error) {
console.error('发起追问出错:', error);
alert('网络错误,请稍后重试');
} finally {
this.submittingQuestion = false;
}
}
}
};
</script>
<style scoped>
.course-detail-page {
background-color: #f5f7fa;
min-height: 100vh;
padding-bottom: 40px;
}
.premium-tag {
display: inline-block;
background-color: #e1f3d8;
color: #67c23a;
font-size: 0.8rem;
padding: 2px 6px;
border-radius: 4px;
margin-right: 8px;
font-weight: bold;
vertical-align: middle;
}
.daren-tag {
display: inline-block;
background-color: #f56c6c;
color: white;
font-size: 0.7rem;
padding: 1px 5px;
border-radius: 4px;
margin-left: 6px;
font-weight: normal;
vertical-align: middle;
line-height: 1.2;
}
.admin-actions-row {
display: flex;
align-items: center;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #ebeef5;
justify-content: flex-end;
}
.admin-label {
font-size: 0.85rem;
color: #909399;
margin-right: 5px;
}
.admin-btn {
color: #409eff;
margin-left: 5px;
}
.admin-btn.delete {
color: #f56c6c;
}
.admin-btn:hover {
text-decoration: underline;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
color: #666;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #409eff;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin-bottom: 15px;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
/* 卡片通用样式 */
.card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.05);
padding: 24px;
margin-bottom: 24px;
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
}
/* 顶部布局 */
.top-section {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 24px;
}
/* 课程信息卡片 */
.card-header {
margin-bottom: 20px;
}
.course-name {
font-size: 24px;
color: #333;
margin: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.tags {
display: flex;
gap: 8px;
}
.tag {
font-size: 12px;
padding: 2px 8px;
border: 1px solid;
border-radius: 12px;
white-space: nowrap;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.info-item {
display: flex;
flex-direction: column;
}
.info-item.full-width {
grid-column: 1 / -1;
}
.label {
font-size: 13px;
color: #909399;
margin-bottom: 4px;
}
.value {
font-size: 16px;
color: #303133;
font-weight: 500;
}
.share-btn {
background-color: #ecf5ff;
color: #409eff;
border: none;
padding: 10px 20px;
border-radius: 20px;
cursor: pointer;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 6px;
transition: background 0.2s;
}
.share-btn:hover {
background-color: #d9ecff;
}
/* 评分卡片 */
.rating-overview {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.total-score {
display: flex;
align-items: baseline;
gap: 8px;
}
.score {
font-size: 48px;
font-weight: bold;
color: #409eff;
line-height: 1;
}
.count {
color: #909399;
font-size: 14px;
}
.user-rating-action p {
margin: 0 0 5px 0;
font-size: 13px;
color: #606266;
text-align: center;
}
.stars {
color: #ddd;
font-size: 24px;
cursor: pointer;
}
.stars.interactive span:hover,
.stars.interactive span.active {
color: #f7ba2a;
}
.bar-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
font-size: 13px;
}
.star-label {
width: 30px;
color: #606266;
}
.el-progress {
flex: 1;
}
.percent {
width: 35px;
text-align: right;
color: #909399;
}
/* AI 总结 */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid #ebeef5;
padding-bottom: 15px;
}
.section-header h2 {
font-size: 18px;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.beta-badge {
background: #f56c6c;
color: white;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
vertical-align: top;
}
.ai-disclaimer {
font-size: 12px;
color: #909399;
}
.ai-content {
line-height: 1.6;
color: #303133;
/* min-height: 80px; */
}
.summary-text {
white-space: pre-wrap;
}
.cursor {
animation: blink 1s infinite;
}
@keyframes blink { 50% { opacity: 0; } }
/* 评论区 */
.sort-tabs {
display: flex;
gap: 10px;
font-size: 14px;
color: #909399;
}
.sort-tabs span {
cursor: pointer;
}
.sort-tabs span.active {
color: #409eff;
font-weight: bold;
}
.divider {
color: #ebeef5;
cursor: default !important;
}
.post-comment {
background: #f9fafc;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
.nickname-input {
padding: 8px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
width: 200px;
}
.comment-textarea {
padding: 10px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
min-height: 80px;
resize: vertical;
}
.submit-btn {
background: #409eff;
color: white;
border: none;
padding: 8px 24px;
border-radius: 4px;
cursor: pointer;
align-self: flex-end;
}
.comment-item {
border-bottom: 1px solid #ebeef5;
padding: 20px 0;
}
.comment-item:last-child {
border-bottom: none;
}
.comment-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.comment-author {
font-weight: bold;
color: #303133;
}
.comment-date {
font-size: 12px;
color: #909399;
}
.comment-body {
font-size: 14px;
color: #606266;
margin-bottom: 12px;
white-space: pre-wrap;
}
.comment-footer {
display: flex;
flex-direction: column;
margin-top: 10px;
font-size: 0.9rem;
color: #999;
}
.footer-top {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.admin-actions-row {
display: flex;
align-items: center;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #ebeef5;
justify-content: flex-end;
}
.rating-tag span {
color: #ff9900;
font-weight: bold;
margin-left: 4px;
}
.actions {
display: flex;
align-items: center;
gap: 15px;
}
.like-btn {
cursor: pointer;
color: #909399;
}
.like-btn.liked {
color: #f56c6c;
}
.action-link {
background: none;
border: none;
cursor: pointer;
font-size: 13px;
}
.action-link.delete { color: #f56c6c; }
.action-link.chat { color: #409eff; }
/* Toast & Modal Styles */
.toast {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.8);
color: white;
padding: 10px 20px;
border-radius: 4px;
z-index: 2000;
}
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.modal-box {
background: white;
width: 90%;
max-width: 400px;
padding: 24px;
border-radius: 8px;
position: relative;
}
.close-btn {
position: absolute;
right: 15px;
top: 10px;
font-size: 24px;
cursor: pointer;
color: #909399;
}
.modal-textarea {
width: 90%;
min-height: 100px;
margin: 15px 0;
padding: 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
resize: none;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.primary-btn {
background: #409eff;
color: white;
border: none;
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
}
.secondary-btn {
background: white;
border: 1px solid #dcdfe6;
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
}
/* 移动端适配 */
@media (max-width: 768px) {
.top-section {
grid-template-columns: 1fr; /* 改为单列 */
gap: 16px;
}
.info-grid {
grid-template-columns: 1fr; /* 信息改为单列 */
}
.rating-overview {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.user-rating-action {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
.score {
font-size: 36px;
}
.nickname-input {
width: 100%;
}
}
.question-btn {
color: #409EFF;
margin-left: 10px;
background: none;
border: none;
cursor: pointer;
font-size: 14px;
}
/* .question-btn:hover {
text-decoration: underline;
} */
.help-icon {
display: inline-flex;
justify-content: center;
align-items: center;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid #909399;
color: #909399;
font-size: 12px;
margin-left: 5px;
cursor: pointer;
}
.help-icon:hover {
color: #409EFF;
border-color: #409EFF;
}
.question-modal .explanation-card {
background-color: #f4f4f5;
color: #909399;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
font-size: 14px;
}
.question-modal .help-link {
color: #409EFF;
text-decoration: none;
font-weight: bold;
}
/* .question-modal .help-link:hover {
text-decoration: underline;
} */
</style>