938 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="chapter-management">
<!-- 顶部操作栏 -->
<div class="toolbar">
<h2>全部章节</h2>
<div class="toolbar-actions">
<n-space>
<n-button type="primary" @click="addChapter">添加章节</n-button>
<n-button @click="importChapters">导入</n-button>
<n-button @click="exportChapters">导出</n-button>
<!-- <n-button type="error" :disabled="selectedChapters.length === 0" @click="deleteSelected">删除</n-button> -->
<div class="search-container">
<n-input v-model:value="searchKeyword" placeholder="请输入想要搜索的内容" style="width: 200px;">
</n-input>
<n-button type="primary" @click="searchChapters">搜索</n-button>
</div>
</n-space>
</div>
</div>
<!-- 章节列表表格 -->
<div class="table-box">
<n-data-table :columns="columns" :data="paginatedChapters" :row-key="rowKey" :checked-row-keys="selectedChapters"
@update:checked-row-keys="handleCheck" :bordered="false" :single-line="false" size="medium"
class="chapter-data-table" :row-class-name="rowClassName" scroll-x="true" :loading="loading" />
<!-- 自定义分页器 -->
<div class="custom-pagination">
<div class="pagination-content">
<div class="page-numbers">
<span class="page-number nav-button" :class="{ disabled: currentPage === 1 }" @click="goToPage('first')">
首页
</span>
<span class="page-number nav-button" :class="{ disabled: currentPage === 1 }" @click="goToPage('prev')">
上一页
</span>
<span v-for="page in visiblePages" :key="page" class="page-number page-number-bordered"
:class="{ active: page === currentPage }" @click="goToPage(page)">
{{ page }}
</span>
<span v-if="showRightEllipsis" class="page-number">...</span>
<span v-if="totalPages > 1" class="page-number page-number-bordered" @click="goToPage(totalPages)">
{{ totalPages }}
</span>
<span class="page-number nav-button" :class="{ disabled: currentPage === totalPages }"
@click="goToPage('next')">
下一页
</span>
<span class="page-number nav-button" :class="{ disabled: currentPage === totalPages }"
@click="goToPage('last')">
尾页
</span>
</div>
</div>
</div>
</div>
<ImportModal v-model:show="showImportModal" template-name="custom_template.xlsx" import-type="custom"
@success="handleImportSuccess" @template-download="handleTemplateDownload" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue'
import { NButton, useMessage, NDataTable, NInput, NSpace, NIcon, useDialog } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { useRouter, useRoute } from 'vue-router'
import { ChevronForwardOutline } from '@vicons/ionicons5'
import ImportModal from '@/components/common/ImportModal.vue'
import TeachCourseApi from '@/api/modules/teachCourse'
const router = useRouter()
const route = useRoute()
const dialog = useDialog()
// 获取当前课程ID
const courseId = ref(route.params.id as string)
const message = useMessage()
// 章节类型定义
interface Chapter {
id: string
name: string
type: string
sort: string | number
createTime?: string
isParent: boolean
children?: Chapter[]
expanded?: boolean
level?: number
parentId?: string
sortOrder?: number | null // 添加sortOrder字段用于排序
}
const showImportModal = ref(false)
// 加载状态
const loading = ref(false)
// const error = ref('')
const handleImportSuccess = () => {
message.success('章节导入成功')
// 重新加载章节列表
fetchCourseChapters()
}
const handleTemplateDownload = () => {
message.success('模板下载成功')
}
// 搜索关键词
const searchKeyword = ref('')
// 选中的章节
const selectedChapters = ref<string[]>([])
// 章节列表数据
const chapterList = ref<Chapter[]>([])
// 章节排序输入值(用于双向绑定)
const chapterSortValues = ref<Record<string, string>>({})
// 扁平化章节列表(用于显示和分页)
const flattenedChapters = computed(() => {
const result: Chapter[] = []
// 分离章节level=1和节level=2过滤掉level=0的项目
const chapters = chapterList.value.filter(item => item.level === 1)
const sections = chapterList.value.filter(item => item.level === 2)
// 对章节按sortOrder排序null值排在最后
const sortedChapters = chapters.sort((a, b) => {
if (a.sortOrder === null && b.sortOrder === null) return 0
if (a.sortOrder === null) return 1
if (b.sortOrder === null) return -1
return (a.sortOrder || 0) - (b.sortOrder || 0)
})
// 为每个章节添加其子节
sortedChapters.forEach(chapter => {
const chapterSections = sections.filter(section => section.parentId === chapter.id)
// 对子节按sortOrder排序null值排在最后
const sortedSections = chapterSections.sort((a, b) => {
if (a.sortOrder === null && b.sortOrder === null) return 0
if (a.sortOrder === null) return 1
if (b.sortOrder === null) return -1
return (a.sortOrder || 0) - (b.sortOrder || 0)
})
chapter.children = sortedSections
result.push(chapter)
// 如果章节展开,添加其子节
if (chapter.expanded && chapter.children) {
chapter.children.forEach(section => {
result.push(section)
})
}
})
return result
})
// 过滤后的章节列表
const filteredChapters = computed(() => {
if (!searchKeyword.value) {
return flattenedChapters.value
}
return flattenedChapters.value.filter((chapter: Chapter) =>
chapter.name.toLowerCase().includes(searchKeyword.value.toLowerCase())
)
})
// 分页相关状态
const currentPage = ref(1)
const pageSize = ref(10)
const totalPages = computed(() => Math.ceil(filteredChapters.value.length / pageSize.value))
// 可见的页码范围
const visiblePages = computed(() => {
const pages = []
const current = currentPage.value
const total = totalPages.value
if (total <= 7) {
// 如果总页数小于等于7显示所有页码
for (let i = 1; i <= total; i++) {
pages.push(i)
}
} else {
// 显示当前页附近的页码
const start = Math.max(1, current - 2)
const end = Math.min(total, current + 2)
for (let i = start; i <= end; i++) {
pages.push(i)
}
}
return pages
})
// 是否显示右侧省略号
const showRightEllipsis = computed(() => {
return currentPage.value < totalPages.value - 3
})
// 分页后的数据
const paginatedChapters = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredChapters.value.slice(start, end)
})
// 表格行键
const rowKey = (row: Chapter) => row.id
// 表格选择处理
const handleCheck = (rowKeys: string[]) => {
selectedChapters.value = rowKeys
}
// 行样式名称
const rowClassName = () => {
return 'chapter-table-row'
}
// 分页方法
const goToPage = (page: string | number) => {
if (typeof page === 'string') {
switch (page) {
case 'first':
currentPage.value = 1
break
case 'prev':
if (currentPage.value > 1) currentPage.value--
break
case 'next':
if (currentPage.value < totalPages.value) currentPage.value++
break
case 'last':
currentPage.value = totalPages.value
break
}
} else {
currentPage.value = page
}
}
// 展开/收起章节
const toggleChapter = (chapter: Chapter) => {
if (chapter.level === 1 && chapter.children) {
chapter.expanded = !chapter.expanded
}
}
// 章节操作方法
const addChapter = () => {
// 跳转到当前课程下的章节编辑器传递mode=add参数表示新增模式
router.push(`/teacher/chapter-editor-teacher/${courseId.value}?mode=add`)
}
const importChapters = () => {
showImportModal.value = true
}
const exportChapters = () => {
message.info('导出章节功能')
}
// const deleteSelected = async () => {
// if (selectedChapters.value.length === 0) return
// // 删除逻辑
// message.info('删除选中章节')
// }
const searchChapters = async () => {
// 搜索逻辑
message.info('搜索章节')
}
const editChapter = async (chapter: Chapter) => {
console.log('编辑章节:', chapter)
const courseId = route.params.id as string
if (!courseId) {
message.error('课程ID不存在')
return
}
try {
// 获取该章节下的所有小节数据
const sectionsResponse = await TeachCourseApi.getCourseSections(courseId)
const allSections = sectionsResponse.data.result || []
// 筛选出属于当前章节的小节
const chapterSections = allSections.filter((section: any) =>
section.level === 2 && section.parentId === chapter.id
)
// 构建完整的章节数据结构
const editChapterData = {
id: chapter.id,
name: chapter.name,
type: chapter.type,
level: chapter.level,
parentId: chapter.parentId,
sortOrder: chapter.sortOrder, // 添加章节的sortOrder
sections: chapterSections.map((section: any) => ({
id: section.id,
name: section.name,
type: section.type,
sortOrder: section.sortOrder,
parentId: section.parentId,
level: section.level,
createTime: section.createTime
}))
}
console.log('编辑章节完整数据:', editChapterData)
// 通过路由state传递数据而不是URL参数
router.push({
path: `/teacher/chapter-editor-teacher/${courseId}`,
query: { mode: 'edit' },
state: { editChapterData }
})
} catch (error) {
console.error('获取章节数据失败:', error)
message.error('获取章节数据失败,请重试')
}
}
const deleteChapter = async (chapter: Chapter) => {
const isChapterLevel = chapter.level === 1; // 是否为章节level=1
const chapterName = chapter.name;
// 如果是章节,需要检查是否有下属小节
let confirmMessage = `确定要删除"${chapterName}"吗?`;
if (isChapterLevel) {
// 查找该章节下的所有小节
const childSections = chapterList.value.filter(item =>
item.level === 2 && item.parentId === chapter.id
);
if (childSections.length > 0) {
confirmMessage = `确定要删除章节"${chapterName}"吗?\n\n删除章节将同时删除其下的 ${childSections.length} 个小节:\n${childSections.map(s => s.name).join('、')}`;
}
}
// 二次确认弹窗
dialog.warning({
title: '删除确认',
content: confirmMessage,
positiveText: '确定删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
loading.value = true;
if (isChapterLevel) {
// 如果是章节,需要先删除其下的所有小节,再删除章节本身
const childSections = chapterList.value.filter(item =>
item.level === 2 && item.parentId === chapter.id
);
// 先删除所有子小节
for (const section of childSections) {
console.log('删除小节:', section.name, 'ID:', section.id);
await TeachCourseApi.deleteCourseSection(section.id);
}
// 再删除章节本身
console.log('删除章节:', chapter.name, 'ID:', chapter.id);
await TeachCourseApi.deleteCourseSection(chapter.id);
message.success(`章节"${chapterName}"及其下属小节删除成功`);
} else {
// 如果是小节,直接删除
console.log('删除小节:', chapter.name, 'ID:', chapter.id);
await TeachCourseApi.deleteCourseSection(chapter.id);
message.success(`小节"${chapterName}"删除成功`);
}
// 重新加载章节列表
await fetchCourseChapters();
} catch (error: any) {
console.error('删除失败:', error);
message.error(`删除失败:${error.message || '未知错误'}`);
} finally {
loading.value = false;
}
},
onNegativeClick: () => {
message.info('已取消删除');
}
});
}
// 更新章节排序
const updateChapterSort = async (chapter: Chapter, newSortOrder: number | null) => {
console.log('更新章节排序:', chapter.name, '新排序:', newSortOrder)
if (chapter.sortOrder === newSortOrder) {
// 排序没有变化,不需要更新
return
}
try {
// 构建更新数据
const updateData = {
id: chapter.id,
courseId: courseId.value,
name: chapter.name,
type: chapter.type === '-' ? null : parseInt(chapter.type) || null,
sortOrder: newSortOrder,
parentId: chapter.parentId || null,
level: chapter.level || 1
}
console.log('发送章节排序更新请求:', updateData)
// 调用编辑接口更新排序
const response = await TeachCourseApi.editCourseSection(updateData)
if (response.data && (response.data.success === true || response.data.code === 200 || response.data.code === 0)) {
// 更新成功,更新本地数据
chapter.sortOrder = newSortOrder
chapterSortValues.value[chapter.id] = newSortOrder?.toString() || ''
message.success(`章节 "${chapter.name}" 排序更新成功`)
// 重新加载数据以确保排序正确
fetchCourseChapters()
} else {
console.error('章节排序更新失败:', response.data)
message.error('章节排序更新失败:' + (response.data?.message || '未知错误'))
}
} catch (error: any) {
console.error('更新章节排序失败:', error)
message.error('更新章节排序失败:' + (error.message || '网络错误'))
}
}
// 表格列配置 - 使用 minWidth 实现响应式
const columns: DataTableColumns<Chapter> = [
{
type: 'selection',
minWidth: 50
},
{
title: '章节名称',
key: 'name',
width: 400,
minWidth: 400,
ellipsis: {
tooltip: true
},
render: (row: Chapter) => {
const isChapter = row.level === 1; // level=1 表示章
return h('div', {
style: {
display: 'flex',
alignItems: 'center',
cursor: (isChapter && row.children && row.children.length > 0) ? 'pointer' : 'default',
marginLeft: isChapter ? '0px' : '-3px'
},
onClick: (isChapter && row.children && row.children.length > 0) ? () => toggleChapter(row) : undefined
}, [
// 只有章节且有子节时才显示箭头
(isChapter && row.children && row.children.length > 0) ? h(NIcon, {
size: 14,
style: {
marginRight: '8px',
color: '#999',
transition: 'transform 0.2s',
transform: row.expanded ? 'rotate(90deg)' : 'rotate(0deg)',
cursor: 'pointer'
}
}, {
default: () => h(ChevronForwardOutline)
}) : (isChapter ? h('span', { style: { marginRight: '22px' } }) : null),
h('span', {
style: { color: '#062333', fontSize: '13px' }
}, row.name)
])
}
},
{
title: '类型',
key: 'type',
minWidth: 60,
render: (row: Chapter) => {
const isChapter = row.level === 1; // level=1 表示章
if (isChapter || row.type === '-') {
return h('span', { style: { color: '#BABABA' } }, '-')
}
return h('div', {
style: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#062333',
fontSize: '12px'
}
}, row.type)
}
},
{
title: '排序',
key: 'sort',
minWidth: 100,
render: (row: Chapter) => {
const isChapter = row.level === 1; // level=1 表示章
if (isChapter) {
// 章节显示可编辑的排序输入框
return h('div', {
style: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}
}, [
h(NInput, {
value: chapterSortValues.value[row.id] ?? (row.sortOrder?.toString() || ''),
size: 'small',
style: { width: '60px', textAlign: 'center' },
placeholder: '排序',
'onUpdate:value': (value: string) => {
// 实时更新输入框的值
chapterSortValues.value[row.id] = value;
},
onBlur: () => {
const inputValue = chapterSortValues.value[row.id];
const newSortOrder = inputValue ? parseInt(inputValue) || null : null;
updateChapterSort(row, newSortOrder);
},
onKeydown: (e: KeyboardEvent) => {
if (e.key === 'Enter') {
const inputValue = chapterSortValues.value[row.id];
const newSortOrder = inputValue ? parseInt(inputValue) || null : null;
updateChapterSort(row, newSortOrder);
(e.target as HTMLInputElement).blur(); // 失去焦点
}
}
})
])
} else {
// 小节显示固定的排序值
return h('span', { style: { color: '#062333', fontSize: '12px' } }, row.sort)
}
}
},
{
title: '创建时间',
key: 'createTime',
minWidth: 180,
render: (row: Chapter) => {
return h('span', { style: { color: '#062333', fontSize: '12px' } }, row.createTime)
}
},
{
title: '操作',
key: 'actions',
minWidth: 160,
render: (row: Chapter) => {
const isChapter = row.level === 1; // level=1 表示章
if (isChapter) {
// 章节显示编辑和删除按钮
return h('div', { style: { display: 'flex', gap: '16px', alignItems: 'center', justifyContent: 'center' } }, [
h(NButton, {
size: 'small',
type: 'info',
secondary: true,
onClick: () => editChapter(row)
}, { default: () => '编辑' }),
h(NButton, {
size: 'small',
type: 'error',
secondary: true,
onClick: () => deleteChapter(row)
}, { default: () => '删除' })
])
} else {
// 小节只显示删除按钮
return h('div', { style: { display: 'flex', gap: '16px', alignItems: 'center', justifyContent: 'center' } }, [
h(NButton, {
size: 'small',
type: 'error',
secondary: true,
onClick: () => deleteChapter(row)
}, { default: () => '删除' })
])
}
}
}
]
const fetchCourseChapters = () => {
loading.value = true
TeachCourseApi.getCourseSections(courseId.value).then(res => {
console.log('章节数据:', res.data)
// 将API返回的CourseSection数据映射为本地Chapter格式
const sections = res.data.result || []
chapterList.value = sections.map((section: any): Chapter => ({
id: section.id || '0',
name: section.name || '',
type: section.type?.toString() || '-',
sort: section.sortOrder?.toString() || '-', // 根据API数据使用sortOrder
createTime: section.createTime || '', // 根据API数据使用createTime
isParent: (section.level || 0) === 1,
expanded: false,
level: section.level || 0, // 添加level字段
parentId: section.parentId || '', // 根据API数据使用parentId
sortOrder: section.sortOrder || null, // 添加sortOrder字段用于排序功能
children: []
}))
console.log('处理后的章节数据:', chapterList.value)
// 初始化章节排序输入值
chapterSortValues.value = {}
chapterList.value.forEach(chapter => {
if (chapter.level === 1) {
chapterSortValues.value[chapter.id] = chapter.sortOrder?.toString() || ''
}
})
}).catch(error => {
console.error('获取章节数据失败:', error)
message.error('获取章节数据失败')
}).finally(() => {
loading.value = false
})
}
onMounted(() => {
fetchCourseChapters()
})
</script>
<style scoped>
.chapter-management {
width: 100%;
background: #fff;
overflow: auto;
height: 100%;
display: flex;
flex-direction: column;
}
/* 顶部工具栏 */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-right: 25px;
background: #fff;
padding: 30px 0 20px 30px;
border-bottom: 2px solid #F6F6F6;
}
.toolbar h2 {
margin: 0;
font-size: 18px;
color: #333;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.btn {
padding: 7px 16px;
border: none;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
border-radius: 2px;
}
.btn-primary {
background: #0288D1;
color: white;
}
.btn-primary:hover {
background: #40a9ff;
}
.btn-new {
background-color: #fff;
border: 1px solid #0288D1;
color: #0288D1;
}
.btn-default {
background: white;
color: #999999;
border: 1px solid #999999;
}
.btn-default:hover {
border-color: #1890ff;
color: #1890ff;
}
.btn-danger {
background: white;
color: #FF4D4F;
border: 1px solid #FF4D4F;
}
.search-box {
display: flex;
align-items: center;
border: 1px solid #F1F3F4;
border-radius: 2px;
overflow: hidden;
}
.search-box input {
border: none;
padding: 6px 12px;
outline: none;
width: 200px;
font-size: 14px;
}
.btn-search {
background: #0288D1;
color: white;
border: none;
padding: 6px 16px;
cursor: pointer;
font-size: 16px;
}
.btn-search:hover {
background: #0277bd;
}
.table-box {
height: 100%;
position: relative;
display: flex;
flex-direction: column;
}
/* Naive UI 表格样式定制 */
:deep(.chapter-data-table) {
background: #fff;
padding: 40px;
height: 100%;
min-height: 500px;
position: relative;
}
/* 表格头部样式 */
:deep(.chapter-data-table .n-data-table-thead) {
background: #f8f9fa;
}
:deep(.chapter-data-table .n-data-table-th) {
background: #f8f9fa !important;
color: #062333 !important;
font-weight: 600;
font-size: 13px;
border-bottom: 1px solid #e9ecef;
text-align: center;
}
/* 表格行样式 */
:deep(.chapter-data-table .n-data-table-td) {
font-size: 13px;
color: #062333;
border-bottom: 1px solid #f0f0f0;
padding: 12px 8px;
vertical-align: middle;
text-align: center;
}
/* 所有列居中对齐 */
:deep(.chapter-data-table .n-data-table-td) {
text-align: center !important;
}
:deep(.chapter-data-table .n-data-table-th) {
text-align: center !important;
}
:deep(.chapter-data-table .n-data-table-tr:hover) {
background: #fafafa;
}
/* 复选框样式 */
:deep(.chapter-data-table .n-checkbox) {
--n-size: 16px;
}
/* 隐藏Naive UI默认的展开触发器 */
:deep(.chapter-data-table .n-data-table-expand-trigger) {
display: none !important;
}
/* 隐藏Naive UI默认的展开占位符 */
:deep(.chapter-data-table .n-data-table-expand-placeholder) {
display: none !important;
}
/* 按钮组样式调整 */
:deep(.chapter-data-table .n-button) {
font-size: 12px;
height: 28px;
padding: 0 12px;
margin: 2px;
}
/* 操作按钮样式 - 只有边框和文字颜色,无背景色 */
:deep(.chapter-data-table .n-button--info-type.n-button--secondary) {
background-color: transparent !important;
border: 1px solid #0288D1;
color: #0288D1;
}
:deep(.chapter-data-table .n-button--info-type.n-button--secondary:hover) {
background-color: rgba(32, 128, 240, 0.05) !important;
border: 1px solid #0288D1;
color: #248DD3;
}
:deep(.chapter-data-table .n-button--error-type.n-button--secondary) {
background-color: transparent !important;
border: 1px solid #FF4D4F;
color: #FD8485;
}
:deep(.chapter-data-table .n-button--error-type.n-button--secondary:hover) {
background-color: rgba(208, 48, 80, 0.05) !important;
border: 1px solid #FF4D4F;
color: #FD8485;
}
/* 表格行样式 */
:deep(.chapter-table-row) {
transition: background-color 0.2s;
}
:deep(.chapter-table-row:hover) {
background-color: #f8f9fa;
}
/* 自定义分页器样式 */
.custom-pagination {
display: flex;
justify-content: center;
background: #fff;
padding: 20px 0;
margin-top: auto;
width: 100%;
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
.pagination-content {
display: flex;
justify-content: center;
align-items: center;
margin: 0 auto;
padding: 0 10px;
}
.page-numbers {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
white-space: nowrap;
}
.page-number {
display: inline-block;
min-width: 38px;
height: 38px;
line-height: 38px;
text-align: center;
color: #333;
text-decoration: none;
font-size: 14px;
padding: 0 5px;
margin: 0 4px;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s ease;
}
.page-number-bordered {
border: 1px solid #d9d9d9;
}
.page-number.active {
background-color: #0088D1;
color: white;
border-color: #0088D1;
}
.page-number:hover:not(.disabled) {
border-color: #0088D1;
}
.page-number.disabled {
color: #ccc;
cursor: not-allowed;
}
.page-number.disabled:hover {
color: #ccc;
border-color: #d9d9d9;
}
.nav-button {
padding: 0 8px;
border: none;
}
.nav-button:hover:not(.disabled) {
color: #0088D1;
}
</style>