feat: 课程详细页面:课程介绍,课程介绍,教学团队,章节目录,评论接入接口; 课程内容,字幕列表接入接口
This commit is contained in:
parent
6b3846ea50
commit
82ae528785
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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 // 新增学期字段
|
||||
}
|
||||
|
||||
// 后端课程列表响应格式
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
|
||||
// 从URL获取courseId
|
||||
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 })))
|
||||
|
||||
// 根据数据结构,看起来章节是通过parentId来区分的,而不是level
|
||||
// 一级章节应该是没有parentId或者parentId为空的
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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%;
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user