feat: 课程详细页面:课程介绍,课程介绍,教学团队,章节目录,评论接入接口; 课程内容,字幕列表接入接口

This commit is contained in:
QDKF 2025-09-24 20:49:26 +08:00
parent 6b3846ea50
commit 82ae528785
13 changed files with 1672 additions and 455 deletions

View File

@ -277,6 +277,15 @@ export const ChatApi = {
return ApiRequest.post(`/aiol/aiolChat/${chatId}/update_last_read/${messageId}`)
},
/**
* 退
* DELETE /aiol/aiolChat/{chatId}/exit
* 退
*/
exitChat: (chatId: string): Promise<ApiResponse<any>> => {
return ApiRequest.delete(`/aiol/aiolChat/${chatId}/exit`)
},
/**
*
* POST /sys/common/upload

View File

@ -11,13 +11,28 @@ import type {
*/
export class CommentApi {
// 获取课程评论
static getCourseComments(courseId: number, params?: {
static async getCourseComments(courseId: number, params?: {
page?: number
pageSize?: number
sortBy?: 'newest' | 'oldest' | 'rating' | 'helpful'
rating?: number
}): Promise<ApiResponse<PaginationResponse<Comment>>> {
return ApiRequest.get(`/courses/${courseId}/comments`, params)
}): Promise<ApiResponse<any>> {
try {
console.log('🚀 获取课程评论:', { courseId, params })
// 使用正确的API路径 - 根据用户提供的接口
const response = await ApiRequest.get(`/aiol/aiolComment/course/${courseId}/list`, {
pageNo: params?.page || 1,
pageSize: params?.pageSize || 20,
...params
})
console.log('✅ 获取课程评论成功:', response)
return response
} catch (error) {
console.error('❌ 获取课程评论失败:', error)
throw error
}
}
// 获取课时评论
@ -48,12 +63,54 @@ export class CommentApi {
}
// 添加课程评论
static addCourseComment(courseId: number, data: {
static async addCourseComment(courseId: number, data: {
content: string
rating?: number
parentId?: number
}): Promise<ApiResponse<Comment>> {
return ApiRequest.post(`/courses/${courseId}/comments`, data)
}): Promise<ApiResponse<any>> {
try {
console.log('🚀 添加课程评论:', { courseId, data })
// 使用正确的API路径
const response = await ApiRequest.post(`/aiol/aiolComment/course/${courseId}/add`, {
content: data.content,
imgs: '',
parentId: data.parentId
})
console.log('✅ 添加课程评论成功:', response)
return response
} catch (error) {
console.error('❌ 添加课程评论失败:', error)
throw error
}
}
// 回复评论 - 使用专门的回复接口
static async replyComment(data: {
content: string
targetType: 'course' | 'lesson' | 'comment'
targetId: string
parentId?: number
}): Promise<ApiResponse<any>> {
try {
console.log('🚀 回复评论:', data)
// 使用专门的回复接口
const response = await ApiRequest.post(`/aiol/aiolComment/add`, {
content: data.content,
targetType: data.targetType,
targetId: data.targetId,
parentId: data.parentId,
imgs: ''
})
console.log('✅ 回复评论成功:', response)
return response
} catch (error) {
console.error('❌ 回复评论失败:', error)
throw error
}
}
// 添加课时评论
@ -78,11 +135,16 @@ export class CommentApi {
}
// 点赞评论
static likeComment(commentId: number): Promise<ApiResponse<{
likes: number
isLiked: boolean
}>> {
return ApiRequest.post(`/comments/${commentId}/like`)
static async likeComment(commentId: string | number): Promise<ApiResponse<any>> {
try {
console.log('🚀 点赞评论:', commentId)
const response = await ApiRequest.get(`/aiol/aiolComment/like/${commentId}`)
console.log('✅ 点赞评论成功:', response)
return response
} catch (error) {
console.error('❌ 点赞评论失败:', error)
throw error
}
}
// 取消点赞评论
@ -211,6 +273,6 @@ export class CommentApi {
}>>> {
return ApiRequest.get('/comments/reported', params)
}
}
}export default CommentApi
export default CommentApi

View File

@ -157,7 +157,9 @@ export class CourseApi {
question: item.question || '',
video: item.video || '',
// 添加AI伴学模式字段
izAi: item.izAi
izAi: item.izAi,
// 添加学期字段
semester: item.semester || ''
}))
return {
@ -217,6 +219,8 @@ export class CourseApi {
}
// 转换后端数据格式为前端格式
const item: BackendCourseItem = response.data.result
console.log('🔍 后端原始课程数据:', item)
console.log('📅 后端学期字段:', item.semester)
const course: Course = {
id: item.id, // 保持字符串格式,不转换为数字
title: item.name || '',
@ -259,7 +263,9 @@ export class CourseApi {
createdAt: this.formatTimestamp(item.createTime),
updatedAt: this.formatTimestamp(item.updateTime),
// 添加AI伴学模式字段
izAi: item.izAi
izAi: item.izAi,
// 添加学期字段
semester: item.semester || ''
} as any
return {
@ -403,6 +409,8 @@ export class CourseApi {
}
// 转换后端数据格式为前端格式
const item: BackendCourseItem = response.data.result
console.log('🔍 后端原始课程数据:', item)
console.log('📅 后端学期字段:', item.semester)
const course: Course = {
id: item.id, // 保持字符串格式,不转换为数字
title: item.name || '',
@ -445,7 +453,9 @@ export class CourseApi {
createdAt: this.formatTimestamp(item.createTime),
updatedAt: this.formatTimestamp(item.updateTime),
// 添加AI伴学模式字段
izAi: item.izAi
izAi: item.izAi,
// 添加学期字段
semester: item.semester || ''
} as any
return {

View File

@ -165,6 +165,7 @@ export interface Course {
createdAt: string
updatedAt: string
publishedAt?: string
semester?: string // 新增学期字段
}
// 后端实际返回的课程数据格式
@ -333,6 +334,7 @@ export interface BackendCourseItem {
updateBy: string
updateTime: string
teacherList: BackendInstructor[] // 新增讲师列表字段
semester?: string // 新增学期字段
}
// 后端课程列表响应格式

View File

@ -2,43 +2,40 @@
<div class="course-content-management">
<!-- 工具栏 -->
<div class="toolbar">
<n-select
v-model:value="selectedCourse"
:options="courseOptions"
placeholder="请选择课程"
style="width: 200px"
@update:value="handleCourseChange"
/>
<n-space>
<n-button type="primary" @click="showAddSummaryModal = true">
<n-select v-model:value="selectedSection" :options="sectionOptions" placeholder="请选择视频章节" style="width: 250px"
@update:value="handleSectionChange" />
</n-space>
<n-space>
<n-button type="primary" @click="showAddSummaryModal = true" :disabled="!selectedSection">
添加总结
</n-button>
<n-input v-model:value="searchKeyword" placeholder="请输入关键词" style="width: 200px" />
<n-button type="primary" @click="handleSearch">
<n-input v-model:value="searchKeyword" placeholder="请输入关键词" style="width: 200px" @keyup.enter="handleSearch" />
<n-button type="primary" @click="handleSearch" :loading="searchLoading">
搜索
</n-button>
<n-button @click="handleClearSearch" v-if="searchKeyword">
清空
</n-button>
<n-button @click="loadData" :loading="loading">
刷新
</n-button>
</n-space>
</div>
<!-- 内容管理区域 -->
<div class="content-area">
<div class="summary-section">
<n-data-table
:columns="summaryColumns"
:data="filteredSummaryList"
:pagination="pagination"
:loading="loading"
:row-key="(row: any) => row.timestamp + row.title"
striped
size="small"
/>
<n-data-table :columns="summaryColumns" :data="filteredSummaryList" :pagination="pagination"
:loading="loading || searchLoading" :row-key="(row) => row.timestamp + row.title" striped size="small" />
</div>
</div>
<!-- 添加总结弹窗 -->
<n-modal v-model:show="showAddSummaryModal" title="添加课程总结">
<n-card style="width: 600px" title="添加课程总结" :bordered="false" size="huge">
<n-form ref="summaryFormRef" :model="summaryForm" :rules="summaryRules" label-placement="left" label-width="auto">
<n-form ref="summaryFormRef" :model="summaryForm" :rules="summaryRules" label-placement="left"
label-width="auto">
<n-form-item label="时间戳" path="timestamp">
<n-input v-model:value="summaryForm.timestamp" placeholder="例如: 00:23" />
</n-form-item>
@ -46,12 +43,7 @@
<n-input v-model:value="summaryForm.title" placeholder="请输入总结标题" />
</n-form-item>
<n-form-item label="内容" path="description">
<n-input
v-model:value="summaryForm.description"
type="textarea"
placeholder="请输入总结内容"
:rows="4"
/>
<n-input v-model:value="summaryForm.description" type="textarea" placeholder="请输入总结内容" :rows="4" />
</n-form-item>
</n-form>
<template #footer>
@ -83,14 +75,22 @@ import {
type FormRules,
type DataTableColumns
} from 'naive-ui'
import { ApiRequest } from '@/api/request'
const message = useMessage()
// Props
const props = defineProps<{
courseId: string
}>()
//
const selectedCourse = ref('')
const selectedSection = ref('')
const showAddSummaryModal = ref(false)
const searchKeyword = ref('')
const loading = ref(false)
const searchLoading = ref(false)
const sectionOptions = ref<Array<{ label: string; value: string }>>([])
//
const summaryFormRef = ref<FormInst | null>(null)
@ -112,26 +112,14 @@ const pagination = ref({
}
})
//
const courseOptions = ref([
{ label: '职业探索与选择', value: 'course1' },
{ label: '软件工程导论', value: 'course2' },
{ label: '数据结构与算法', value: 'course3' }
])
//
const summaryList = ref([
{
timestamp: '00:23',
title: '职业探索与选择:追求卓越与实现自我价值',
description: '本次课程旨在引导学生探索自身的职业目标及价值观,强调了根据个人兴趣和优势做出职业选择的重要性。通过分享不同领域的职场榜样,如一位对C罗的远见和执行力表示赞赏的学生、另一位崇拜Elon Musk的学习能力和创新精神、以及第三位钦佩俞敏洪的社会洞察力和团队合作精神,课程鼓励学生思考并追求自己理想中的职业生涯。这些榜样人物不仅在各自领域取得了巨大成功,而且还展现了持续学习、勇于创新和积极影响社会的价值观。'
},
{
timestamp: '00:45',
title: '职业探索与选择:追求卓越与实现自我价值',
description: '本次课程旨在引导学生探索自身的职业目标及价值观,强调了根据个人兴趣和优势做出职业选择的重要性。通过分享不同领域的职场榜样,如一位对C罗的远见和执行力表示赞赏的学生、另一位崇拜Elon Musk的学习能力和创新精神、以及第三位钦佩俞敏洪的社会洞察力和团队合作精神,课程鼓励学生思考并追求自己理想中的职业生涯。这些榜样人物不仅在各自领域取得了巨大成功,而且还展现了持续学习、勇于创新和积极影响社会的价值观。'
}
])
const summaryList = ref<Array<{
id: string
timestamp: string
title: string
description: string
type?: string
}>>([])
//
@ -164,7 +152,7 @@ const summaryColumns: DataTableColumns<any> = [
render: (row: any) => {
return h('span', {
style: {
background: '#1890ff',
background: '#0C99DA',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
@ -186,6 +174,23 @@ const summaryColumns: DataTableColumns<any> = [
title: '内容',
key: 'description'
},
{
title: '类型',
key: 'type',
width: 100,
align: 'center',
render: (row: any) => {
return h('span', {
style: {
background: '#52c41a',
color: 'white',
padding: '2px 6px',
borderRadius: '3px',
fontSize: '11px'
}
}, row.type || '总结')
}
},
{
title: '操作',
key: 'actions',
@ -227,32 +232,150 @@ const filteredSummaryList = computed(() => {
})
//
const handleCourseChange = (courseId: string) => {
console.log('切换课程:', courseId)
const handleSectionChange = (sectionId: string) => {
console.log('切换章节:', sectionId)
loadData()
}
const loadData = async () => {
loading.value = true
const loadCourseSections = async () => {
try {
// API
await new Promise(resolve => setTimeout(resolve, 500))
//
loading.value = true
console.log('📚 加载课程章节列表:', props.courseId)
const response = await ApiRequest.get(`/aiol/aiolCourse/${props.courseId}/section`)
console.log('✅ 章节列表响应:', response.data)
console.log('📊 原始章节数据:', response.data?.result)
if (response.data && response.data.result) {
const videoSections = response.data.result.filter((section: any) => section.type === 0)
console.log('🎬 筛选出的视频章节:', videoSections)
sectionOptions.value = videoSections.map((section: any) => ({
label: section.name,
value: section.id
}))
console.log('✅ 视频章节选项:', sectionOptions.value)
//
if (sectionOptions.value.length > 0) {
selectedSection.value = sectionOptions.value[0].value
console.log('🎯 默认选择章节:', selectedSection.value)
//
loadData()
}
} else {
console.log('⚠️ 章节列表为空')
sectionOptions.value = []
}
} catch (error) {
console.error('加载数据失败:', error)
message.error('加载数据失败,请重试')
console.error('❌ 加载章节列表失败:', error)
message.error('加载章节列表失败,请重试')
sectionOptions.value = []
} finally {
loading.value = false
}
}
const handleSearch = () => {
//
const loadData = async () => {
if (!props.courseId || !selectedSection.value) {
summaryList.value = []
return
}
loading.value = true
try {
console.log('📚 加载视频总结数据:', {
courseId: props.courseId,
sectionId: selectedSection.value
})
console.log('🔍 请求参数 resourceId:', selectedSection.value)
const response = await ApiRequest.get(`/aiol/aiolResourceContent/video_summary?resourceId=${selectedSection.value}`)
console.log('✅ 视频总结响应:', response.data)
if (response.data && response.data.result) {
const result = response.data.result
console.log('📊 原始结果数据:', result)
// contentData JSON
if (result.contentData) {
try {
// JSON
const unescapedData = result.contentData.replace(/\\"/g, '"').replace(/\\\\/g, '\\')
console.log('🔧 处理转义后的数据:', unescapedData)
const parsedData = JSON.parse(unescapedData)
console.log('📋 解析后的数据:', parsedData)
//
summaryList.value = parsedData.map((item: any, index: number) => ({
id: `${result.id}_${index}`,
timestamp: item.time,
title: item.title,
description: item.content,
type: '总结'
}))
console.log('✅ 总结数据加载成功,共', summaryList.value.length, '条')
} catch (error) {
console.error('❌ 解析contentData失败:', error)
summaryList.value = []
}
} else {
console.log('⚠️ contentData字段为空')
summaryList.value = []
}
} else {
console.log('⚠️ 视频总结数据为空')
summaryList.value = []
}
} catch (error) {
console.error('❌ 加载视频总结失败:', error)
message.error('加载视频总结失败,请重试')
summaryList.value = []
} finally {
loading.value = false
}
}
const handleSearch = async () => {
if (!searchKeyword.value.trim()) {
message.warning('请输入搜索关键词')
return
}
searchLoading.value = true
console.log('搜索关键词:', searchKeyword.value)
try {
//
await new Promise(resolve => setTimeout(resolve, 500))
// filteredSummaryList
// searchKeyword.value filteredSummaryList
console.log('搜索完成,找到', filteredSummaryList.value.length, '条结果')
} catch (error) {
console.error('搜索失败:', error)
message.error('搜索失败,请重试')
} finally {
searchLoading.value = false
}
}
const handleClearSearch = () => {
searchKeyword.value = ''
console.log('清空搜索')
}
const editSummary = (item: any) => {
summaryForm.value = { ...item }
summaryForm.value = {
timestamp: item.timestamp,
title: item.title,
description: item.description
}
showAddSummaryModal.value = true
}
@ -264,7 +387,17 @@ const deleteSummary = (index: number) => {
const handleAddSummary = async () => {
try {
await summaryFormRef.value?.validate()
summaryList.value.push({ ...summaryForm.value })
//
const newSummary = {
id: Date.now().toString(),
timestamp: summaryForm.value.timestamp,
title: summaryForm.value.title,
description: summaryForm.value.description,
type: '总结'
}
summaryList.value.push(newSummary)
showAddSummaryModal.value = false
summaryForm.value = { timestamp: '', title: '', description: '' }
message.success('添加成功')
@ -274,12 +407,10 @@ const handleAddSummary = async () => {
}
onMounted(() => {
//
if (courseOptions.value.length > 0) {
selectedCourse.value = courseOptions.value[0].value
//
if (props.courseId) {
loadCourseSections()
}
//
loadData()
})
</script>

View File

@ -2,43 +2,41 @@
<div class="subtitle-management">
<!-- 工具栏 -->
<div class="toolbar">
<n-select
v-model:value="selectedCourse"
:options="courseOptions"
placeholder="请选择课程"
style="width: 200px"
@update:value="handleCourseChange"
/>
<n-space>
<n-button type="primary" @click="showAddSubtitleModal = true">
<n-select v-model:value="selectedSection" :options="sectionOptions" placeholder="请选择视频章节" style="width: 250px"
@update:value="handleSectionChange" />
</n-space>
<n-space>
<n-button type="primary" @click="showAddSubtitleModal = true" :disabled="!selectedSection">
添加字幕
</n-button>
<n-input v-model:value="searchKeyword" placeholder="请输入关键词" style="width: 200px" />
<n-button type="primary" @click="handleSearch">
<n-input v-model:value="searchKeyword" placeholder="请输入关键词" style="width: 200px" @keyup.enter="handleSearch" />
<n-button type="primary" @click="handleSearch" :loading="searchLoading">
搜索
</n-button>
<n-button @click="handleClearSearch" v-if="searchKeyword">
清空
</n-button>
<n-button @click="loadData" :loading="loading">
刷新
</n-button>
</n-space>
</div>
<!-- 字幕管理区域 -->
<div class="content-area">
<div class="subtitles-section">
<n-data-table
:columns="subtitleColumns"
:data="filteredSubtitlesList"
:pagination="pagination"
:loading="loading"
:row-key="(row: any) => row.startTime + row.endTime + row.text"
striped
size="small"
/>
<n-data-table :columns="subtitleColumns" :data="filteredSubtitlesList" :pagination="pagination"
:loading="loading || searchLoading" :row-key="(row) => row.startTime + row.endTime + row.text" striped
size="small" />
</div>
</div>
<!-- 添加字幕弹窗 -->
<n-modal v-model:show="showAddSubtitleModal" title="添加字幕">
<n-card style="width: 600px" title="添加字幕" :bordered="false" size="huge">
<n-form ref="subtitleFormRef" :model="subtitleForm" :rules="subtitleRules" label-placement="left" label-width="auto">
<n-form ref="subtitleFormRef" :model="subtitleForm" :rules="subtitleRules" label-placement="left"
label-width="auto">
<n-form-item label="开始时间" path="startTime">
<n-input v-model:value="subtitleForm.startTime" placeholder="例如: 00:23" />
</n-form-item>
@ -46,12 +44,7 @@
<n-input v-model:value="subtitleForm.endTime" placeholder="例如: 00:45" />
</n-form-item>
<n-form-item label="字幕内容" path="text">
<n-input
v-model:value="subtitleForm.text"
type="textarea"
placeholder="请输入字幕内容"
:rows="3"
/>
<n-input v-model:value="subtitleForm.text" type="textarea" placeholder="请输入字幕内容" :rows="3" />
</n-form-item>
</n-form>
<template #footer>
@ -83,14 +76,22 @@ import {
type FormRules,
type DataTableColumns
} from 'naive-ui'
import { ApiRequest } from '@/api/request'
const message = useMessage()
// Props
const props = defineProps<{
courseId: string
}>()
//
const selectedCourse = ref('')
const selectedSection = ref('')
const showAddSubtitleModal = ref(false)
const searchKeyword = ref('')
const loading = ref(false)
const searchLoading = ref(false)
const sectionOptions = ref<Array<{ label: string; value: string }>>([])
//
const subtitleFormRef = ref<FormInst | null>(null)
@ -112,31 +113,14 @@ const pagination = ref({
}
})
//
const courseOptions = ref([
{ label: '职业探索与选择', value: 'course1' },
{ label: '软件工程导论', value: 'course2' },
{ label: '数据结构与算法', value: 'course3' }
])
//
const subtitlesList = ref([
{
startTime: '00:23',
endTime: '00:45',
text: '欢迎来到职业探索与选择课程,今天我们将探讨如何追求卓越与实现自我价值。'
},
{
startTime: '00:45',
endTime: '01:12',
text: '首先,让我们来了解一下职业规划的重要性,以及如何根据个人兴趣和优势做出选择。'
},
{
startTime: '01:12',
endTime: '01:35',
text: '通过分享不同领域的职场榜样,我们可以学习到成功人士的共同特质。'
}
])
const subtitlesList = ref<Array<{
id: string
startTime: string
endTime: string
text: string
language?: string
}>>([])
//
const subtitleForm = ref({
@ -168,14 +152,14 @@ const subtitleColumns: DataTableColumns<any> = [
render: (row: any) => {
return h('span', {
style: {
background: '#1890ff',
background: '#0C99DA',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}
}, `${row.startTime} - ${row.endTime}`)
}, row.startTime || row.endTime || '')
}
},
{
@ -224,32 +208,150 @@ const filteredSubtitlesList = computed(() => {
})
//
const handleCourseChange = (courseId: string) => {
console.log('切换课程:', courseId)
const handleSectionChange = (sectionId: string) => {
console.log('切换章节:', sectionId)
loadData()
}
const loadData = async () => {
loading.value = true
const loadCourseSections = async () => {
try {
// API
await new Promise(resolve => setTimeout(resolve, 500))
//
loading.value = true
console.log('📚 加载课程章节列表:', props.courseId)
const response = await ApiRequest.get(`/aiol/aiolCourse/${props.courseId}/section`)
console.log('✅ 章节列表响应:', response.data)
console.log('📊 原始章节数据:', response.data?.result)
if (response.data && response.data.result) {
const videoSections = response.data.result.filter((section: any) => section.type === 0)
console.log('🎬 筛选出的视频章节:', videoSections)
sectionOptions.value = videoSections.map((section: any) => ({
label: section.name,
value: section.id
}))
console.log('✅ 视频章节选项:', sectionOptions.value)
//
if (sectionOptions.value.length > 0) {
selectedSection.value = sectionOptions.value[0].value
console.log('🎯 默认选择章节:', selectedSection.value)
//
loadData()
}
} else {
console.log('⚠️ 章节列表为空')
sectionOptions.value = []
}
} catch (error) {
console.error('加载数据失败:', error)
message.error('加载数据失败,请重试')
console.error('❌ 加载章节列表失败:', error)
message.error('加载章节列表失败,请重试')
sectionOptions.value = []
} finally {
loading.value = false
}
}
const handleSearch = () => {
//
const loadData = async () => {
if (!props.courseId || !selectedSection.value) {
subtitlesList.value = []
return
}
loading.value = true
try {
console.log('📚 加载视频字幕数据:', {
courseId: props.courseId,
sectionId: selectedSection.value
})
console.log('🔍 请求参数 resourceId:', selectedSection.value)
const response = await ApiRequest.get(`/aiol/aiolResourceContent/video_subtitle?resourceId=${selectedSection.value}`)
console.log('✅ 视频字幕响应:', response.data)
if (response.data && response.data.result) {
const result = response.data.result
console.log('📊 原始结果数据:', result)
// contentData JSON
if (result.contentData) {
try {
// JSON
const unescapedData = result.contentData.replace(/\\"/g, '"').replace(/\\\\/g, '\\')
console.log('🔧 处理转义后的数据:', unescapedData)
const parsedData = JSON.parse(unescapedData)
console.log('📋 解析后的数据:', parsedData)
//
subtitlesList.value = parsedData.map((item: any, index: number) => ({
id: `${result.id}_${index}`,
startTime: item.startTime || item.time || '',
endTime: item.endTime || '',
text: item.text || item.content || '',
language: 'zh-CN'
}))
console.log('✅ 字幕数据加载成功,共', subtitlesList.value.length, '条')
} catch (error) {
console.error('❌ 解析contentData失败:', error)
subtitlesList.value = []
}
} else {
console.log('⚠️ contentData字段为空')
subtitlesList.value = []
}
} else {
console.log('⚠️ 视频字幕数据为空')
subtitlesList.value = []
}
} catch (error) {
console.error('❌ 加载视频字幕失败:', error)
message.error('加载视频字幕失败,请重试')
subtitlesList.value = []
} finally {
loading.value = false
}
}
const handleSearch = async () => {
if (!searchKeyword.value.trim()) {
message.warning('请输入搜索关键词')
return
}
searchLoading.value = true
console.log('搜索关键词:', searchKeyword.value)
try {
//
await new Promise(resolve => setTimeout(resolve, 500))
// filteredSubtitlesList
// searchKeyword.value filteredSubtitlesList
console.log('搜索完成,找到', filteredSubtitlesList.value.length, '条结果')
} catch (error) {
console.error('搜索失败:', error)
message.error('搜索失败,请重试')
} finally {
searchLoading.value = false
}
}
const handleClearSearch = () => {
searchKeyword.value = ''
console.log('清空搜索')
}
const editSubtitle = (item: any) => {
subtitleForm.value = { ...item }
subtitleForm.value = {
startTime: item.startTime,
endTime: item.endTime,
text: item.text
}
showAddSubtitleModal.value = true
}
@ -261,7 +363,17 @@ const deleteSubtitle = (index: number) => {
const handleAddSubtitle = async () => {
try {
await subtitleFormRef.value?.validate()
subtitlesList.value.push({ ...subtitleForm.value })
//
const newSubtitle = {
id: Date.now().toString(),
startTime: subtitleForm.value.startTime,
endTime: subtitleForm.value.endTime,
text: subtitleForm.value.text,
language: 'zh-CN'
}
subtitlesList.value.push(newSubtitle)
showAddSubtitleModal.value = false
subtitleForm.value = { startTime: '', endTime: '', text: '' }
message.success('添加成功')
@ -270,15 +382,11 @@ const handleAddSubtitle = async () => {
}
}
onMounted(() => {
//
if (courseOptions.value.length > 0) {
selectedCourse.value = courseOptions.value[0].value
//
if (props.courseId) {
loadCourseSections()
}
//
loadData()
})
</script>

View File

@ -40,7 +40,15 @@
<!-- 右侧课程信息 -->
<div class="course-info-section">
<h1 class="course-title">{{ courseInfo.title }}</h1>
<div class="course-description" v-html="cleanHtmlContent(courseInfo.description)"></div>
<div class="course-description">
<div v-if="!showFullDescription" class="description-preview"
v-html="cleanHtmlContent(courseInfo.description?.substring(0, 200) + '...')"></div>
<div v-else class="description-full" v-html="cleanHtmlContent(courseInfo.description)"></div>
<button v-if="courseInfo.description && courseInfo.description.length > 200" @click="toggleDescription"
class="toggle-btn">
{{ showFullDescription ? '收起' : '展开' }}
</button>
</div>
<!-- 课程关键信息 -->
<div class="course-metrics">
@ -78,10 +86,15 @@
<!-- 开课学期选择 -->
<div class="semester-section">
<span class="semester-label">开课1学期</span>
<span class="semester-label">开课学期</span>
<div v-if="semesterOptions.length > 0" class="semester-select-container">
<n-select v-model:value="selectedSemester" :options="semesterOptions" class="semester-select"
size="small" />
</div>
<div v-else class="semester-empty">
<span class="semester-empty-text">暂无学期信息</span>
</div>
</div>
</div>
</template>
</div>
@ -134,12 +147,12 @@
<transition name="tab-fade" mode="out-in">
<!-- 课程介绍内容 -->
<div v-if="activeTab === 'intro'" key="intro" class="tab-pane">
<CourseIntro />
<CourseIntro :course-info="courseInfo" />
</div>
<!-- 教学团队内容 -->
<div v-else-if="activeTab === 'team'" key="team" class="tab-pane">
<TeachingTeam />
<TeachingTeam :instructors="instructors" />
</div>
<!-- 章节目录内容 -->
@ -182,7 +195,7 @@ const courseInfo = ref({
courseTime: '2025-08-25-2026.08-25',
category: '分类名称',
duration: '4小时28分钟',
instructor: '王建国',
instructor: '加载中...',
teacherCount: 1,
credits: 60,
thumbnail: '/images/teacher/fj.png'
@ -205,16 +218,70 @@ const courseStats = ref({
})
//
const selectedSemester = ref('2025-2026-1')
const semesterOptions = [
{ label: '2025-2026第一学期', value: '2025-2026-1' },
{ label: '2025-2026第二学期', value: '2025-2026-2' }
]
const selectedSemester = ref('')
const semesterOptions = ref<Array<{ label: string; value: string }>>([]) // API
//
const loadSemesterOptions = async () => {
try {
//
// const response = await SemesterApi.getSemesterList()
// if (response.data && response.data.code === 200) {
// semesterOptions.value = response.data.result.map(item => ({
// label: item.name,
// value: item.id
// }))
// }
//
console.log('📅 学期选项加载完成:', semesterOptions.value)
} catch (error) {
console.error('❌ 加载学期选项失败:', error)
}
}
//
const activeTab = ref('intro')
// /
const showFullDescription = ref(false)
// - 使
// const truncatedDescription = computed(() => {
// try {
// if (!courseInfo.value?.description) return ''
//
// // HTML
// const textContent = courseInfo.value.description.replace(/<[^>]*>/g, '')
//
// if (textContent.length <= maxDescriptionLength) {
// return courseInfo.value.description
// }
//
// // 200
// const truncated = textContent.substring(0, maxDescriptionLength) + '...'
// return truncated
// } catch (error) {
// console.error(':', error)
// return courseInfo.value?.description || ''
// }
// })
// const needsTruncation = computed(() => {
// try {
// if (!courseInfo.value?.description) return false
// const textContent = courseInfo.value.description.replace(/<[^>]*>/g, '')
// return textContent.length > maxDescriptionLength
// } catch (error) {
// console.error(':', error)
// return false
// }
// })
//
const toggleDescription = () => {
showFullDescription.value = !showFullDescription.value
}
const goBack = () => {
if (window.history.length > 1) {
router.go(-1)
@ -258,20 +325,21 @@ const loadCourseDetail = async () => {
duration: course?.duration,
studentsCount: course?.studentsCount,
createdAt: course?.createdAt,
updatedAt: course?.updatedAt
updatedAt: course?.updatedAt,
semester: course?.semester
})
//
courseInfo.value = {
title: course?.title || '课程名称课程名称课',
description: course?.description || '本课程旨在带领学生系统地学习【课程核心领域】的知识。我们将从【最基础的概念】讲起,逐步深入到【高级主题或应用】。通过理论与实践相结合的方式,学生不仅能够掌握【具体的理论知识】,还能获得【具体的实践技能,如解决XX问题、开发XX应用等】。',
title: course?.title || '',
description: course?.description || '',
courseTime: formatCourseTime(course?.createdAt, course?.updatedAt),
category: course?.category?.name || '分类名称',
duration: course?.duration || '4小时28分钟',
instructor: course?.instructor?.name || '王建国',
teacherCount: 1, // 1
credits: 60, // 60
thumbnail: course?.thumbnail || '/images/teacher/fj.png'
category: course?.category?.name || '',
duration: course?.duration || '',
instructor: course?.instructor?.name || '',
teacherCount: course?.teacherList?.length || 0,
credits: 0, //
thumbnail: course?.thumbnail || ''
}
// API
@ -285,8 +353,22 @@ const loadCourseDetail = async () => {
comments: Math.floor((course?.studentsCount || 0) * 0.3)
}
//
if (course?.semester) {
//
semesterOptions.value = [
{ label: course.semester, value: course.semester }
]
selectedSemester.value = course.semester
console.log('📅 设置课程学期:', course.semester)
console.log('📅 学期选项更新:', semesterOptions.value)
} else {
console.log('📅 课程未设置学期信息')
}
console.log('🎯 课程信息更新完成:', courseInfo.value)
console.log('📈 统计数据更新完成:', courseStats.value)
console.log('📅 学期信息:', course?.semester || '未设置学期')
} else {
error.value = response.message || '获取课程详情失败'
console.error('❌ API返回错误:', response)
@ -334,7 +416,7 @@ const loadCourseInstructors = async () => {
})
//
const allInstructorNames = sortedInstructors.map(teacher => teacher.name).join('、')
courseInfo.value.instructor = allInstructorNames || '王建国'
courseInfo.value.instructor = allInstructorNames || '暂无讲师'
console.log('👨‍🏫 更新所有教师:', courseInfo.value.instructor)
console.log('📋 教师团队排序:', sortedInstructors.map(t => ({ name: t.name, sortOrder: t.sortOrder })))
}
@ -342,11 +424,13 @@ const loadCourseInstructors = async () => {
console.warn('⚠️ 教师团队API返回错误:', response)
//
courseInfo.value.teacherCount = 1
courseInfo.value.instructor = '未知讲师'
}
} catch (err) {
console.error('❌ 加载教师团队失败:', err)
//
courseInfo.value.teacherCount = 1
courseInfo.value.instructor = '未知讲师'
} finally {
instructorsLoading.value = false
}
@ -405,7 +489,15 @@ const loadCourseCategoryFromManagementAPI = async () => {
// ID
const categoryNames = categoryIds.map((id: number) => {
const category = categoryResponse.data.find(cat => cat.id === String(id))
// ID
let category = categoryResponse.data.find(cat => cat.id === String(id))
if (!category) {
category = categoryResponse.data.find(cat => String(cat.id) === String(id))
}
if (!category) {
category = categoryResponse.data.find(cat => Number(cat.id) === id)
}
return category ? category.name : `未知分类${id}`
}).filter(Boolean)
@ -428,7 +520,8 @@ onMounted(async () => {
//
await Promise.all([
loadCourseDetail(),
loadCourseInstructors()
loadCourseInstructors(),
loadSemesterOptions()
])
console.log('🎉 所有数据加载完成')
@ -558,6 +651,7 @@ onMounted(async () => {
.retry-btn:hover {
background: #0A8BC7;
}
/* 左侧课程图片 */
.course-image-section {
flex: 0 0 305px;
@ -592,6 +686,30 @@ onMounted(async () => {
margin-bottom: 10px;
}
.description-preview {
max-height: 100px;
overflow: hidden;
}
.description-full {
max-height: none;
}
.toggle-btn {
background: none;
border: none;
color: #0288D1;
cursor: pointer;
font-size: 14px;
padding: 4px 0;
margin-top: 8px;
text-decoration: underline;
}
.toggle-btn:hover {
color: #0277BD;
}
/* 课程关键信息 */
.course-metrics {
display: grid;
@ -630,10 +748,30 @@ onMounted(async () => {
color: #333;
}
.semester-select {
.semester-select-container {
width: 181px;
}
.semester-select {
width: 100%;
}
.semester-empty {
display: flex;
align-items: center;
width: 181px;
height: 37px;
padding: 0 12px;
background: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 3px;
}
.semester-empty-text {
font-size: 14px;
color: #999;
}
/* 学期选择器样式 */
.semester-select :deep(.n-base-selection-label) {
background-color: #0C99DA !important;

View File

@ -11,10 +11,10 @@
<OperationLog />
</n-tab-pane>
<n-tab-pane name="content" tab="课程内容">
<CourseContentManagement />
<CourseContentManagement :course-id="courseId" />
</n-tab-pane>
<n-tab-pane name="subtitles" tab="字幕列表">
<SubtitleManagement />
<SubtitleManagement :course-id="courseId" />
</n-tab-pane>
</n-tabs>
</div>

View File

@ -2,157 +2,196 @@
<div class="chapters-content">
<h4>章节目录</h4>
<div class="chapter-list">
<div class="chapter-section">
<div class="chapter-header" @click="toggleChapter(0)">
<div class="chapter-info">
<span class="chapter-number">第一章</span>
<span class="chapter-title">课前准备</span>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<span>正在加载章节目录...</span>
</div>
<span class="chapter-toggle" :class="{ 'expanded': chapters[0].expanded }">
<!-- 章节列表 -->
<div v-else-if="chapters.length > 0" class="chapter-list">
<div v-for="(chapter, index) in chapters" :key="chapter.id" class="chapter-section">
<div class="chapter-header" @click="toggleChapter(index)">
<div class="chapter-info">
<span class="chapter-number">{{ index + 1 }}</span>
<span class="chapter-title">{{ chapter.name }}</span>
</div>
<span class="chapter-toggle" :class="{ 'expanded': chapter.expanded }">
<svg width="12" height="12" viewBox="0 0 12 12">
<path d="M4 3l4 3-4 3" stroke="currentColor" stroke-width="1.5" fill="none" />
</svg>
</span>
</div>
<div v-if="chapters[0].expanded" class="chapter-lessons">
<div class="lesson-item">
<div v-if="chapter.expanded" class="chapter-lessons">
<div v-for="lesson in getChapterLessons(chapter.id)" :key="lesson.id" class="lesson-item">
<div class="lesson-content">
<div class="lesson-type-badge video">视频</div>
<div class="lesson-type-badge" :class="getLessonTypeClass(lesson.type)">
{{ getLessonTypeText(lesson.type) }}
</div>
<div class="lesson-info">
<span class="lesson-title">开课彩蛋:新开始新征程</span>
<span class="lesson-title">{{ lesson.name }}</span>
</div>
<div class="lesson-duration">
<span class="duration-text">01:03:56</span>
<img src="/images/courses/video.png" alt="视频" class="duration-play-icon">
<span v-if="lesson.duration" class="duration-text">{{ lesson.duration }}</span>
<img :src="getLessonIcon(lesson.type)" :alt="getLessonTypeText(lesson.type)" class="duration-icon">
</div>
</div>
</div>
<div class="lesson-item">
<div class="lesson-content">
<div class="lesson-type-badge video">视频</div>
<div class="lesson-info">
<span class="lesson-title">课程定位与目标</span>
</div>
<div class="lesson-duration">
<span class="duration-text">00:44:05</span>
<img src="/images/courses/video.png" alt="视频" class="duration-play-icon">
</div>
</div>
</div>
<div class="lesson-item">
<div class="lesson-content">
<div class="lesson-type-badge video">视频</div>
<div class="lesson-info">
<span class="lesson-title">教学安排及学习建议</span>
</div>
<div class="lesson-duration">
<span class="duration-text">00:52:22</span>
<img src="/images/courses/video.png" alt="视频" class="duration-play-icon">
</div>
</div>
</div>
<div class="lesson-item">
<div class="lesson-content">
<div class="lesson-type-badge resource">资料</div>
<div class="lesson-info">
<span class="lesson-title">课前准备PPT</span>
</div>
<div class="lesson-duration">
<img src="/images/courses/download.png" alt="下载" class="duration-download-icon">
</div>
</div>
</div>
</div>
</div>
<div class="chapter-section">
<div class="chapter-header" @click="toggleChapter(1)">
<div class="chapter-info">
<span class="chapter-number">第一章</span>
<span class="chapter-title">课前准备</span>
</div>
<span class="chapter-toggle" :class="{ 'expanded': chapters[1].expanded }">
<svg width="12" height="12" viewBox="0 0 12 12">
<path d="M4 3l4 3-4 3" stroke="currentColor" stroke-width="1.5" fill="none" />
</svg>
</span>
</div>
<div v-if="chapters[1].expanded" class="chapter-lessons">
<div class="lesson-item">
<div class="lesson-content">
<div class="lesson-type-badge video">视频</div>
<div class="lesson-info">
<span class="lesson-title">开课彩蛋:新开始新征程</span>
</div>
<div class="lesson-duration">
<span class="duration-text">01:03:56</span>
<img src="/images/courses/video.png" alt="视频" class="duration-play-icon">
</div>
</div>
</div>
<div class="lesson-item">
<div class="lesson-content">
<div class="lesson-type-badge video">视频</div>
<div class="lesson-info">
<span class="lesson-title">课程定位与目标</span>
</div>
<div class="lesson-duration">
<span class="duration-text">00:44:05</span>
<img src="/images/courses/video.png" alt="视频" class="duration-play-icon">
</div>
</div>
</div>
<div class="lesson-item">
<div class="lesson-content">
<div class="lesson-type-badge video">视频</div>
<div class="lesson-info">
<span class="lesson-title">教学安排及学习建议</span>
</div>
<div class="lesson-duration">
<span class="duration-text">00:52:22</span>
<img src="/images/courses/video.png" alt="视频" class="duration-play-icon">
</div>
</div>
</div>
<div class="lesson-item">
<div class="lesson-content">
<div class="lesson-type-badge resource">资料</div>
<div class="lesson-info">
<span class="lesson-title">课前准备PPT</span>
</div>
<div class="lesson-duration">
<img src="/images/courses/download.png" alt="下载" class="duration-download-icon">
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-state">
<div class="empty-text">暂无章节目录</div>
<div class="empty-desc">该课程还没有配置章节内容</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { CourseApi } from '@/api/modules/course'
import type { CourseSection } from '@/api/types'
//
interface ChapterWithExpanded extends CourseSection {
expanded?: boolean
}
//
const route = useRoute()
//
const chapters = ref([
{
id: 1,
title: '课前准备',
expanded: true
},
{
id: 2,
title: '课前准备',
expanded: true
const chapters = ref<ChapterWithExpanded[]>([])
const allSections = ref<CourseSection[]>([])
const loading = ref(false)
// URLcourseId
const courseId = computed(() => {
return route.params.id as string
})
//
const parentChapters = computed(() => {
if (!Array.isArray(allSections.value)) {
console.warn('⚠️ allSections.value 不是数组:', allSections.value)
return []
}
// level
console.log('🔍 所有章节的level值:', allSections.value.map(s => ({ name: s.name, level: s.level, parentId: s.parentId })))
// parentIdlevel
// parentIdparentId
const parentChapters = allSections.value.filter(section => !section.parentId || section.parentId === '')
console.log('🔍 找到的一级章节:', parentChapters.map(s => ({ name: s.name, level: s.level, parentId: s.parentId })))
return parentChapters
})
//
const getChapterLessons = (chapterId: string) => {
if (!Array.isArray(allSections.value)) {
console.warn('⚠️ allSections.value 不是数组:', allSections.value)
return []
}
const lessons = allSections.value.filter(section => section.parentId === chapterId)
console.log(`🔍 章节 ${chapterId} 的子课程:`, lessons.map(s => ({ name: s.name, type: s.type })))
return lessons
}
//
const getLessonTypeText = (type: number | null) => {
switch (type) {
case 0: return '视频'
case 1: return '资料'
case 2: return '考试'
case 3: return '作业'
default: return '课程'
}
}
//
const getLessonTypeClass = (type: number | null) => {
switch (type) {
case 0: return 'video'
case 1: return 'resource'
case 2: return 'exam'
case 3: return 'homework'
default: return 'default'
}
}
//
const getLessonIcon = (type: number | null) => {
switch (type) {
case 0: return '/images/courses/video.png'
case 1: return '/images/courses/download.png'
case 2: return '/images/courses/examination.png'
case 3: return '/images/courses/homework.png'
default: return '/images/courses/video.png'
}
}
])
// /
const toggleChapter = (index: number) => {
chapters.value[index].expanded = !chapters.value[index].expanded
}
//
const loadChapters = async () => {
if (!courseId.value) {
console.warn('❌ 课程ID不存在无法加载章节数据')
return
}
loading.value = true
try {
console.log('🚀 开始加载课程章节课程ID:', courseId.value)
console.log('🔍 API请求URL: /aiol/aiolCourse/' + courseId.value + '/section')
const response = await CourseApi.getCourseSections(courseId.value)
console.log('📊 章节API响应:', response)
if (response.code === 200 && response.data) {
// CourseApi.getCourseSections
const sectionsData = response.data.list || []
allSections.value = sectionsData
console.log('🔍 处理后的章节数据:', allSections.value)
console.log('🔍 数据类型:', typeof allSections.value, '是否为数组:', Array.isArray(allSections.value))
console.log('🔍 章节数量:', allSections.value.length)
//
chapters.value = parentChapters.value.map(chapter => ({
...chapter,
expanded: false //
} as ChapterWithExpanded))
console.log('✅ 章节数据加载成功,共', chapters.value.length, '个章节')
console.log('📋 所有章节数据:', allSections.value)
} else {
console.log('⚠️ API返回数据为空或失败')
chapters.value = []
allSections.value = []
}
} catch (error) {
console.error('❌ 加载章节失败:', error)
chapters.value = []
allSections.value = []
} finally {
loading.value = false
}
}
//
onMounted(() => {
loadChapters()
})
</script>
<style scoped>
@ -163,14 +202,66 @@ const toggleChapter = (index: number) => {
margin: 0 0 12px 0;
}
/* 加载状态 */
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 0;
color: #666;
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #0288D1;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 10px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.empty-text {
font-size: 16px;
font-weight: 500;
color: #666;
margin-bottom: 8px;
}
.empty-desc {
font-size: 14px;
color: #999;
}
.chapter-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.chapter-section {
}
.chapter-section {}
.chapter-section:last-child {
border-bottom: none;
@ -282,16 +373,29 @@ const toggleChapter = (index: number) => {
font-weight: 500;
}
.duration-play-icon {
.duration-icon {
width: 14px;
height: 14px;
object-fit: contain;
}
.duration-download-icon {
width: 14px;
height: 14px;
object-fit: contain;
/* 课程类型样式 */
.lesson-type-badge.exam {
background: transparent;
border: 1px solid #FF9800;
color: #FF9800;
}
.lesson-type-badge.homework {
background: transparent;
border: 1px solid #9C27B0;
color: #9C27B0;
}
.lesson-type-badge.default {
background: transparent;
border: 1px solid #E1E1E1;
color: #C0C0C0;
}

View File

@ -2,7 +2,8 @@
<div class="comments-content">
<h4>评论</h4>
<div class="comment-list">
<!-- 评论列表 -->
<div class="comment-list" v-if="displayComments.length > 0">
<div class="comment-item" v-for="comment in displayComments" :key="comment.id">
<div class="comment-avatar">
<img :src="comment.avatar" :alt="comment.username" />
@ -10,7 +11,8 @@
<div class="comment-content">
<div class="comment-header">
<span class="comment-username">{{ comment.username }}</span>
<span v-if="comment.type === 'instructor'" class="instructor-badge">讲师</span>
<span v-if="comment.userType === 'instructor'" class="instructor-badge">{{ comment.userBadge }}</span>
<span v-else-if="comment.userType === 'student'" class="student-badge">{{ comment.userBadge }}</span>
</div>
<div class="comment-text">{{ comment.content }}</div>
<div class="comment-actions">
@ -20,7 +22,13 @@
<button class="action-btn">
<span>{{ comment.time }}</span>
</button>
<button class="action-btn" @click="startReply(comment.id, comment.username)">回复</button>
<button class="action-btn" @click="likeComment(comment)">
<span>{{ comment.isLiked ? '已点赞' : '点赞' }} ({{ comment.likeCount }})</span>
</button>
<button v-if="!comment.replies || comment.replies.length === 0" class="action-btn"
@click="startReply(comment.id, comment.username)">
回复
</button>
</div>
<!-- 回复区域 -->
@ -46,92 +54,288 @@
</div>
</div>
</div>
<!-- 回复输入框 -->
<div v-if="replyingTo === comment.id" class="reply-input-section">
<div class="reply-input-header">
<span>回复 @{{ replyToUsername }}</span>
</div>
<div class="reply-input-content">
<textarea v-model="replyContent" class="reply-textarea" placeholder="请输入回复内容..." :maxlength="500"
rows="3"></textarea>
<div class="reply-input-actions">
<button class="reply-cancel-btn" @click="cancelReply">取消</button>
<button class="reply-submit-btn" @click="submitReply">发送</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else-if="!loading && !error" class="empty-state">
<div class="empty-content">
<h3 class="empty-title">暂无评论</h3>
<p class="empty-description">还没有人发表评论快来抢沙发吧</p>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<div class="loading-content">
<p>正在加载评论...</p>
</div>
</div>
<!-- 错误状态 -->
<div v-if="error" class="error-container">
<div class="error-content">
<p>{{ error }}</p>
<button @click="loadComments" class="retry-btn">重试</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useMessage } from 'naive-ui'
import { CommentApi } from '@/api/modules/comment'
//
const route = useRoute()
const message = useMessage()
// ID
const courseId = computed(() => route.params.id as string)
//
const displayComments = ref([
{
id: 1,
username: '春暖花开°C',
avatar: '/images/activity/1.png',
time: '2025.07.23 16:28',
content: '为了让科学教育有效,它必须广泛包容,而且应该认识到科学教师、科学家、家庭和社区如何合作实现学习和教学的目标。',
isPinned: true,
type: 'pinned',
replies: [
{
id: 'r1',
username: '汪波',
avatar: '/images/activity/3.png',
time: '2025.07.23 16:28',
content: '欢迎大家👋又不懂的地方可以私信老师',
type: 'instructor',
badge: '讲师'
}
]
},
{
id: 2,
username: '春暖花开°C',
avatar: '/images/activity/2.png',
time: '2025.07.23 16:28',
content: '来了来了!老师讲的好好啊~',
isPinned: false,
type: 'comment',
replies: []
},
{
id: 3,
username: '春暖花开°C',
avatar: '/images/activity/4.png',
time: '2025.07.23 16:28',
content: '为了让科学教育有效,它必须广泛包容,而且应该认识到科学教师、科学家、家庭和社区如何合作实现学习和教学的目标。',
isPinned: true,
type: 'pinned',
replies: [
{
id: 'r2',
username: '汪波',
avatar: '/images/activity/6.png',
time: '2025.07.23 16:28',
content: '欢迎大家👋又不懂的地方可以私信老师',
type: 'instructor',
badge: '讲师'
}
]
},
{
id: 4,
username: '春暖花开°C',
avatar: '/images/activity/5.png',
time: '2025.07.23 16:28',
content: '来了来了!老师讲的好好啊~',
isPinned: false,
type: 'comment',
replies: []
},
{
id: 5,
username: '春暖花开°C',
avatar: '/images/activity/7.png',
time: '2025.07.23 16:28',
content: '为了让科学教育有效,它必须广泛包容,而且应该认识到科学教师、科学家、家庭和社区如何合作实现学习和教学的目标。',
isPinned: true,
type: 'pinned',
replies: []
}
])
const displayComments = ref<any[]>([])
const loading = ref(false)
const error = ref('')
//
const replyingTo = ref<number | null>(null)
const replyToUsername = ref('')
const replyContent = ref('')
//
const formatTime = (timeStr: string) => {
try {
const date = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
// 1
if (diff < 60000) {
return '刚刚'
}
// 1
if (diff < 3600000) {
return `${Math.floor(diff / 60000)}分钟前`
}
// 1
if (diff < 86400000) {
return `${Math.floor(diff / 3600000)}小时前`
}
// 7
if (diff < 604800000) {
return `${Math.floor(diff / 86400000)}天前`
}
// 7
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).replace(/\//g, '.')
} catch (error) {
return timeStr
}
}
//
const loadComments = async () => {
if (!courseId.value) {
console.error('❌ 课程ID不存在')
return
}
try {
loading.value = true
error.value = ''
console.log('🚀 开始加载课程评论:', courseId.value)
// API - 使
const response = await CommentApi.getCourseComments(Number(courseId.value), {
page: 1,
pageSize: 20,
sortBy: 'newest'
})
console.log('📊 评论API响应:', response)
if (response.data && response.data.code === 200) {
const comments = response.data.result || []
// API
const apiComments = comments.map((comment: any) => {
//
console.log('🔍 原始评论数据:', comment)
console.log('🔍 用户身份相关字段:', {
userType: comment.userType,
user_type: comment.user_type,
role: comment.role,
isTeacher: comment.isTeacher,
is_teacher: comment.is_teacher,
userRole: comment.userRole,
user_role: comment.user_role,
type: comment.type,
userCategory: comment.userCategory,
user_category: comment.user_category
})
//
let userType = 'user'
let userBadge = '用户'
// / -
if (comment.userType === 'teacher' || comment.user_type === 'teacher' ||
comment.role === 'teacher' || comment.role === 'instructor' ||
comment.isTeacher === true || comment.is_teacher === true ||
comment.userRole === 'teacher' || comment.user_role === 'teacher' ||
comment.type === 'teacher' || comment.type === 'instructor' ||
comment.userCategory === 'teacher' || comment.user_category === 'teacher') {
userType = 'instructor'
userBadge = '讲师'
console.log('✅ 识别为讲师')
}
//
else if (comment.userType === 'student' || comment.user_type === 'student' ||
comment.role === 'student' || comment.isStudent === true ||
comment.is_student === true ||
comment.userRole === 'student' || comment.user_role === 'student' ||
comment.type === 'student' ||
comment.userCategory === 'student' || comment.user_category === 'student') {
userType = 'student'
userBadge = '学生'
console.log('✅ 识别为学生')
} else {
console.log('❌ 未识别用户身份,默认为用户')
}
return {
id: comment.id,
username: comment.userName || comment.username || '匿名用户',
avatar: comment.userAvatar || comment.avatar || '/images/activity/1.png',
time: formatTime(comment.createTime || comment.create_time),
content: comment.content,
isPinned: comment.isPinned || comment.izTop === 1,
type: comment.isPinned ? 'pinned' : 'comment',
likeCount: comment.likeCount || 0,
isLiked: comment.isLiked || false,
userType: userType,
userBadge: userBadge,
replies: (comment.replies || []).map((reply: any) => {
//
console.log('🔍 原始回复数据:', reply)
console.log('🔍 回复用户身份相关字段:', {
userType: reply.userType,
user_type: reply.user_type,
role: reply.role,
isTeacher: reply.isTeacher,
is_teacher: reply.is_teacher,
userRole: reply.userRole,
user_role: reply.user_role,
type: reply.type,
userCategory: reply.userCategory,
user_category: reply.user_category
})
//
let userType = 'user'
let userBadge = '用户'
// / -
if (reply.userType === 'teacher' || reply.user_type === 'teacher' ||
reply.role === 'teacher' || reply.role === 'instructor' ||
reply.isTeacher === true || reply.is_teacher === true ||
reply.userRole === 'teacher' || reply.user_role === 'teacher' ||
reply.type === 'teacher' || reply.type === 'instructor' ||
reply.userCategory === 'teacher' || reply.user_category === 'teacher') {
userType = 'instructor'
userBadge = '讲师'
console.log('✅ 回复识别为讲师')
}
//
else if (reply.userType === 'student' || reply.user_type === 'student' ||
reply.role === 'student' || reply.isStudent === true ||
reply.is_student === true ||
reply.userRole === 'student' || reply.user_role === 'student' ||
reply.type === 'student' ||
reply.userCategory === 'student' || reply.user_category === 'student') {
userType = 'student'
userBadge = '学生'
console.log('✅ 回复识别为学生')
} else {
console.log('❌ 回复未识别用户身份,默认为用户')
}
return {
id: reply.id || `reply_${Date.now()}`,
username: reply.userName || reply.username || reply.user_name || '匿名用户',
avatar: reply.userAvatar || reply.avatar || reply.user_avatar || '/images/activity/1.png',
time: formatTime(reply.createTime || reply.create_time || reply.time),
content: reply.content || reply.text || '',
type: userType,
badge: userBadge
}
})
}
})
// 使API
displayComments.value = apiComments
console.log('✅ API评论数据:', apiComments.length, '条')
console.log('✅ 转换后的评论数据:', displayComments.value)
// API
if (comments.length > 0 && comments[0].replies) {
console.log('🔍 API原始回复数据:', comments[0].replies)
}
} else {
console.warn('⚠️ 评论API返回数据为空或失败')
//
displayComments.value = []
}
} catch (err: any) {
console.error('❌ 加载评论失败:', err)
console.error('❌ 错误详情:', {
message: err.message,
status: err.response?.status,
statusText: err.response?.statusText,
data: err.response?.data,
url: err.config?.url
})
error.value = `加载评论失败: ${err.response?.status || err.message}`
//
displayComments.value = []
} finally {
loading.value = false
}
}
//
const startReply = (commentId: number, username: string) => {
@ -139,11 +343,80 @@ const startReply = (commentId: number, username: string) => {
replyToUsername.value = username
}
//
const submitReply = async () => {
if (!replyContent.value.trim()) {
message.warning('请输入回复内容')
return
}
try {
console.log('🚀 发送回复请求:', {
content: replyContent.value,
targetType: 'comment',
targetId: replyingTo.value
})
// 使
const response = await CommentApi.replyComment({
content: replyContent.value,
targetType: 'comment',
targetId: String(replyingTo.value),
parentId: replyingTo.value || undefined
})
console.log('📊 回复API响应:', response)
if (response.data && response.data.code === 200) {
message.success('回复成功')
replyContent.value = ''
replyingTo.value = null
replyToUsername.value = ''
//
await loadComments()
} else {
message.error(response.data?.message || '回复失败')
}
} catch (err) {
console.error('❌ 回复失败:', err)
message.error('回复失败,请重试')
}
}
//
const likeComment = async (comment: any) => {
try {
console.log('🚀 发送点赞请求:', comment.id)
const response = await CommentApi.likeComment(comment.id)
console.log('📊 点赞API响应:', response)
if (response.data && response.data.code === 200) {
comment.isLiked = !comment.isLiked
comment.likeCount += comment.isLiked ? 1 : -1
message.success(comment.isLiked ? '点赞成功' : '取消点赞')
} else {
message.error(response.data?.message || '操作失败')
}
} catch (err) {
console.error('❌ 点赞失败:', err)
message.error('点赞失败,请重试')
}
}
//
// const cancelReply = () => {
// replyingTo.value = null
// replyToUsername.value = ''
// }
const cancelReply = () => {
replyContent.value = ''
replyingTo.value = null
replyToUsername.value = ''
}
//
onMounted(() => {
loadComments()
})
</script>
<style scoped>
@ -200,6 +473,17 @@ const startReply = (commentId: number, username: string) => {
font-weight: 500;
}
.student-badge {
display: inline-block;
padding: 2px 6px;
background: #fff7e6;
color: #fa8c16;
font-size: 10px;
border-radius: 2px;
margin-left: 8px;
font-weight: 500;
}
.comment-text {
font-size: 14px;
line-height: 1.6;
@ -307,6 +591,16 @@ const startReply = (commentId: number, username: string) => {
color: #52c41a;
}
.reply-badge.student {
background: #fff7e6;
color: #fa8c16;
}
.reply-badge.teacher {
background: #f6ffed;
color: #52c41a;
}
.reply-time {
font-size: 12px;
color: #999;
@ -345,4 +639,154 @@ const startReply = (commentId: number, username: string) => {
.reply-action-btn:hover {
color: #1890ff;
}
/* 空状态样式 */
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: 60px 20px;
min-height: 200px;
}
.empty-content {
text-align: center;
max-width: 300px;
}
.empty-title {
font-size: 18px;
font-weight: 500;
color: #374151;
margin: 0 0 8px 0;
}
.empty-description {
font-size: 14px;
color: #6B7280;
margin: 0 0 24px 0;
line-height: 1.5;
}
/* 加载和错误状态样式 */
.loading-container,
.error-container {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 20px;
}
.loading-content,
.error-content {
text-align: center;
}
.loading-content p,
.error-content p {
color: #666;
font-size: 14px;
margin-bottom: 16px;
}
.retry-btn {
background: #1890ff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.retry-btn:hover {
background: #40a9ff;
}
/* 回复输入框样式 */
.reply-input-section {
margin-top: 16px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.reply-input-header {
margin-bottom: 12px;
font-size: 14px;
color: #666;
font-weight: 500;
}
.reply-input-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.reply-textarea {
width: 100%;
min-height: 80px;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
line-height: 1.5;
resize: vertical;
outline: none;
transition: border-color 0.3s;
}
.reply-textarea:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.reply-textarea::placeholder {
color: #bfbfbf;
}
.reply-input-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.reply-cancel-btn {
padding: 6px 16px;
border: 1px solid #d9d9d9;
background: white;
color: #666;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
}
.reply-cancel-btn:hover {
border-color: #40a9ff;
color: #40a9ff;
}
.reply-submit-btn {
padding: 6px 16px;
border: none;
background: #1890ff;
color: white;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: background 0.3s;
}
.reply-submit-btn:hover {
background: #40a9ff;
}
.reply-submit-btn:active {
background: #096dd9;
}
</style>

View File

@ -1,15 +1,118 @@
<template>
<div class="intro-content">
<h4>课程介绍</h4>
<img src="/images/courses/课程介绍区.png" alt="课程介绍图片" class="course-intro-image">
<!-- 课程描述 -->
<div v-if="courseDescription" class="course-description" v-html="courseDescription"></div>
<!-- 课程大纲 -->
<div v-if="courseOutline" class="course-outline">
<h5>课程大纲</h5>
<div class="outline-content" v-html="courseOutline"></div>
</div>
<!-- 授课目标 -->
<div v-if="courseTarget" class="course-target">
<h5>授课目标</h5>
<div class="target-content" v-html="courseTarget"></div>
</div>
<!-- 预备知识 -->
<div v-if="coursePrerequisite" class="course-prerequisite">
<h5>预备知识</h5>
<div class="prerequisite-content" v-html="coursePrerequisite"></div>
</div>
<!-- 参考资料 -->
<div v-if="courseReference" class="course-reference">
<h5>参考资料</h5>
<div class="reference-content" v-html="courseReference"></div>
</div>
<!-- 学时安排 -->
<div v-if="courseArrangement" class="course-arrangement">
<h5>学时安排</h5>
<div class="arrangement-content" v-html="courseArrangement"></div>
</div>
<!-- 常见问题 -->
<div v-if="courseQuestion" class="course-question">
<h5>常见问题</h5>
<div class="question-content" v-html="courseQuestion"></div>
</div>
<!-- 课程介绍图片 -->
<div v-if="courseIntroImage && !isAvatarImage" class="course-intro-image-container">
<img :src="courseIntroImage" alt="课程介绍图片" class="course-intro-image">
</div>
<!-- 如果没有内容显示空状态 -->
<div v-if="!hasContent && !courseIntroImage" class="no-content">
<div class="empty-state">
<div class="empty-icon">📚</div>
<p class="empty-text">暂无课程介绍内容</p>
<p class="empty-desc">课程介绍信息正在完善中...</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
// props
const props = defineProps<{
courseInfo?: {
description?: string
outline?: string
target?: string
prerequisite?: string
reference?: string
arrangement?: string
question?: string
introImage?: string
cover?: string
thumbnail?: string
}
}>()
//
const courseDescription = computed(() => props.courseInfo?.description)
const courseOutline = computed(() => props.courseInfo?.outline)
const courseTarget = computed(() => props.courseInfo?.target)
const coursePrerequisite = computed(() => props.courseInfo?.prerequisite)
const courseReference = computed(() => props.courseInfo?.reference)
const courseArrangement = computed(() => props.courseInfo?.arrangement)
const courseQuestion = computed(() => props.courseInfo?.question)
// 使 introImage cover thumbnail
const courseIntroImage = computed(() => {
return props.courseInfo?.introImage || props.courseInfo?.cover || props.courseInfo?.thumbnail
})
// URL
const isAvatarImage = computed(() => {
const imageUrl = courseIntroImage.value
if (!imageUrl) return false
// URL
const avatarKeywords = ['avatar', 'head', 'portrait', 'user', 'profile', 'PixPin']
return avatarKeywords.some(keyword => imageUrl.toLowerCase().includes(keyword.toLowerCase()))
})
//
const hasContent = computed(() => {
return courseDescription.value ||
courseOutline.value ||
courseTarget.value ||
coursePrerequisite.value ||
courseReference.value ||
courseArrangement.value ||
courseQuestion.value
})
</script>
<style scoped>
.intro-content h4 {
font-size: 18px;
font-weight: 500;
@ -17,6 +120,71 @@
margin: 0 0 12px 0;
}
.intro-content h5 {
font-size: 16px;
font-weight: 500;
color: #333;
margin: 20px 0 12px 0;
padding: 8px 12px;
background-color: #F1F3F4;
}
.course-description,
.outline-content,
.target-content,
.prerequisite-content,
.reference-content,
.arrangement-content,
.question-content {
line-height: 1.6;
color: #555;
margin-bottom: 16px;
padding: 12px 0;
}
.course-description {
font-size: 15px;
padding: 16px;
background-color: #f8f8f8;
margin-bottom: 20px;
}
.course-intro-image-container {
margin-top: 20px;
padding: 16px;
background-color: #fafafa;
}
.no-content {
text-align: center;
padding: 60px 20px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.6;
}
.empty-text {
font-size: 16px;
color: #666;
margin: 0 0 8px 0;
font-weight: 500;
}
.empty-desc {
font-size: 14px;
color: #999;
margin: 0;
}
.course-intro-image {
width: 100%;

View File

@ -2,25 +2,11 @@
<div class="team-content">
<h4>教学团队</h4>
<div class="speaker-container">
<div class="speaker">
<img src="/images/special/avatar1.png" alt="讲师1">
<div v-for="instructor in props.instructors" :key="instructor.id" class="speaker">
<img :src="instructor.avatar || '/images/special/avatar1.png'" :alt="instructor.name" @error="handleImageError">
<div>
<div class="speaker-name">汪波</div>
<div class="speaker-title">教授</div>
</div>
</div>
<div class="speaker">
<img src="/images/special/avatar1.png" alt="讲师2">
<div>
<div class="speaker-name">汪波</div>
<div class="speaker-title">教授</div>
</div>
</div>
<div class="speaker">
<img src="/images/special/avatar1.png" alt="讲师3">
<div>
<div class="speaker-name">汪波</div>
<div class="speaker-title">教授</div>
<div class="speaker-name">{{ instructor.name }}</div>
<div class="speaker-title">{{ instructor.title || '教师' }}</div>
</div>
</div>
</div>
@ -28,7 +14,16 @@
</template>
<script setup lang="ts">
//
// props
const props = defineProps<{
instructors?: any[]
}>()
//
const handleImageError = (event: Event) => {
const img = event.target as HTMLImageElement
img.src = '/images/special/avatar1.png'
}
</script>
<style scoped>

View File

@ -335,7 +335,7 @@
<!-- 操作按钮 -->
<div class="actions-section">
<button class="action-btn danger-btn">清空聊天记录</button>
<button class="action-btn danger-btn">退出班级群</button>
<button class="action-btn danger-btn" @click="handleExitGroup">退出班级群</button>
</div>
</div>
@ -435,7 +435,7 @@
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { NIcon, NBadge, useMessage } from 'naive-ui'
import { NIcon, NBadge, useMessage, useDialog } from 'naive-ui'
import {
PeopleOutline,
ChatbubbleEllipsesOutline
@ -509,6 +509,7 @@ const activeContactId = ref<string | null>(null)
const messagesContainer = ref<HTMLElement>()
const messageInputRef = ref()
const message = useMessage()
const dialog = useDialog()
const userStore = useUserStore()
//
@ -1119,6 +1120,46 @@ const handleCurrentUserNotDisturb = async () => {
}
}
// 退
const handleExitGroup = async () => {
if (!activeContactId.value) return
// 使
dialog.warning({
title: '退出群聊',
content: '确定要退出这个群聊吗?退出后将无法接收群聊消息。',
positiveText: '确定退出',
negativeText: '取消',
onPositiveClick: async () => {
try {
console.log('🚪 退出群聊:', { chatId: activeContactId.value, groupName: activeContact.value?.name })
// 退API
await ChatApi.exitChat(activeContactId.value!)
message.success('已成功退出群聊')
//
const contactIndex = contacts.value.findIndex((c: Contact) => c.id === activeContactId.value)
if (contactIndex !== -1) {
contacts.value.splice(contactIndex, 1)
}
//
activeContactId.value = null
messages.value = []
showDetailsPanel.value = false
console.log('✅ 退出群聊成功,已从联系人列表中移除')
} catch (error) {
console.error('❌ 退出群聊失败:', error)
message.error('退出群聊失败,请重试')
}
}
})
}
//
const updateContactLastMessage = (chatId: string, lastMessage: Message) => {
const contact = contacts.value.find((c: Contact) => c.id === chatId)
@ -1357,6 +1398,11 @@ const currentMessages = computed(() => {
//
const selectContact = async (contactId: string) => {
//
if (showDetailsPanel.value) {
showDetailsPanel.value = false
}
activeContactId.value = contactId
//
@ -1907,8 +1953,8 @@ onMounted(() => {
}
.contact-avatar img {
width: 70px;
height: 70px;
width: 50px;
height: 50px;
border-radius: 50%;
object-fit: cover;
}