feat: 修复班级管理报错;添加课程内容,字幕列表页面;即时消息接入部分接口且完善接口所需样式

This commit is contained in:
QDKF 2025-09-22 21:12:48 +08:00
parent 62143affd5
commit b2ec1e2015
8 changed files with 1244 additions and 43 deletions

View File

@ -201,5 +201,37 @@ export const ChatApi = {
*/
getChatDetail: (chatId: string): Promise<ApiResponse<any>> => {
return ApiRequest.get(`/aiol/aiolChat/${chatId}/detail`)
},
/**
*
* POST /aiol/aiolChat/{chatId}/show_label
*/
showLabel: (chatId: string): Promise<ApiResponse<any>> => {
return ApiRequest.post(`/aiol/aiolChat/${chatId}/show_label`)
},
/**
*
* POST /aiol/aiolChat/{chatId}/hide_label
*/
hideLabel: (chatId: string): Promise<ApiResponse<any>> => {
return ApiRequest.post(`/aiol/aiolChat/${chatId}/hide_label`)
},
/**
*
* POST /aiol/aiolChat/{chatId}/mute_member/{userId}
*/
muteMember: (chatId: string, userId: string): Promise<ApiResponse<any>> => {
return ApiRequest.post(`/aiol/aiolChat/${chatId}/mute_member/${userId}`)
},
/**
*
* POST /aiol/aiolChat/{chatId}/unmute_member/{userId}
*/
unmuteMember: (chatId: string, userId: string): Promise<ApiResponse<any>> => {
return ApiRequest.post(`/aiol/aiolChat/${chatId}/unmute_member/${userId}`)
}
}

View File

@ -81,7 +81,34 @@
<n-data-table :columns="columns" :data="paginatedData" :pagination="pagination" :loading="loading"
:row-key="(row: StudentItem) => row.id" v-model:checked-row-keys="selectedRowKeys" striped bordered
size="small" />
size="small">
<template #empty>
<div class="custom-empty">
<n-empty v-if="!selectedDepartment && !props.classId" description="请先选择班级查看学员信息">
<template #icon>
<n-icon size="48" color="#ccc">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2C13.1 2 14 2.9 14 4C14 5.1 13.1 6 12 6C10.9 6 10 5.1 10 4C10 2.9 10.9 2 12 2ZM21 9V7L15 6.5V7.5C15 8.3 14.3 9 13.5 9H10.5C9.7 9 9 8.3 9 7.5V6.5L3 7V9L9 8.5V9.5C9 10.3 9.7 11 10.5 11H13.5C14.3 11 15 10.3 15 9.5V8.5L21 9ZM6 13.5C6 12.7 6.7 12 7.5 12H16.5C17.3 12 18 12.7 18 13.5V20.5C18 21.3 17.3 22 16.5 22H7.5C6.7 22 6 21.3 6 20.5V13.5Z"
fill="currentColor" />
</svg>
</n-icon>
</template>
</n-empty>
<n-empty v-else description="暂无学员数据">
<template #icon>
<n-icon size="48" color="#ccc">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2C13.1 2 14 2.9 14 4C14 5.1 13.1 6 12 6C10.9 6 10 5.1 10 4C10 2.9 10.9 2 12 2ZM21 9V7L15 6.5V7.5C15 8.3 14.3 9 13.5 9H10.5C9.7 9 9 8.3 9 7.5V6.5L3 7V9L9 8.5V9.5C9 10.3 9.7 11 10.5 11H13.5C14.3 11 15 10.3 15 9.5V8.5L21 9ZM6 13.5C6 12.7 6.7 12 7.5 12H16.5C17.3 12 18 12.7 18 13.5V20.5C18 21.3 17.3 22 16.5 22H7.5C6.7 22 6 21.3 6 20.5V13.5Z"
fill="currentColor" />
</svg>
</n-icon>
</template>
</n-empty>
</div>
</template>
</n-data-table>
<!-- 添加班级弹窗 -->
<n-modal v-model:show="showAddClassModal" :title="isRenameMode ? '重命名' : '添加班级'">
@ -472,7 +499,7 @@ const paginatedData = computed(() => {
//
const departmentOptions = computed(() => [
{ label: '默认班级', value: '' },
{ label: '请选择班级', value: '' },
...masterClassList.value.map(item => ({
label: item.className,
value: item.id
@ -1239,8 +1266,9 @@ const loadData = async (classId?: string | number | null) => {
loading.value = true
try {
if (classId === null || classId === undefined) {
if (classId === null || classId === undefined || classId === '' || classId === 'undefined') {
//
console.log('📋 未选择班级,显示空数据')
data.value = []
totalStudents.value = 0
} else {
@ -1367,7 +1395,16 @@ onMounted(async () => {
// 使使classId使
const initialClassId = props.classId ? props.classId : selectedDepartment.value
loadData(initialClassId)
// classId
if (initialClassId && initialClassId !== '' && initialClassId !== 'undefined') {
loadData(initialClassId)
} else {
// classId
data.value = []
totalStudents.value = 0
loading.value = false
}
// id id
if (route.path.includes('/teacher/course-editor')) {
@ -1402,7 +1439,7 @@ defineExpose({
font-size: 14px;
}
.student-title{
.student-title {
font-size: 16px;
font-weight: bold;
color: #333;
@ -1503,7 +1540,7 @@ defineExpose({
.invite-code-display {
text-align: center;
}
.invite-title {
@ -1712,4 +1749,14 @@ defineExpose({
padding-bottom: 2px;
margin-bottom: 2px;
}
/* 自定义空状态样式 */
.custom-empty {
padding: 40px 20px;
text-align: center;
}
.custom-empty .n-empty {
margin: 0;
}
</style>

View File

@ -0,0 +1,304 @@
<template>
<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-button>
<n-input v-model:value="searchKeyword" placeholder="请输入关键词" style="width: 200px" />
<n-button type="primary" @click="handleSearch">
搜索
</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"
/>
</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-item label="时间戳" path="timestamp">
<n-input v-model:value="summaryForm.timestamp" placeholder="例如: 00:23" />
</n-form-item>
<n-form-item label="标题" path="title">
<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-form-item>
</n-form>
<template #footer>
<div class="modal-footer">
<n-button @click="showAddSummaryModal = false">取消</n-button>
<n-button type="primary" @click="handleAddSummary">确定</n-button>
</div>
</template>
</n-card>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue'
import {
NSelect,
NButton,
NModal,
NCard,
NForm,
NFormItem,
NInput,
NDataTable,
NSpace,
useMessage,
type FormInst,
type FormRules,
type DataTableColumns
} from 'naive-ui'
const message = useMessage()
//
const selectedCourse = ref('')
const showAddSummaryModal = ref(false)
const searchKeyword = ref('')
const loading = ref(false)
//
const summaryFormRef = ref<FormInst | null>(null)
//
const pagination = ref({
page: 1,
pageSize: 10,
showSizePicker: true,
pageSizes: [10, 20, 50],
onChange: (page: number) => {
pagination.value.page = page
loadData()
},
onUpdatePageSize: (pageSize: number) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
loadData()
}
})
//
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 summaryForm = ref({
timestamp: '',
title: '',
description: ''
})
//
const summaryRules: FormRules = {
timestamp: [
{ required: true, message: '请输入时间戳', trigger: 'blur' }
],
title: [
{ required: true, message: '请输入标题', trigger: 'blur' }
],
description: [
{ required: true, message: '请输入内容', trigger: 'blur' }
]
}
//
const summaryColumns: DataTableColumns<any> = [
{
title: '时间戳',
key: 'timestamp',
width: 100,
align: 'center',
render: (row: any) => {
return h('span', {
style: {
background: '#1890ff',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}
}, row.timestamp)
}
},
{
title: '标题',
key: 'title',
width: 300,
ellipsis: {
tooltip: true
}
},
{
title: '内容',
key: 'description'
},
{
title: '操作',
key: 'actions',
width: 120,
align: 'center',
render: (row: any, index: number) => {
return h(NSpace, {
size: 'small',
justify: 'center'
}, {
default: () => [
h(NButton, {
size: 'small',
type: 'primary',
ghost: true,
onClick: () => editSummary(row)
}, { default: () => '编辑' }),
h(NButton, {
size: 'small',
type: 'error',
ghost: true,
onClick: () => deleteSummary(index)
}, { default: () => '删除' })
]
})
}
}
]
//
const filteredSummaryList = computed(() => {
if (!searchKeyword.value) {
return summaryList.value
}
return summaryList.value.filter((item: any) =>
item.title.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
item.description.toLowerCase().includes(searchKeyword.value.toLowerCase())
)
})
//
const handleCourseChange = (courseId: string) => {
console.log('切换课程:', courseId)
loadData()
}
const loadData = async () => {
loading.value = true
try {
// API
await new Promise(resolve => setTimeout(resolve, 500))
//
} catch (error) {
console.error('加载数据失败:', error)
message.error('加载数据失败,请重试')
} finally {
loading.value = false
}
}
const handleSearch = () => {
//
console.log('搜索关键词:', searchKeyword.value)
}
const editSummary = (item: any) => {
summaryForm.value = { ...item }
showAddSummaryModal.value = true
}
const deleteSummary = (index: number) => {
summaryList.value.splice(index, 1)
message.success('删除成功')
}
const handleAddSummary = async () => {
try {
await summaryFormRef.value?.validate()
summaryList.value.push({ ...summaryForm.value })
showAddSummaryModal.value = false
summaryForm.value = { timestamp: '', title: '', description: '' }
message.success('添加成功')
} catch (error) {
message.error('请检查表单信息')
}
}
onMounted(() => {
//
if (courseOptions.value.length > 0) {
selectedCourse.value = courseOptions.value[0].value
}
//
loadData()
})
</script>
<style scoped>
.course-content-management {
padding: 16px 0;
}
.toolbar {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@ -0,0 +1,304 @@
<template>
<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-button>
<n-input v-model:value="searchKeyword" placeholder="请输入关键词" style="width: 200px" />
<n-button type="primary" @click="handleSearch">
搜索
</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"
/>
</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-item label="开始时间" path="startTime">
<n-input v-model:value="subtitleForm.startTime" placeholder="例如: 00:23" />
</n-form-item>
<n-form-item label="结束时间" path="endTime">
<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-form-item>
</n-form>
<template #footer>
<div class="modal-footer">
<n-button @click="showAddSubtitleModal = false">取消</n-button>
<n-button type="primary" @click="handleAddSubtitle">确定</n-button>
</div>
</template>
</n-card>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue'
import {
NSelect,
NButton,
NModal,
NCard,
NForm,
NFormItem,
NInput,
NDataTable,
NSpace,
useMessage,
type FormInst,
type FormRules,
type DataTableColumns
} from 'naive-ui'
const message = useMessage()
//
const selectedCourse = ref('')
const showAddSubtitleModal = ref(false)
const searchKeyword = ref('')
const loading = ref(false)
//
const subtitleFormRef = ref<FormInst | null>(null)
//
const pagination = ref({
page: 1,
pageSize: 10,
showSizePicker: true,
pageSizes: [10, 20, 50],
onChange: (page: number) => {
pagination.value.page = page
loadData()
},
onUpdatePageSize: (pageSize: number) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
loadData()
}
})
//
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 subtitleForm = ref({
startTime: '',
endTime: '',
text: ''
})
//
const subtitleRules: FormRules = {
startTime: [
{ required: true, message: '请输入开始时间', trigger: 'blur' }
],
endTime: [
{ required: true, message: '请输入结束时间', trigger: 'blur' }
],
text: [
{ required: true, message: '请输入字幕内容', trigger: 'blur' }
]
}
//
const subtitleColumns: DataTableColumns<any> = [
{
title: '时间范围',
key: 'timeRange',
width: 150,
align: 'center',
render: (row: any) => {
return h('span', {
style: {
background: '#1890ff',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}
}, `${row.startTime} - ${row.endTime}`)
}
},
{
title: '字幕内容',
key: 'text'
},
{
title: '操作',
key: 'actions',
width: 120,
align: 'center',
render: (row: any, index: number) => {
return h(NSpace, {
size: 'small',
justify: 'center'
}, {
default: () => [
h(NButton, {
size: 'small',
type: 'primary',
ghost: true,
onClick: () => editSubtitle(row)
}, { default: () => '编辑' }),
h(NButton, {
size: 'small',
type: 'error',
ghost: true,
onClick: () => deleteSubtitle(index)
}, { default: () => '删除' })
]
})
}
}
]
//
const filteredSubtitlesList = computed(() => {
if (!searchKeyword.value) {
return subtitlesList.value
}
return subtitlesList.value.filter((item: any) =>
item.text.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
item.startTime.includes(searchKeyword.value) ||
item.endTime.includes(searchKeyword.value)
)
})
//
const handleCourseChange = (courseId: string) => {
console.log('切换课程:', courseId)
loadData()
}
const loadData = async () => {
loading.value = true
try {
// API
await new Promise(resolve => setTimeout(resolve, 500))
//
} catch (error) {
console.error('加载数据失败:', error)
message.error('加载数据失败,请重试')
} finally {
loading.value = false
}
}
const handleSearch = () => {
//
console.log('搜索关键词:', searchKeyword.value)
}
const editSubtitle = (item: any) => {
subtitleForm.value = { ...item }
showAddSubtitleModal.value = true
}
const deleteSubtitle = (index: number) => {
subtitlesList.value.splice(index, 1)
message.success('删除成功')
}
const handleAddSubtitle = async () => {
try {
await subtitleFormRef.value?.validate()
subtitlesList.value.push({ ...subtitleForm.value })
showAddSubtitleModal.value = false
subtitleForm.value = { startTime: '', endTime: '', text: '' }
message.success('添加成功')
} catch (error) {
message.error('请检查表单信息')
}
}
onMounted(() => {
//
if (courseOptions.value.length > 0) {
selectedCourse.value = courseOptions.value[0].value
}
//
loadData()
})
</script>
<style scoped>
.subtitle-management {
padding: 16px 0;
}
.toolbar {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@ -191,7 +191,7 @@ const activeSubNavItem = ref(''); // 子菜单激活状态
const examMenuExpanded = ref(false); //
const studentMenuExpanded = ref(false); //
const orchestrationMenuExpanded = ref(false); //
const showTopImage = ref(true); // /
const showTopImage = ref(false); // /
//
const hideTopImageRoutes = [
@ -800,6 +800,9 @@ onMounted(() => {
//
updateActiveNavItem();
// 广
showTopImage.value = false;
// CSS
if (showTopImage.value) {
document.documentElement.style.setProperty('--top-height', '130px');
@ -825,7 +828,8 @@ const updateTopImageVisibility = () => {
currentPath.includes(routePath)
);
showTopImage.value = !shouldHideTopImage;
// 广
showTopImage.value = false; //
console.log('顶部图片显示状态:', showTopImage.value);
};

View File

@ -173,7 +173,7 @@ const route = useRoute()
const courseId = ref(route.params.id as string)
//
const showTopImage = ref(true) // /
const showTopImage = ref(false) // /
//
const courseInfo = ref({

View File

@ -10,6 +10,12 @@
<n-tab-pane name="log" tab="操作日志">
<OperationLog />
</n-tab-pane>
<n-tab-pane name="content" tab="课程内容">
<CourseContentManagement />
</n-tab-pane>
<n-tab-pane name="subtitles" tab="字幕列表">
<SubtitleManagement />
</n-tab-pane>
</n-tabs>
</div>
</template>
@ -20,6 +26,8 @@ import { NTabs, NTabPane } from 'naive-ui'
import ClassManagement from '@/components/teacher/ClassManagement.vue'
import TeamManagement from '@/components/teacher/TeamManagement.vue'
import OperationLog from '@/components/teacher/OperationLog.vue'
import CourseContentManagement from '@/components/teacher/CourseContentManagement.vue'
import SubtitleManagement from '@/components/teacher/SubtitleManagement.vue'
// tab
const activeTab = ref('class')

View File

@ -132,7 +132,10 @@
</div>
<div class="message-content">
<div v-if="!message.isOwn" class="message-sender">{{ message.senderName }}</div>
<div v-if="!message.isOwn" class="message-sender">
{{ message.senderName }}
<span v-if="shouldShowTeacherLabel(message.senderId)" class="teacher-label">讲师</span>
</div>
<!-- 文本消息 -->
<div v-if="message.type === 'text'" class="message-bubble">
@ -207,11 +210,14 @@
<div class="members-section">
<!-- 有搜索结果时显示成员列表 -->
<div v-if="displayedMembers.length > 0" class="members-grid">
<div v-for="member in displayedMembers" :key="member.id" class="member-item">
<div v-for="member in displayedMembers" :key="member.id" class="member-item"
@click="openMemberModal(member)">
<div class="member-avatar">
<img :src="member.avatar || '/images/profile/default-avatar.png'" :alt="member.realname" />
</div>
<div class="member-name">{{ member.realname }}</div>
<div class="member-info">
<div class="member-name">{{ member.realname }}</div>
</div>
</div>
</div>
@ -268,8 +274,8 @@
<div class="setting-item">
<span class="setting-label">发言加讲师标签</span>
<div class="switch-wrapper">
<input type="checkbox" id="addTeacherTag" v-model="groupSettings.addTeacherTag"
class="switch-input">
<input type="checkbox" id="addTeacherTag" v-model="showTeacherLabel"
@change="handleTeacherLabelToggle" class="switch-input">
<label for="addTeacherTag" class="switch-label"></label>
</div>
</div>
@ -333,6 +339,39 @@
</div>
</div>
</div>
<!-- 成员操作弹框 -->
<div v-if="showMemberModal" class="modal-overlay" @click="closeMemberModal">
<div class="member-modal" @click.stop>
<div class="modal-header">
<h3>成员操作</h3>
<button class="close-btn" @click="closeMemberModal">×</button>
</div>
<div class="modal-content">
<div class="member-profile">
<div class="member-avatar-large">
<img :src="selectedMember?.avatar || '/images/profile/default-avatar.png'"
:alt="selectedMember?.realname" />
</div>
<div class="member-details">
<h4>{{ selectedMember?.realname }}</h4>
<p v-if="selectedMember?.isTeacher" class="member-role">讲师</p>
<p v-else class="member-role">学员</p>
</div>
</div>
<div class="modal-actions">
<button v-if="!selectedMember?.isTeacher" @click="handleMuteMember" class="modal-btn mute-btn"
:disabled="isMuteLoading">
{{ selectedMember?.isMuted ? '解除禁言' : '禁言' }}
</button>
<button v-if="!selectedMember?.isTeacher" @click="handleRemoveMember" class="modal-btn remove-btn"
:disabled="isMuteLoading">
移除群聊
</button>
</div>
</div>
</div>
</div>
</div>
</template>
@ -346,6 +385,7 @@ import {
import MessageInput from './MessageInput.vue'
import { ChatApi } from '@/api'
import { useUserStore } from '@/stores/user'
import { TeachCourseApi } from '@/api/modules/teachCourse'
// API
interface Contact {
@ -359,6 +399,7 @@ interface Contact {
isOnline?: boolean
memberCount?: number
izAllMuted?: boolean | number // 1/0
showLabel?: boolean | number // 1/0
}
//
@ -387,6 +428,7 @@ interface Message {
type: 'text' | 'image' | 'file'
content: string
senderName: string
senderId: string // ID
avatar: string
time: string
isOwn: boolean
@ -416,6 +458,9 @@ const groupSettings = ref({
addTeacherTag: false
})
//
const showTeacherLabel = ref(false)
//
const privateSettings = ref({
messageMute: false,
@ -453,6 +498,14 @@ const maxDisplayMembers = 20 // 5行 × 4列 = 20个成员
const memberSearchKeyword = ref('')
const isSearchingMembers = ref(false)
//
const teacherUserIds = ref<string[]>([])
const currentChatShowLabel = ref(0) // showLabel
//
const showMemberModal = ref(false) //
const selectedMember = ref<any>(null) //
//
const filteredMembers = computed(() => {
if (!memberSearchKeyword.value.trim()) {
@ -484,14 +537,46 @@ const shouldShowViewMore = computed(() => {
//
onMounted(() => {
loadContacts()
loadTeacherList()
})
// ID
const loadTeacherList = async () => {
try {
const response = await TeachCourseApi.getTeacherList()
if (response.data && response.data.result) {
teacherUserIds.value = response.data.result.map((teacher: any) => teacher.id || teacher.userId)
}
} catch (error) {
console.error('❌ 获取教师列表失败:', error)
//
teacherUserIds.value = ['1000000000', '1000000001']
}
}
//
const isTeacher = (senderId: string) => {
return teacherUserIds.value.includes(senderId)
}
//
const shouldShowTeacherLabel = (senderId: string) => {
// 使showTeacherLabel
const showLabel = showTeacherLabel.value
const isTeacherUser = isTeacher(senderId)
const shouldShow = showLabel && isTeacherUser
return shouldShow
}
//
const loadContacts = async () => {
loading.value = true
try {
const response = await ChatApi.getMyChats()
if (response.data && response.data.success) {
// API
contacts.value = response.data.result.map((chat: any) => {
// API0=1=
@ -507,10 +592,12 @@ const loadContacts = async () => {
unreadCount: chat.unreadCount || 0,
isOnline: chat.isOnline,
memberCount: chat.memberCount || (contactType === 'group' ? 0 : undefined),
izAllMuted: chat.izAllMuted === 1
izAllMuted: chat.izAllMuted === 1,
showLabel: chat.showLabel === 1
}
})
// memberCount
for (const contact of contacts.value) {
if (contact.type === 'group' && (!contact.memberCount || contact.memberCount === 0)) {
@ -553,7 +640,6 @@ const loadGroupMembers = async (chatId: string) => {
if (response.data && response.data.success) {
groupMembers.value = response.data.result || []
console.log('群成员加载成功:', groupMembers.value.length, '个成员')
//
const contact = contacts.value.find((c: Contact) => c.id === chatId)
@ -566,7 +652,6 @@ const loadGroupMembers = async (chatId: string) => {
}
} else {
console.log('API响应失败:', response.data)
}
} catch (error) {
console.error('获取群成员失败:', error)
@ -592,6 +677,8 @@ const loadChatDetail = async (chatId: string) => {
if (response.data && response.data.success) {
const chatDetail = response.data.result
//
//
if (chatDetail.izAllMuted !== undefined) {
isAllMuted.value = chatDetail.izAllMuted === 1
@ -603,6 +690,19 @@ const loadChatDetail = async (chatId: string) => {
}
}
//
if (chatDetail.showLabel !== undefined) {
showTeacherLabel.value = chatDetail.showLabel === 1
currentChatShowLabel.value = chatDetail.showLabel
} else {
//
const contact = contacts.value.find((c: Contact) => c.id === chatId)
if (contact && contact.showLabel !== undefined) {
showTeacherLabel.value = contact.showLabel === 1 || contact.showLabel === true
currentChatShowLabel.value = contact.showLabel === 1 || contact.showLabel === true ? 1 : 0
}
}
return chatDetail
}
} catch (error) {
@ -652,20 +752,130 @@ const handleMuteAllToggle = async () => {
}
}
//
const handleTeacherLabelToggle = async () => {
if (!activeContactId.value) return
try {
if (showTeacherLabel.value) {
//
await ChatApi.showLabel(activeContactId.value)
message.success('已开启教师标签显示')
} else {
//
await ChatApi.hideLabel(activeContactId.value)
message.success('已关闭教师标签显示')
}
//
const contact = contacts.value.find((c: Contact) => c.id === activeContactId.value)
if (contact) {
contact.showLabel = showTeacherLabel.value ? 1 : 0
}
} catch (error) {
console.error('❌ 切换教师标签显示状态失败:', error)
message.error('操作失败,请重试')
//
showTeacherLabel.value = !showTeacherLabel.value
}
}
//
const openMemberModal = (member: any) => {
selectedMember.value = member
showMemberModal.value = true
}
//
const closeMemberModal = () => {
showMemberModal.value = false
selectedMember.value = null
}
// /
const handleMuteMember = async () => {
if (!activeContactId.value || !selectedMember.value) return
isMuteLoading.value = true
try {
const member = selectedMember.value
const isCurrentlyMuted = member.isMuted || false
if (isCurrentlyMuted) {
//
console.log('🔓 解除禁言:', { chatId: activeContactId.value, userId: member.id, memberName: member.realname })
await ChatApi.unmuteMember(activeContactId.value, member.id)
message.success(`已解除 ${member.realname} 的禁言`)
//
member.isMuted = false
} else {
//
console.log('🔒 禁言用户:', { chatId: activeContactId.value, userId: member.id, memberName: member.realname })
await ChatApi.muteMember(activeContactId.value, member.id)
message.success(`已禁言 ${member.realname}`)
//
member.isMuted = true
}
//
closeMemberModal()
} catch (error) {
console.error('❌ 禁言操作失败:', error)
message.error('操作失败,请重试')
} finally {
isMuteLoading.value = false
}
}
//
const handleRemoveMember = async () => {
if (!activeContactId.value || !selectedMember.value) return
isMuteLoading.value = true
try {
const member = selectedMember.value
// await ChatApi.removeMember(activeContactId.value, member.id)
message.success(`已移除 ${member.realname}`)
//
const index = groupMembers.value.findIndex((m: any) => m.id === member.id)
if (index > -1) {
groupMembers.value.splice(index, 1)
}
//
closeMemberModal()
} catch (error) {
console.error('❌ 移除成员操作失败:', error)
message.error('操作失败,请重试')
} finally {
isMuteLoading.value = false
}
}
//
const loadMessages = async (chatId: string) => {
messagesLoading.value = true
try {
console.log('🚀 开始获取会话消息chatId:', chatId)
const response = await ChatApi.getChatMessages(chatId)
console.log('✅ 会话消息API响应:', response)
//
if (teacherUserIds.value.length === 0) {
await loadTeacherList()
}
const response = await ChatApi.getChatMessages(chatId)
if (response.data && response.data.success) {
console.log('📝 消息数据:', response.data.result)
console.log('📝 消息列表数据:', response.data.result)
// API
messages.value = response.data.result.map((msg: any): Message => {
console.log('🔍 处理消息:', msg)
console.log('🔍 消息类型:', msg.messageType, '发送者信息:', msg.senderInfo)
// messageType0=1=2=
let messageType = 'text'
@ -681,6 +891,7 @@ const loadMessages = async (chatId: string) => {
type: messageType as 'text' | 'image' | 'file',
content: msg.content,
senderName: msg.senderInfo?.realname || '未知用户',
senderId: msg.senderInfo?.id || msg.senderId || '', // ID
avatar: msg.senderInfo?.avatar || '',
time: formatTime(msg.createTime),
isOwn: false, // TODO: ID
@ -690,7 +901,100 @@ const loadMessages = async (chatId: string) => {
fileUrl: msg.fileUrl
}
})
console.log('✅ 转换后的消息列表:', messages.value)
//
const mockMessages: Message[] = [
{
id: 'mock_1_' + Date.now(),
contactId: chatId,
type: 'text',
content: '大家好,欢迎来到我们的学习群!',
senderName: '李老师',
senderId: teacherUserIds.value[0] || '1956303241317928961',
avatar: 'https://avatars.githubusercontent.com/u/38358644',
time: formatTime(new Date(Date.now() - 1000 * 60 * 30).toISOString()),
isOwn: false,
isRead: true
},
{
id: 'mock_2_' + Date.now(),
contactId: chatId,
type: 'text',
content: '今天我们来学习Vue.js的基础知识',
senderName: '王同学',
senderId: 'student_001',
avatar: '',
time: formatTime(new Date(Date.now() - 1000 * 60 * 25).toISOString()),
isOwn: false,
isRead: true
},
{
id: 'mock_3_' + Date.now(),
contactId: chatId,
type: 'text',
content: '老师Vue的响应式原理是什么',
senderName: '赵同学',
senderId: 'student_002',
avatar: '',
time: formatTime(new Date(Date.now() - 1000 * 60 * 20).toISOString()),
isOwn: false,
isRead: true
},
{
id: 'mock_4_' + Date.now(),
contactId: chatId,
type: 'text',
content: 'Vue的响应式是通过Object.defineProperty或Proxy实现的',
senderName: '张老师',
senderId: teacherUserIds.value[0] || '1956303241317928961',
avatar: 'https://avatars.githubusercontent.com/u/38358644',
time: formatTime(new Date(Date.now() - 1000 * 60 * 15).toISOString()),
isOwn: false,
isRead: true
},
{
id: 'mock_5_' + Date.now(),
contactId: chatId,
type: 'text',
content: '谢谢老师!',
senderName: '赵同学',
senderId: 'student_002',
avatar: '',
time: formatTime(new Date(Date.now() - 1000 * 60 * 10).toISOString()),
isOwn: false,
isRead: true
},
{
id: 'mock_6_' + Date.now(),
contactId: chatId,
type: 'text',
content: '不客气,有问题随时提问',
senderName: '张老师',
senderId: teacherUserIds.value[0] || '1956303241317928961',
avatar: 'https://avatars.githubusercontent.com/u/38358644',
time: formatTime(new Date(Date.now() - 1000 * 60 * 5).toISOString()),
isOwn: false,
isRead: true
},
{
id: 'mock_7_' + Date.now(),
contactId: chatId,
type: 'text',
content: '大家好,我是老师,这是最新的测试消息',
senderName: '张老师',
senderId: teacherUserIds.value[0] || '1956303241317928961',
avatar: 'https://avatars.githubusercontent.com/u/38358644',
time: formatTime(new Date().toISOString()),
isOwn: false,
isRead: true
}
]
//
messages.value.push(...mockMessages)
console.log('✅ 最终消息列表:', messages.value)
} else {
console.warn('⚠️ API返回失败:', response.data)
}
@ -700,6 +1004,17 @@ const loadMessages = async (chatId: string) => {
messages.value = []
} finally {
messagesLoading.value = false
//
nextTick(() => {
scrollToBottom()
})
}
}
//
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
@ -754,6 +1069,9 @@ const selectContact = async (contactId: string) => {
memberCount: 0
}
// 1
currentChatShowLabel.value = 1
//
const contact = contacts.value.find((c: Contact) => c.id === contactId)
if (contact) {
@ -795,6 +1113,7 @@ const handleSendMessage = async (content: string) => {
type: 'text',
content,
senderName: userStore.user?.profile?.realName || userStore.user?.nickname || '我',
senderId: userStore.user?.id?.toString() || '', // ID
avatar: userStore.user?.avatar || '', // 使
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
isOwn: true,
@ -811,17 +1130,17 @@ const handleSendMessage = async (content: string) => {
contact.lastMessageTime = newMessage.time
}
//
nextTick(() => {
scrollToBottom()
})
//
try {
//
await new Promise(resolve => setTimeout(resolve, 300))
//
console.log('模拟发送消息成功:', {
chatId: activeContactId.value,
content,
messageType: 'text'
})
//
setTimeout(() => {
@ -868,6 +1187,7 @@ const simulateReply = (chatId: string) => {
type: 'text',
content: randomReply,
senderName: '对方',
senderId: '2000000000', // ID
avatar: 'https://avatars.githubusercontent.com/u/38358644', // 使
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
isOwn: false,
@ -884,20 +1204,14 @@ const simulateReply = (chatId: string) => {
contact.unreadCount = (contact.unreadCount || 0) + 1
}
//
nextTick(() => {
scrollToBottom()
})
}
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
const previewImage = (src: string) => {
const previewImage = (_src: string) => {
// TODO:
console.log('预览图片:', src)
}
//
@ -914,7 +1228,6 @@ const getFileIcon = (type: string) => {
//
const downloadFile = (message: any) => {
//
console.log('下载文件:', message.fileName)
//
if (message.fileUrl) {
const link = document.createElement('a')
@ -939,7 +1252,6 @@ const closeDetailsPanel = () => {
//
const handleEmoji = () => {
console.log('选择表情')
// TODO:
}
@ -960,6 +1272,7 @@ const handleImage = (imageData: any) => {
fileName: imageData.name || '图片',
fileSize: imageData.size?.toString() || '0',
senderName: userStore.user?.profile?.realName || userStore.user?.nickname || '我',
senderId: userStore.user?.id?.toString() || '', // ID
avatar: userStore.user?.avatar || '',
time: formatTime(new Date().toISOString()),
isOwn: true,
@ -991,6 +1304,7 @@ const handleImage = (imageData: any) => {
type: 'text',
content: '收到图片了!',
senderName: '对方',
senderId: '2000000000', // ID
avatar: 'https://avatars.githubusercontent.com/u/38358644',
time: formatTime(new Date().toISOString()),
isOwn: false,
@ -1033,6 +1347,7 @@ const handleFile = (fileData: any) => {
fileSize: fileData.size.toString(),
fileType: fileData.type,
senderName: userStore.user?.profile?.realName || userStore.user?.nickname || '我',
senderId: userStore.user?.id?.toString() || '', // ID
avatar: userStore.user?.avatar || '',
time: formatTime(new Date().toISOString()),
isOwn: true,
@ -1064,6 +1379,7 @@ const handleFile = (fileData: any) => {
type: 'text',
content: '收到文件了!',
senderName: '对方',
senderId: '2000000000', // ID
avatar: 'https://avatars.githubusercontent.com/u/38358644',
time: formatTime(new Date().toISOString()),
isOwn: false,
@ -1198,8 +1514,8 @@ onMounted(() => {
}
.avatar-placeholder {
width: 70px;
height: 70px;
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
@ -1234,7 +1550,7 @@ onMounted(() => {
.contact-header {
display: flex;
align-items: center;
margin-bottom: 10px;
margin-bottom: 2px;
color: #3F3F44;
}
@ -1455,6 +1771,7 @@ onMounted(() => {
.message-avatar {
margin-right: 8px;
flex-shrink: 0;
position: relative;
}
.message-own .message-avatar {
@ -1500,7 +1817,20 @@ onMounted(() => {
.message-sender {
font-size: 12px;
color: #666;
margin-bottom: 4px;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
.teacher-label {
background: #E2F5FF;
color: #0088D1;
border: 1px solid #0088D1;
padding: 0 5px;
border-radius: 2px;
font-size: 10px;
font-weight: 500;
}
.message-bubble {
@ -1995,6 +2325,7 @@ onMounted(() => {
border-radius: 50%;
animation: spin 1s linear infinite;
}
.actions-section {
margin: 0 20px;
display: flex;
@ -2108,4 +2439,175 @@ onMounted(() => {
max-width: 75%;
}
}
/* 成员弹框样式 - 符合项目风格 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.member-modal {
background: #ffffff;
border-radius: 8px;
width: 380px;
max-width: 90vw;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
animation: modalSlideIn 0.2s ease-out;
border: 1px solid #e6e6e6;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e6e6e6;
background: #fafafa;
border-radius: 8px 8px 0 0;
}
.modal-header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 20px;
color: #999;
cursor: pointer;
padding: 4px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.close-btn:hover {
background: #f0f0f0;
color: #666;
}
.modal-content {
padding: 20px;
}
.member-profile {
display: flex;
align-items: center;
margin-bottom: 20px;
padding: 12px;
background: #f8f9fa;
border-radius: 6px;
border: 1px solid #e6e6e6;
}
.member-avatar-large {
width: 50px;
height: 50px;
border-radius: 50%;
overflow: hidden;
margin-right: 12px;
flex-shrink: 0;
border: 2px solid #e6e6e6;
}
.member-avatar-large img {
width: 100%;
height: 100%;
object-fit: cover;
}
.member-details h4 {
margin: 0 0 6px 0;
font-size: 15px;
font-weight: 500;
color: #333;
}
.member-details .member-role {
margin: 0;
font-size: 12px;
color: #666;
background: #e6e6e6;
padding: 2px 6px;
border-radius: 10px;
display: inline-block;
}
.modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.modal-btn {
padding: 8px 16px;
border: 1px solid #e6e6e6;
border-radius: 4px;
font-size: 13px;
font-weight: 400;
cursor: pointer;
transition: all 0.2s;
min-width: 80px;
background: #ffffff;
}
.modal-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.modal-btn.mute-btn {
background: #ffffff;
color: #ff4d4f;
border-color: #ff4d4f;
}
.modal-btn.mute-btn:hover:not(:disabled) {
background: #ff4d4f;
color: white;
}
.modal-btn.remove-btn {
background: #ffffff;
color: #666;
border-color: #d9d9d9;
}
.modal-btn.remove-btn:hover:not(:disabled) {
background: #f5f5f5;
color: #333;
}
/* 群成员列表样式 - 保持原有样式,只添加点击效果 */
.member-item {
cursor: pointer;
}
</style>