1284 lines
37 KiB
Vue
1284 lines
37 KiB
Vue
<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> |