feat:个人中心对接

This commit is contained in:
小张 2025-08-19 01:50:27 +08:00
parent 6e1ad5ea07
commit 9201cc44e5
12 changed files with 830 additions and 139 deletions

View File

@ -19,6 +19,7 @@ export const API_ENDPOINTS = {
// 认证相关 // 认证相关
AUTH: { AUTH: {
LOGIN: '/biz/user/login', LOGIN: '/biz/user/login',
USER_INFO: '/biz/user/info',
REGISTER: '/auth/register', REGISTER: '/auth/register',
LOGOUT: '/auth/logout', LOGOUT: '/auth/logout',
REFRESH: '/auth/refresh', REFRESH: '/auth/refresh',

View File

@ -8,6 +8,8 @@ import type {
// BackendLoginResponse, // BackendLoginResponse,
RegisterRequest, RegisterRequest,
UserProfile, UserProfile,
BackendUserInfo,
UserInfoResponse,
} from '../types' } from '../types'
/** /**
@ -227,11 +229,82 @@ export class AuthApi {
return ApiRequest.post('/auth/refresh', { refreshToken }) return ApiRequest.post('/auth/refresh', { refreshToken })
} }
// 获取当前用户信息 // 获取当前用户信息 - 使用后端实际接口
static async getUserInfo(): Promise<UserInfoResponse> {
const response = await ApiRequest.get<any>('/biz/user/info')
console.log('🔍 getUserInfo - 原始响应:', response)
// 后端返回的格式是 { success, message, code, result: BackendUserInfo, timestamp }
// 而不是标准的 ApiResponse 格式
if (response.data && response.data.result) {
return {
success: response.data.success || (response.data.code === 200 || response.data.code === 0),
message: response.data.message || '',
code: response.data.code,
result: response.data.result,
timestamp: response.data.timestamp || Date.now()
}
} else {
// 如果是标准ApiResponse格式直接转换
return {
success: response.code === 200 || response.code === 0,
message: response.message || '',
code: response.code,
result: response.data,
timestamp: Date.now()
}
}
}
// 获取当前用户信息 - 兼容旧接口
static getCurrentUser(): Promise<ApiResponse<User>> { static getCurrentUser(): Promise<ApiResponse<User>> {
return ApiRequest.get('/users/info') return ApiRequest.get('/users/info')
} }
// 将后端用户信息转换为前端User格式
static convertBackendUserToUser(backendUser: BackendUserInfo): User {
const { baseInfo, roles, extendedInfo } = backendUser
// 转换性别
let gender: 'male' | 'female' | 'other' = 'other'
if (baseInfo.sex === 1) gender = 'male'
else if (baseInfo.sex === 2) gender = 'female'
// 转换状态 (1表示正常/激活0表示禁用)
const status = baseInfo.status === 1 ? 'active' : 'inactive'
// 确定角色
let role: 'student' | 'teacher' | 'admin' = 'student'
if (roles.includes('admin') || roles.includes('ADMIN')) {
role = 'admin'
} else if (roles.includes('teacher') || roles.includes('TEACHER')) {
role = 'teacher'
}
return {
id: parseInt(baseInfo.id) || 0,
username: baseInfo.username,
email: baseInfo.email,
phone: baseInfo.phone,
nickname: baseInfo.realname || baseInfo.username,
avatar: baseInfo.avatar,
role,
status: status as 'active' | 'inactive' | 'banned',
createdAt: new Date().toISOString(), // 后端没有提供,使用当前时间
updatedAt: new Date().toISOString(), // 后端没有提供,使用当前时间
profile: {
realName: baseInfo.realname,
gender,
birthday: baseInfo.birthday,
bio: extendedInfo.tag,
location: extendedInfo.college,
website: '',
socialLinks: {}
}
}
}
// 更新用户资料 // 更新用户资料
static updateProfile(data: Partial<UserProfile>): Promise<ApiResponse<User>> { static updateProfile(data: Partial<UserProfile>): Promise<ApiResponse<User>> {
return ApiRequest.put('/auth/profile', data) return ApiRequest.put('/auth/profile', data)

View File

@ -17,8 +17,10 @@ import type {
BackendCourseSection, BackendCourseSection,
BackendInstructor, BackendInstructor,
BackendSectionVideo, BackendSectionVideo,
BackendComment,
SectionVideo, SectionVideo,
VideoQuality, VideoQuality,
CourseComment,
Quiz, Quiz,
LearningProgress, LearningProgress,
SearchRequest, SearchRequest,
@ -118,6 +120,7 @@ export class CourseApi {
title: item.name || '', title: item.name || '',
description: item.description || '', description: item.description || '',
instructor: item.school || '未知讲师', instructor: item.school || '未知讲师',
teacherList: item.teacherList || [], // 新增:传递讲师列表
duration: item.arrangement || '待定', duration: item.arrangement || '待定',
level: this.mapDifficultyToLevel(item.difficulty), level: this.mapDifficultyToLevel(item.difficulty),
category: item.subject || '其他', category: item.subject || '其他',
@ -948,6 +951,87 @@ export class CourseApi {
return qualities return qualities
} }
// 获取课程评论列表
static async getCourseComments(courseId: string): Promise<ApiResponse<CourseComment[]>> {
try {
console.log('🔍 获取课程评论数据课程ID:', courseId)
console.log('🔍 API请求URL: /biz/comment/course/' + courseId + '/list')
const response = await ApiRequest.get<any>(`/biz/comment/course/${courseId}/list`)
console.log('🔍 评论API响应:', response)
// 处理后端响应格式
if (response.data && response.data.success && response.data.result) {
console.log('✅ 响应状态码:', response.data.code)
console.log('✅ 响应消息:', response.data.message)
console.log('✅ 原始评论数据:', response.data.result)
console.log('✅ 评论数据数量:', response.data.result.length || 0)
// 适配数据格式
const adaptedComments: CourseComment[] = response.data.result.map((comment: BackendComment) => ({
id: comment.id,
userId: comment.userId,
userName: comment.userName || '匿名用户',
userAvatar: comment.userAvatar || '',
userTag: comment.userTag || '',
content: comment.content || '',
images: comment.imgs ? comment.imgs.split(',').filter(img => img.trim()) : [], // 图片URL逗号分隔
isTop: comment.izTop === 1, // 1=置顶0=普通
likeCount: comment.likeCount || 0,
createTime: comment.createTime || '',
timeAgo: this.formatTimeAgo(comment.createTime) // 计算相对时间
}))
console.log('✅ 适配后的评论数据:', adaptedComments)
return {
code: response.data.code,
message: response.data.message,
data: adaptedComments
}
} else {
console.warn('⚠️ API返回的数据结构不正确:', response.data)
return {
code: 500,
message: '数据格式错误',
data: []
}
}
} catch (error) {
console.error('❌ 评论API调用失败:', error)
throw error
}
}
// 格式化时间为相对时间显示
private static formatTimeAgo(createTime: string): string {
if (!createTime) return '未知时间'
try {
const now = new Date()
const commentTime = new Date(createTime)
const diffMs = now.getTime() - commentTime.getTime()
const diffMinutes = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
const diffWeeks = Math.floor(diffDays / 7)
const diffMonths = Math.floor(diffDays / 30)
if (diffMinutes < 1) return '刚刚'
if (diffMinutes < 60) return `${diffMinutes}分钟前`
if (diffHours < 24) return `${diffHours}小时前`
if (diffDays < 7) return `${diffDays}天前`
if (diffWeeks < 4) return `${diffWeeks}周前`
if (diffMonths < 12) return `${diffMonths}个月前`
return commentTime.toLocaleDateString()
} catch (error) {
console.warn('时间格式化失败:', error)
return createTime
}
}
} }
export default CourseApi export default CourseApi

View File

@ -46,6 +46,39 @@ export interface UserProfile {
} }
} }
// 后端用户信息接口返回的数据结构
export interface BackendUserInfo {
baseInfo: {
id: string
username: string
realname: string
avatar: string
phone: string
email: string
sex: number // 0: 未知, 1: 男, 2: 女
birthday: string
status: number // 0: 正常, 1: 禁用
}
roles: string[]
extendedInfo: {
major: string
college: string
education: string
title: string
tag: string
sortOrder: number
}
}
// 用户信息接口响应类型
export interface UserInfoResponse {
success: boolean
message: string
code: number
result: BackendUserInfo
timestamp: number
}
// 登录注册类型 // 登录注册类型
export interface LoginRequest { export interface LoginRequest {
email?: string email?: string
@ -107,6 +140,7 @@ export interface Course {
requirements: string[] requirements: string[]
objectives: string[] objectives: string[]
instructor: Instructor instructor: Instructor
teacherList?: BackendInstructor[] // 新增讲师列表字段(从后端适配)
status: 'draft' | 'published' | 'archived' status: 'draft' | 'published' | 'archived'
isEnrolled?: boolean isEnrolled?: boolean
progress?: number progress?: number
@ -279,6 +313,7 @@ export interface BackendCourseItem {
createTime: string createTime: string
updateBy: string updateBy: string
updateTime: string updateTime: string
teacherList: BackendInstructor[] // 新增讲师列表字段
} }
// 后端课程列表响应格式 // 后端课程列表响应格式
@ -415,6 +450,7 @@ export interface BackendInstructor {
avatar: string avatar: string
title: string title: string
tag: string tag: string
sortOrder?: number // 排序字段,用于多讲师排序
} }
// 后端讲师列表响应格式 // 后端讲师列表响应格式
@ -475,6 +511,49 @@ export interface SectionVideo {
currentQuality: string // 当前选中的质量 currentQuality: string // 当前选中的质量
} }
// 后端评论数据结构
export interface BackendComment {
id: string
userId: string
targetType: string
targetId: string
content: string
imgs: string
izTop: number // 是否置顶0=否1=是
likeCount: number
createBy: string
createTime: string
updateBy: string
updateTime: string
userName: string
userAvatar: string
userTag: string
}
// 后端评论列表响应格式
export interface BackendCommentListResponse {
success: boolean
message: string
code: number
result: BackendComment[]
timestamp: number
}
// 前端评论类型
export interface CourseComment {
id: string
userId: string
userName: string
userAvatar: string
userTag: string
content: string
images: string[] // 评论图片列表
isTop: boolean // 是否置顶
likeCount: number
createTime: string
timeAgo: string // 相对时间显示(如"2天前"
}
// 前端章节列表响应格式 // 前端章节列表响应格式
export interface CourseSectionListResponse { export interface CourseSectionListResponse {
list: CourseSection[] list: CourseSection[]

View File

@ -121,21 +121,48 @@ const handleLogin = async () => {
if (response.code === 200 || response.code === 0) { if (response.code === 200 || response.code === 0) {
const { user, token, refreshToken } = response.data const { user, token, refreshToken } = response.data
// tokenstore // tokenstore
userStore.user = user
userStore.token = token userStore.token = token
//
localStorage.setItem('X-Access-Token', token) localStorage.setItem('X-Access-Token', token)
localStorage.setItem('token', token) localStorage.setItem('token', token)
localStorage.setItem('refreshToken', refreshToken || '') localStorage.setItem('refreshToken', refreshToken || '')
localStorage.setItem('user', JSON.stringify(user))
// //
if (loginForm.remember) { if (loginForm.remember) {
localStorage.setItem('rememberMe', 'true') localStorage.setItem('rememberMe', 'true')
} }
try {
//
console.log('🔍 登录成功,正在获取用户信息...')
const userInfoResponse = await AuthApi.getUserInfo()
if (userInfoResponse.success && userInfoResponse.result) {
//
const convertedUser = AuthApi.convertBackendUserToUser(userInfoResponse.result)
console.log('🔍 转换后的用户信息:', convertedUser)
console.log('🔍 用户真实姓名:', convertedUser.profile?.realName)
console.log('🔍 用户头像:', convertedUser.avatar)
//
userStore.user = convertedUser
localStorage.setItem('user', JSON.stringify(convertedUser))
console.log('✅ 用户信息获取成功并保存到store:', userStore.user)
} else {
// 使
console.warn('⚠️ 获取用户信息失败,使用登录返回的基本信息')
userStore.user = user
localStorage.setItem('user', JSON.stringify(user))
}
} catch (userInfoError) {
// 使
console.warn('⚠️ 获取用户信息异常,使用登录返回的基本信息:', userInfoError)
userStore.user = user
localStorage.setItem('user', JSON.stringify(user))
}
message.success('登录成功!') message.success('登录成功!')
emit('success') emit('success')
closeModal() closeModal()

View File

@ -101,8 +101,8 @@
<div v-else class="user-menu"> <div v-else class="user-menu">
<n-dropdown :options="userMenuOptions" @select="handleUserMenuSelect"> <n-dropdown :options="userMenuOptions" @select="handleUserMenuSelect">
<div class="user-info"> <div class="user-info">
<SafeAvatar :src="userStore.user?.avatar" :name="userStore.user?.username" :size="32" /> <SafeAvatar :src="userStore.user?.avatar" :name="userStore.user?.profile?.realName || userStore.user?.nickname || userStore.user?.username" :size="32" />
<span class="username">{{ userStore.user?.username }}</span> <span class="username">{{ userStore.user?.profile?.realName || userStore.user?.nickname || userStore.user?.username }}</span>
</div> </div>
</n-dropdown> </n-dropdown>
</div> </div>
@ -120,7 +120,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, h, onMounted, onUnmounted } from 'vue' import { ref, computed, h, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
@ -140,6 +140,20 @@ const router = useRouter()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const userStore = useUserStore() const userStore = useUserStore()
//
console.log('🔍 AppHeader - 当前用户信息:', userStore.user)
console.log('🔍 AppHeader - 用户真实姓名:', userStore.user?.profile?.realName)
console.log('🔍 AppHeader - 用户头像:', userStore.user?.avatar)
//
watch(() => userStore.user, (newUser) => {
console.log('🔄 AppHeader - 用户信息已更新:', newUser)
if (newUser) {
console.log('🔄 AppHeader - 新的真实姓名:', newUser.profile?.realName)
console.log('🔄 AppHeader - 新的头像:', newUser.avatar)
}
}, { deep: true })
// //
const mobileMenuOpen = ref(false) const mobileMenuOpen = ref(false)

View File

@ -58,27 +58,60 @@ export const useUserStore = defineStore('user', () => {
} }
// 获取当前用户信息 // 获取当前用户信息
const getCurrentUser = async () => { const getCurrentUser = async (forceRefresh = false) => {
if (!token.value) { if (!token.value) {
return { success: false, message: '未登录' } return { success: false, message: '未登录' }
} }
// 如果已经有用户信息,直接返回成功 // 如果已经有用户信息且不强制刷新,直接返回成功
if (user.value) { if (user.value && !forceRefresh) {
return { success: true, message: '用户信息已存在' } return { success: true, message: '用户信息已存在' }
} }
// 尝试从localStorage恢复用户信息 // 如果不强制刷新尝试从localStorage恢复用户信息
const savedUser = localStorage.getItem('user') if (!forceRefresh) {
if (savedUser) { const savedUser = localStorage.getItem('user')
try { if (savedUser) {
user.value = JSON.parse(savedUser) try {
return { success: true, message: '用户信息已恢复' } user.value = JSON.parse(savedUser)
} catch (error) { return { success: true, message: '用户信息已恢复' }
console.error('解析用户信息失败:', error) } catch (error) {
console.error('解析用户信息失败:', error)
}
} }
} }
// 尝试从服务器获取用户信息
try {
console.log('🔍 从服务器获取用户信息...')
const userInfoResponse = await AuthApi.getUserInfo()
if (userInfoResponse.success && userInfoResponse.result) {
// 将后端用户信息转换为前端格式
const convertedUser = AuthApi.convertBackendUserToUser(userInfoResponse.result)
// 保存转换后的用户信息
user.value = convertedUser
localStorage.setItem('user', JSON.stringify(convertedUser))
console.log('✅ 从服务器获取用户信息成功:', convertedUser)
return { success: true, message: '用户信息获取成功' }
} else {
console.warn('⚠️ 服务器返回的用户信息格式不正确')
return { success: false, message: '用户信息格式错误' }
}
} catch (error: any) {
console.error('❌ 获取用户信息失败:', error)
// 如果是401错误说明token已过期
if (error.response?.status === 401) {
await logout()
return { success: false, message: '登录已过期,请重新登录' }
}
return { success: false, message: '获取用户信息失败' }
}
// 暂时注释掉API调用因为后端可能没有这个接口 // 暂时注释掉API调用因为后端可能没有这个接口
// isLoading.value = true // isLoading.value = true
// try { // try {
@ -108,6 +141,11 @@ export const useUserStore = defineStore('user', () => {
return { success: false, message: '无法获取用户信息' } return { success: false, message: '无法获取用户信息' }
} }
// 强制刷新用户信息
const refreshUserInfo = async () => {
return await getCurrentUser(true)
}
const updateProfile = async (profileData: any) => { const updateProfile = async (profileData: any) => {
isLoading.value = true isLoading.value = true
try { try {
@ -143,8 +181,8 @@ export const useUserStore = defineStore('user', () => {
user.value = JSON.parse(savedUser) user.value = JSON.parse(savedUser)
token.value = savedToken token.value = savedToken
// 验证token是否仍然有效 // 验证token是否仍然有效,并强制刷新用户信息
await getCurrentUser() await getCurrentUser(true)
} catch (error) { } catch (error) {
console.error('Failed to parse saved user data or token expired:', error) console.error('Failed to parse saved user data or token expired:', error)
await logout() await logout()
@ -169,6 +207,7 @@ export const useUserStore = defineStore('user', () => {
register, register,
logout, logout,
getCurrentUser, getCurrentUser,
refreshUserInfo,
updateProfile, updateProfile,
initializeAuth initializeAuth
} }

View File

@ -170,7 +170,7 @@
<button class="tab-btn" :class="{ active: activeTab === 'intro' }" <button class="tab-btn" :class="{ active: activeTab === 'intro' }"
@click="activeTab = 'intro'">课程介绍</button> @click="activeTab = 'intro'">课程介绍</button>
<button class="tab-btn" :class="{ active: activeTab === 'comments' }" <button class="tab-btn" :class="{ active: activeTab === 'comments' }"
@click="activeTab = 'comments'">评论(1251)</button> @click="activeTab = 'comments'">评论({{ comments.length }})</button>
</div> </div>
<!-- 标签页内容区域 --> <!-- 标签页内容区域 -->
@ -195,25 +195,58 @@
</div> --> </div> -->
<div class="comment-list"> <div class="comment-list">
<div class="comment-item" v-for="comment in displayComments" :key="comment.id"> <!-- 加载状态 -->
<div class="comment-avatar"> <div v-if="commentsLoading" class="comments-loading">
<img :src="comment.avatar" :alt="comment.username" /> <p>正在加载评论...</p>
</div> </div>
<div class="comment-content">
<div class="comment-header"> <!-- 错误状态 -->
<span class="comment-username">{{ comment.username }}</span> <div v-else-if="commentsError" class="comments-error">
<!-- <span class="comment-time">{{ comment.time }}</span> --> <p>{{ commentsError }}</p>
<button @click="loadCourseComments" class="retry-btn">重试</button>
</div>
<!-- 评论列表 -->
<div v-else-if="comments.length > 0">
<div class="comment-item" v-for="comment in comments" :key="comment.id">
<div class="comment-avatar">
<SafeAvatar :src="comment.userAvatar" :name="comment.userName" :size="40" />
</div> </div>
<div class="comment-text">{{ comment.content }}</div> <div class="comment-content">
<div class="comment-actions"> <div class="comment-header">
<button class="action-btn"> <span class="comment-username">{{ comment.userName }}</span>
<span class="top">置顶评论</span> <span v-if="comment.userTag" class="comment-tag">{{ comment.userTag }}</span>
<span>2025.07.23 16:28</span> <span class="comment-time">{{ comment.timeAgo }}</span>
</button> </div>
<button class="action-btn">回复</button> <div class="comment-text">{{ comment.content }}</div>
<!-- 评论图片 -->
<div v-if="comment.images.length > 0" class="comment-images">
<img v-for="(image, index) in comment.images" :key="index"
:src="image" :alt="`评论图片${index + 1}`" />
</div>
<div class="comment-actions">
<button v-if="comment.isTop" class="action-btn top-comment">
<span class="top">置顶评论</span>
<span>{{ comment.createTime }}</span>
</button>
<button class="action-btn like-btn">
<svg width="14" height="14" viewBox="0 0 14 14" class="like-icon">
<path d="M7 12.5L6.125 11.75C3.5 9.375 1.75 7.75 1.75 5.75C1.75 4.25 2.875 3.125 4.375 3.125C5.25 3.125 6.125 3.5 7 4.25C7.875 3.5 8.75 3.125 9.625 3.125C11.125 3.125 12.25 4.25 12.25 5.75C12.25 7.75 10.5 9.375 7.875 11.75L7 12.5Z" fill="currentColor"/>
</svg>
{{ comment.likeCount }}
</button>
<button class="action-btn">回复</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- 无评论状态 -->
<div v-else class="no-comments">
<p>暂无评论快来发表第一条评论吧</p>
</div>
</div> </div>
<!-- <div class="load-more"> <!-- <div class="load-more">
@ -463,7 +496,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { CourseApi } from '@/api/modules/course' import { CourseApi } from '@/api/modules/course'
import type { Course, CourseSection } from '@/api/types' import type { Course, CourseSection, CourseComment } from '@/api/types'
import SafeAvatar from '@/components/common/SafeAvatar.vue' import SafeAvatar from '@/components/common/SafeAvatar.vue'
import LoginModal from '@/components/auth/LoginModal.vue' import LoginModal from '@/components/auth/LoginModal.vue'
import RegisterModal from '@/components/auth/RegisterModal.vue' import RegisterModal from '@/components/auth/RegisterModal.vue'
@ -492,6 +525,11 @@ const sectionsError = ref('')
const instructorsLoading = ref(false) const instructorsLoading = ref(false)
const instructorsError = ref('') const instructorsError = ref('')
//
const comments = ref<CourseComment[]>([])
const commentsLoading = ref(false)
const commentsError = ref('')
// //
const isEnrolled = ref(false) // const isEnrolled = ref(false) //
const enrollmentLoading = ref(false) // const enrollmentLoading = ref(false) //
@ -662,32 +700,8 @@ const formatTotalDuration = () => {
return `${hours}小时${minutes}分钟` return `${hours}小时${minutes}分钟`
} }
const displayComments = ref([ // displayComments 使API (comments)
{ // const displayComments = ref([]) // comments
id: 1,
username: '学习者小王',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&auto=format&fit=crop&w=50&q=80',
time: '2天前',
content: '老师讲得很详细,从零基础到实际应用都有涉及,非常适合初学者!',
likes: 23
},
{
id: 2,
username: 'AI爱好者',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&auto=format&fit=crop&w=50&q=80',
time: '5天前',
content: '课程内容很实用跟着做了几个项目收获很大。推荐给想学AI的朋友们',
likes: 18
},
{
id: 3,
username: '程序员小李',
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?ixlib=rb-4.0.3&auto=format&fit=crop&w=50&q=80',
time: '1周前',
content: 'DeepSeek确实是个很强大的工具通过这个课程学会了很多实用技巧。',
likes: 31
}
])
// //
const loadCourseDetail = async () => { const loadCourseDetail = async () => {
@ -828,6 +842,54 @@ const loadCourseInstructors = async () => {
} }
} }
//
const loadCourseComments = async () => {
if (!courseId.value || courseId.value.trim() === '') {
commentsError.value = '课程ID无效'
console.error('课程ID无效:', courseId.value)
return
}
try {
commentsLoading.value = true
commentsError.value = ''
console.log('调用API获取课程评论...')
const response = await CourseApi.getCourseComments(courseId.value)
console.log('评论API响应:', response)
if (response.code === 0 || response.code === 200) {
if (response.data && Array.isArray(response.data)) {
//
const sortedComments = response.data.sort((a, b) => {
//
if (a.isTop !== b.isTop) {
return a.isTop ? -1 : 1 //
}
//
return new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
})
comments.value = sortedComments
console.log('✅ 评论数据设置成功:', comments.value)
} else {
console.log('⚠️ API返回的评论数据为空')
comments.value = []
}
} else {
console.log('⚠️ API返回错误')
commentsError.value = response.message || '获取评论失败'
comments.value = []
}
} catch (err) {
console.error('加载课程评论失败:', err)
commentsError.value = '获取评论失败'
comments.value = []
} finally {
commentsLoading.value = false
}
}
// / // /
const toggleChapter = (chapterIndex: number) => { const toggleChapter = (chapterIndex: number) => {
console.log('点击切换章节,章节索引:', chapterIndex) console.log('点击切换章节,章节索引:', chapterIndex)
@ -1143,6 +1205,7 @@ onMounted(() => {
loadCourseDetail() loadCourseDetail()
loadCourseSections() // loadCourseSections() //
loadCourseInstructors() // loadCourseInstructors() //
loadCourseComments() //
}) })
</script> </script>
@ -3289,4 +3352,68 @@ onMounted(() => {
gap: 8px; gap: 8px;
} }
} }
/* 评论加载和错误状态 */
.comments-loading, .comments-error, .no-comments {
padding: 40px 20px;
text-align: center;
color: #666;
}
.comments-error {
color: #ff4d4f;
}
.comments-error .retry-btn {
margin-top: 10px;
padding: 8px 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.comments-error .retry-btn:hover {
background: #40a9ff;
}
/* 评论用户标签 */
.comment-tag {
display: inline-block;
padding: 2px 6px;
background: #f0f0f0;
color: #666;
font-size: 10px;
border-radius: 2px;
margin-left: 8px;
}
/* 置顶评论样式 */
.top-comment {
background: #fff7e6 !important;
color: #fa8c16 !important;
border: 1px solid #ffd591 !important;
}
.top-comment .top {
font-weight: 500;
}
/* 点赞按钮 */
.like-btn {
display: flex;
align-items: center;
gap: 4px;
}
.like-icon {
color: #999;
transition: color 0.2s;
}
.like-btn:hover .like-icon {
color: #ff4d4f;
}
</style> </style>

View File

@ -148,7 +148,15 @@
<!-- 讲师信息 --> <!-- 讲师信息 -->
<div class="instructors-section"> <div class="instructors-section">
<h3 class="section-title">讲师</h3> <h3 class="section-title">讲师</h3>
<div class="instructors-list">
<div v-if="instructorsLoading" class="instructors-loading">
<p>正在加载讲师信息...</p>
</div>
<div v-else-if="instructorsError" class="instructors-error">
<p>{{ instructorsError }}</p>
<button @click="loadCourseInstructors" class="retry-btn">重试</button>
</div>
<div v-else class="instructors-list">
<div class="instructor-item" v-for="instructor in instructors" :key="instructor.id"> <div class="instructor-item" v-for="instructor in instructors" :key="instructor.id">
<div class="instructor-avatar"> <div class="instructor-avatar">
<SafeAvatar :src="instructor.avatar" :name="instructor.name" :size="50" /> <SafeAvatar :src="instructor.avatar" :name="instructor.name" :size="50" />
@ -170,7 +178,7 @@
<button class="tab-btn" :class="{ active: activeTab === 'intro' }" <button class="tab-btn" :class="{ active: activeTab === 'intro' }"
@click="activeTab = 'intro'">课程介绍</button> @click="activeTab = 'intro'">课程介绍</button>
<button class="tab-btn" :class="{ active: activeTab === 'comments' }" <button class="tab-btn" :class="{ active: activeTab === 'comments' }"
@click="activeTab = 'comments'">评论(1251)</button> @click="activeTab = 'comments'">评论({{ comments.length }})</button>
</div> </div>
<!-- 标签页内容区域 --> <!-- 标签页内容区域 -->
@ -195,25 +203,58 @@
</div> --> </div> -->
<div class="comment-list"> <div class="comment-list">
<div class="comment-item" v-for="comment in displayComments" :key="comment.id"> <!-- 加载状态 -->
<div class="comment-avatar"> <div v-if="commentsLoading" class="comments-loading">
<img :src="comment.avatar" :alt="comment.username" /> <p>正在加载评论...</p>
</div> </div>
<div class="comment-content">
<div class="comment-header"> <!-- 错误状态 -->
<span class="comment-username">{{ comment.username }}</span> <div v-else-if="commentsError" class="comments-error">
<span class="comment-time">{{ comment.time }}</span> <p>{{ commentsError }}</p>
<button @click="loadCourseComments" class="retry-btn">重试</button>
</div>
<!-- 评论列表 -->
<div v-else-if="comments.length > 0">
<div class="comment-item" v-for="comment in comments" :key="comment.id">
<div class="comment-avatar">
<SafeAvatar :src="comment.userAvatar" :name="comment.userName" :size="40" />
</div> </div>
<div class="comment-text">{{ comment.content }}</div> <div class="comment-content">
<div class="comment-actions"> <div class="comment-header">
<button class="action-btn"> <span class="comment-username">{{ comment.userName }}</span>
<span class="top">置顶评论</span> <span v-if="comment.userTag" class="comment-tag">{{ comment.userTag }}</span>
<span>2025.07.23 16:28</span> <span class="comment-time">{{ comment.timeAgo }}</span>
</button> </div>
<button class="action-btn">回复</button> <div class="comment-text">{{ comment.content }}</div>
<!-- 评论图片 -->
<div v-if="comment.images.length > 0" class="comment-images">
<img v-for="(image, index) in comment.images" :key="index"
:src="image" :alt="`评论图片${index + 1}`" />
</div>
<div class="comment-actions">
<button v-if="comment.isTop" class="action-btn top-comment">
<span class="top">置顶评论</span>
<span>{{ comment.createTime }}</span>
</button>
<button class="action-btn like-btn">
<svg width="14" height="14" viewBox="0 0 14 14" class="like-icon">
<path d="M7 12.5L6.125 11.75C3.5 9.375 1.75 7.75 1.75 5.75C1.75 4.25 2.875 3.125 4.375 3.125C5.25 3.125 6.125 3.5 7 4.25C7.875 3.5 8.75 3.125 9.625 3.125C11.125 3.125 12.25 4.25 12.25 5.75C12.25 7.75 10.5 9.375 7.875 11.75L7 12.5Z" fill="currentColor"/>
</svg>
{{ comment.likeCount }}
</button>
<button class="action-btn">回复</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- 无评论状态 -->
<div v-else class="no-comments">
<p>暂无评论快来发表第一条评论吧</p>
</div>
</div> </div>
<!-- <div class="load-more"> <!-- <div class="load-more">
@ -400,7 +441,7 @@ import { ref, onMounted, onUnmounted, computed, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { CourseApi } from '@/api/modules/course' import { CourseApi } from '@/api/modules/course'
import type { Course, CourseSection, SectionVideo, VideoQuality } from '@/api/types' import type { Course, CourseSection, SectionVideo, VideoQuality, CourseComment, Instructor } from '@/api/types'
import SafeAvatar from '@/components/common/SafeAvatar.vue' import SafeAvatar from '@/components/common/SafeAvatar.vue'
import LearningProgressStats from '@/components/common/LearningProgressStats.vue' import LearningProgressStats from '@/components/common/LearningProgressStats.vue'
import NotesModal from '@/components/common/NotesModal.vue' import NotesModal from '@/components/common/NotesModal.vue'
@ -435,6 +476,16 @@ const currentQuality = ref<string>('360') // 默认360p
const videoLoading = ref(false) const videoLoading = ref(false)
const showQualityMenu = ref(false) const showQualityMenu = ref(false)
//
const comments = ref<CourseComment[]>([])
const commentsLoading = ref(false)
const commentsError = ref('')
//
const instructors = ref<Instructor[]>([])
const instructorsLoading = ref(false)
const instructorsError = ref('')
// //
const VIDEO_CONFIG = { const VIDEO_CONFIG = {
// 使 // 使
@ -487,27 +538,7 @@ const activeTab = ref('intro')
// //
const showNotesModal = ref(false) const showNotesModal = ref(false)
// // 使API (instructors)
const instructors = ref([
{
id: 1,
name: '汪波',
title: '教授',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&auto=format&fit=crop&w=60&q=80'
},
{
id: 2,
name: '汪波',
title: '教授',
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?ixlib=rb-4.0.3&auto=format&fit=crop&w=60&q=80'
},
{
id: 3,
name: '汪波',
title: '教授',
avatar: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?ixlib=rb-4.0.3&auto=format&fit=crop&w=60&q=80'
}
])
// //
const totalLessons = computed(() => { const totalLessons = computed(() => {
@ -537,32 +568,8 @@ const formatTotalDuration = () => {
return `${hours}小时${minutes}分钟` return `${hours}小时${minutes}分钟`
} }
const displayComments = ref([ // displayComments 使API (comments)
{ // const displayComments = ref([]) // comments
id: 1,
username: '学习者小王',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&auto=format&fit=crop&w=50&q=80',
time: '2天前',
content: '老师讲得很详细,从零基础到实际应用都有涉及,非常适合初学者!',
likes: 23
},
{
id: 2,
username: 'AI爱好者',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&auto=format&fit=crop&w=50&q=80',
time: '5天前',
content: '课程内容很实用跟着做了几个项目收获很大。推荐给想学AI的朋友们',
likes: 18
},
{
id: 3,
username: '程序员小李',
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?ixlib=rb-4.0.3&auto=format&fit=crop&w=50&q=80',
time: '1周前',
content: 'DeepSeek确实是个很强大的工具通过这个课程学会了很多实用技巧。',
likes: 31
}
])
// //
const generateMockSections = (): CourseSection[] => { const generateMockSections = (): CourseSection[] => {
@ -721,6 +728,91 @@ const loadMockData = () => {
} }
} }
//
const loadCourseComments = async () => {
if (!courseId.value || courseId.value.trim() === '') {
commentsError.value = '课程ID无效'
console.error('课程ID无效:', courseId.value)
return
}
try {
commentsLoading.value = true
commentsError.value = ''
console.log('调用API获取课程评论...')
const response = await CourseApi.getCourseComments(courseId.value)
console.log('评论API响应:', response)
if (response.code === 0 || response.code === 200) {
if (response.data && Array.isArray(response.data)) {
//
const sortedComments = response.data.sort((a, b) => {
//
if (a.isTop !== b.isTop) {
return a.isTop ? -1 : 1 //
}
//
return new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
})
comments.value = sortedComments
console.log('✅ 评论数据设置成功:', comments.value)
} else {
console.log('⚠️ API返回的评论数据为空')
comments.value = []
}
} else {
console.log('⚠️ API返回错误')
commentsError.value = response.message || '获取评论失败'
comments.value = []
}
} catch (err) {
console.error('加载课程评论失败:', err)
commentsError.value = '获取评论失败'
comments.value = []
} finally {
commentsLoading.value = false
}
}
//
const loadCourseInstructors = async () => {
if (!courseId.value || courseId.value.trim() === '') {
instructorsError.value = '课程ID无效'
console.error('课程ID无效:', courseId.value)
return
}
try {
instructorsLoading.value = true
instructorsError.value = ''
console.log('调用API获取课程讲师...')
const response = await CourseApi.getCourseInstructors(courseId.value)
console.log('讲师API响应:', response)
if (response.code === 0 || response.code === 200) {
if (response.data && Array.isArray(response.data)) {
instructors.value = response.data
console.log('✅ 讲师数据设置成功:', instructors.value)
} else {
console.log('⚠️ API返回的讲师数据为空使用默认数据')
// mock
}
} else {
console.log('⚠️ API返回错误使用默认数据')
instructorsError.value = response.message || '获取讲师信息失败'
}
} catch (err) {
console.error('加载课程讲师失败:', err)
instructorsError.value = '获取讲师信息失败'
// mock
} finally {
instructorsLoading.value = false
}
}
// / // /
const toggleChapter = (chapterIndex: number) => { const toggleChapter = (chapterIndex: number) => {
console.log('切换章节展开/收起:', chapterIndex) console.log('切换章节展开/收起:', chapterIndex)
@ -1158,6 +1250,8 @@ onMounted(async () => {
} }
loadCourseDetail() loadCourseDetail()
loadCourseSections() loadCourseSections()
loadCourseComments() //
loadCourseInstructors() //
}) })
// CKPlayer // CKPlayer
@ -1555,6 +1649,34 @@ onUnmounted(() => {
color: #999; color: #999;
} }
/* 讲师加载状态 */
.instructors-loading {
padding: 20px;
text-align: center;
color: #666;
}
.instructors-error {
padding: 20px;
text-align: center;
color: #ff4d4f;
}
.instructors-error .retry-btn {
margin-top: 10px;
padding: 6px 12px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.instructors-error .retry-btn:hover {
background: #40a9ff;
}
/* 分隔线样式 */ /* 分隔线样式 */
.course-info-divider { .course-info-divider {
height: 1px; height: 1px;
@ -2905,4 +3027,68 @@ onUnmounted(() => {
font-size: 14px; font-size: 14px;
} }
} }
/* 评论加载和错误状态 */
.comments-loading, .comments-error, .no-comments {
padding: 40px 20px;
text-align: center;
color: #666;
}
.comments-error {
color: #ff4d4f;
}
.comments-error .retry-btn {
margin-top: 10px;
padding: 8px 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.comments-error .retry-btn:hover {
background: #40a9ff;
}
/* 评论用户标签 */
.comment-tag {
display: inline-block;
padding: 2px 6px;
background: #f0f0f0;
color: #666;
font-size: 10px;
border-radius: 2px;
margin-left: 8px;
}
/* 置顶评论样式 */
.top-comment {
background: #fff7e6 !important;
color: #fa8c16 !important;
border: 1px solid #ffd591 !important;
}
.top-comment .top {
font-weight: 500;
}
/* 点赞按钮 */
.like-btn {
display: flex;
align-items: center;
gap: 4px;
}
.like-icon {
color: #999;
transition: color 0.2s;
}
.like-btn:hover .like-icon {
color: #ff4d4f;
}
</style> </style>

View File

@ -121,7 +121,7 @@
</div> </div>
<div class="course-footer"> <div class="course-footer">
<div class="course-stats"> <div class="course-stats">
<span class="course-students">讲师: 刘莹</span> <span class="course-students">讲师: {{ getCourseInstructors(course) }}</span>
</div> </div>
<button class="enroll-btn" @click="goToCourseDetail(course)">去学习</button> <button class="enroll-btn" @click="goToCourseDetail(course)">去学习</button>
</div> </div>
@ -389,6 +389,39 @@ const getCourseTitle = (course: Course) => {
return course.title return course.title
} }
//
const getCourseInstructors = (course: Course) => {
// teacherList
if (course.teacherList && Array.isArray(course.teacherList) && course.teacherList.length > 0) {
// sortOrdersortOrder
const sortedTeachers = [...course.teacherList].sort((a, b) => {
const sortOrderA = a.sortOrder || 0
const sortOrderB = b.sortOrder || 0
return sortOrderB - sortOrderA //
})
//
const teacherNames = sortedTeachers.map(teacher => teacher.name).join('、')
console.log('🔍 课程讲师信息:', {
courseTitle: course.title,
originalTeachers: course.teacherList,
sortedTeachers: sortedTeachers,
teacherNames: teacherNames
})
return teacherNames
}
// teacherListinstructor
if (course.instructor && course.instructor.name) {
return course.instructor.name
}
//
return '暂无讲师信息'
}
// //
const goToCourseDetail = (course: Course) => { const goToCourseDetail = (course: Course) => {
router.push({ router.push({

View File

@ -187,20 +187,48 @@ const handleSubmit = async () => {
if (response.code === 200) { if (response.code === 200) {
const { user, token, refreshToken } = response.data const { user, token, refreshToken } = response.data
// tokenstore // tokenstore
userStore.user = user
userStore.token = token userStore.token = token
localStorage.setItem('X-Access-Token', token)
//
localStorage.setItem('token', token) localStorage.setItem('token', token)
localStorage.setItem('refreshToken', refreshToken) localStorage.setItem('refreshToken', refreshToken)
localStorage.setItem('user', JSON.stringify(user))
// //
if (rememberMe.value) { if (rememberMe.value) {
localStorage.setItem('rememberMe', 'true') localStorage.setItem('rememberMe', 'true')
} }
try {
//
console.log('🔍 登录成功,正在获取用户信息...')
const userInfoResponse = await AuthApi.getUserInfo()
if (userInfoResponse.success && userInfoResponse.result) {
//
const convertedUser = AuthApi.convertBackendUserToUser(userInfoResponse.result)
console.log('🔍 转换后的用户信息:', convertedUser)
console.log('🔍 用户真实姓名:', convertedUser.profile?.realName)
console.log('🔍 用户头像:', convertedUser.avatar)
//
userStore.user = convertedUser
localStorage.setItem('user', JSON.stringify(convertedUser))
console.log('✅ 用户信息获取成功并保存到store:', userStore.user)
} else {
// 使
console.warn('⚠️ 获取用户信息失败,使用登录返回的基本信息')
userStore.user = user
localStorage.setItem('user', JSON.stringify(user))
}
} catch (userInfoError) {
// 使
console.warn('⚠️ 获取用户信息异常,使用登录返回的基本信息:', userInfoError)
userStore.user = user
localStorage.setItem('user', JSON.stringify(user))
}
message.success('登录成功!') message.success('登录成功!')
// //

View File

@ -5,9 +5,9 @@
<!-- 左侧侧边栏 --> <!-- 左侧侧边栏 -->
<div class="block_14"> <div class="block_14">
<!-- 用户头像和姓名 --> <!-- 用户头像和姓名 -->
<SafeAvatar class="image_7" :src="userStore.user?.avatar" :name="userStore.user?.username || '用户'" :size="96" <SafeAvatar class="image_7" :src="userStore.user?.avatar" :name="userStore.user?.profile?.realName || userStore.user?.nickname || userStore.user?.username || '用户'" :size="96"
alt="用户头像" /> alt="用户头像" />
<span class="text_72">{{ userStore.user?.username || '用户名' }}</span> <span class="text_72">{{ userStore.user?.profile?.realName || userStore.user?.nickname || userStore.user?.username || '用户名' }}</span>
<!-- 菜单项容器 --> <!-- 菜单项容器 -->
<div class="box_22"> <div class="box_22">
@ -847,7 +847,7 @@
<div class="avatar-section"> <div class="avatar-section">
<div class="avatar-container"> <div class="avatar-container">
<SafeAvatar :src="userStore.user?.avatar" <SafeAvatar :src="userStore.user?.avatar"
:name="userStore.user?.username || userInfo.nickname || '用户'" :size="68" alt="用户头像" :name="userStore.user?.profile?.realName || userStore.user?.nickname || userStore.user?.username || '用户'" :size="68" alt="用户头像"
class="user-avatar-large" /> class="user-avatar-large" />
<div class="avatar-edit-btn"> <div class="avatar-edit-btn">
<img <img