Browse Source

feat(课程页面): 添加拖拽排序功能并优化组件结构

- 为课程页面列表和内容组件添加拖拽排序功能
- 使用vuedraggable实现页面和组件的拖拽排序
- 优化页面和组件结构,增加拖拽手柄和样式
- 移除手动排序字段,改为自动计算排序值
- 为视频组件添加封面图片上传功能
- 优化移动端预览样式
master
前端-胡立永 1 month ago
parent
commit
9d56afb373
1 changed files with 355 additions and 90 deletions
  1. +355
    -90
      jeecgboot-vue3/src/views/applet/course-page/AppletCoursePageList.vue

+ 355
- 90
jeecgboot-vue3/src/views/applet/course-page/AppletCoursePageList.vue View File

@ -24,33 +24,42 @@
<!-- 上方课程页面列表横向滑动 -->
<div class="page-list-container">
<div class="page-list-scroll">
<div class="page-list">
<div v-for="(page, index) in pageList" :key="page.id"
:class="['page-item', { active: currentPageId === page.id }]" @click="selectPage(page)">
<!-- <div class="page-thumbnail">
<Icon :icon="getPageTypeIcon(page.type)" class="page-icon" />
</div> -->
<div class="page-info">
<div class="page-title">{{ page.title || `${index + 1}` }}</div>
<div class="page-meta">
<span class="page-type">{{ getPageTypeName(page.type) }}</span>
<!-- <span class="page-sort">排序: {{ page.sort || 0 }}</span> -->
<draggable
v-model="pageList"
class="page-list"
item-key="id"
handle=".drag-handle"
:animation="200"
@start="onPageDragStart"
@end="onPageDragEnd"
>
<template #item="{ element: page, index }">
<div :class="['page-item', { active: currentPageId === page.id }]" @click="selectPage(page)">
<!-- 拖拽手柄 -->
<div class="drag-handle">
<Icon icon="ant-design:drag-outlined" />
</div>
<div class="page-info">
<div class="page-title">{{ page.title || `${index + 1}` }}</div>
<div class="page-meta">
<span class="page-type">{{ getPageTypeName(page.type) }}</span>
</div>
</div>
<div class="page-actions">
<a-button size="small" type="text" @click.stop="editPage(page)">
<Icon icon="ant-design:edit-outlined" />
</a-button>
<a-button size="small" type="text" danger @click.stop="deletePage(page)">
<Icon icon="ant-design:delete-outlined" />
</a-button>
</div>
</div>
<div class="page-actions">
<a-button size="small" type="text" @click.stop="editPage(page)">
<Icon icon="ant-design:edit-outlined" />
</a-button>
<a-button size="small" type="text" danger @click.stop="deletePage(page)">
<Icon icon="ant-design:delete-outlined" />
</a-button>
</div>
</div>
<!-- 空状态 -->
<div v-if="pageList.length === 0" class="empty-page-list">
<Icon icon="ant-design:file-add-outlined" class="empty-icon" />
<p>暂无页面点击右上角"新增页面"开始创建</p>
</div>
</template>
</draggable>
<!-- 空状态 -->
<div v-if="pageList.length === 0" class="empty-page-list">
<Icon icon="ant-design:file-add-outlined" class="empty-icon" />
<p>暂无页面点击右上角"新增页面"开始创建</p>
</div>
</div>
</div>
@ -85,65 +94,98 @@
<!-- 内容组件列表 -->
<div class="content-components">
<div v-for="(component, index) in contentComponents" :key="index" class="content-component">
<!-- 文本组件 -->
<div v-if="component.type === 'text'" class="text-component">
<div class="component-header">
<Icon icon="ant-design:font-size-outlined" />
<span>文本内容</span>
<a-button size="small" type="text" danger @click="removeComponent(index)">
<Icon icon="ant-design:delete-outlined" />
</a-button>
</div>
<!-- 语言选择 -->
<div class="language-selector">
<a-radio-group v-model:value="component.language" size="small">
<a-radio value="zh">中文</a-radio>
<a-radio value="en">英文</a-radio>
</a-radio-group>
</div>
<a-textarea v-model:value="component.content" :rows="4" placeholder="请输入文本内容" />
</div>
<draggable
v-model="contentComponents"
item-key="id"
handle=".drag-handle"
animation="200"
class="draggable-list"
@start="onDragStart"
@end="onDragEnd"
>
<template #item="{ element: component, index }">
<div class="content-component">
<!-- 文本组件 -->
<div v-if="component.type === 'text'" class="text-component">
<div class="component-header">
<div class="drag-handle">
<Icon icon="ant-design:drag-outlined" />
</div>
<Icon icon="ant-design:font-size-outlined" />
<span>文本内容</span>
<a-button size="small" type="text" danger @click="removeComponent(index)">
<Icon icon="ant-design:delete-outlined" />
</a-button>
</div>
<!-- 语言选择 -->
<div class="language-selector">
<a-radio-group v-model:value="component.language" size="small">
<a-radio value="zh">中文</a-radio>
<a-radio value="en">英文</a-radio>
</a-radio-group>
</div>
<a-textarea v-model:value="component.content" :rows="4" placeholder="请输入文本内容" />
</div>
<!-- 图片组件 -->
<div v-else-if="component.type === 'image'" class="image-component">
<div class="component-header">
<Icon icon="ant-design:picture-outlined" />
<span>图片内容</span>
<a-button size="small" type="text" danger @click="removeComponent(index)">
<Icon icon="ant-design:delete-outlined" />
</a-button>
</div>
<JImageUpload
v-model:value="component.imageUrl"
:fileMax="1"
listType="picture-card"
text="上传图片"
bizPath="course"
:accept="['image/*']"
@change="handleImageChange($event, component)"
/>
<a-input v-model:value="component.alt" placeholder="图片描述(可选)" style="margin-top: 8px;" />
</div>
<!-- 图片组件 -->
<div v-else-if="component.type === 'image'" class="image-component">
<div class="component-header">
<div class="drag-handle">
<Icon icon="ant-design:drag-outlined" />
</div>
<Icon icon="ant-design:picture-outlined" />
<span>图片内容</span>
<a-button size="small" type="text" danger @click="removeComponent(index)">
<Icon icon="ant-design:delete-outlined" />
</a-button>
</div>
<JImageUpload
v-model:value="component.imageUrl"
:fileMax="1"
listType="picture-card"
text="上传图片"
bizPath="course"
:accept="['image/*']"
@change="handleImageChange($event, component)"
/>
<a-input v-model:value="component.alt" placeholder="图片描述(可选)" style="margin-top: 8px;" />
</div>
<!-- 视频组件 -->
<div v-else-if="component.type === 'video'" class="video-component">
<div class="component-header">
<Icon icon="ant-design:video-camera-outlined" />
<span>视频内容</span>
<a-button size="small" type="text" danger @click="removeComponent(index)">
<Icon icon="ant-design:delete-outlined" />
</a-button>
<!-- 视频组件 -->
<div v-else-if="component.type === 'video'" class="video-component">
<div class="component-header">
<div class="drag-handle">
<Icon icon="ant-design:drag-outlined" />
</div>
<Icon icon="ant-design:video-camera-outlined" />
<span>视频内容</span>
<a-button size="small" type="text" danger @click="removeComponent(index)">
<Icon icon="ant-design:delete-outlined" />
</a-button>
</div>
<JUpload
v-model:value="component.url"
bizPath="course"
text="上传视频"
@change="handleVideoChange($event, component)"
style="margin-top: 8px;"
/>
<div style="margin-top: 12px;">
<div style="margin-bottom: 8px; font-size: 14px; color: #666;">视频封面</div>
<JImageUpload
v-model:value="component.coverUrl"
:fileMax="1"
listType="picture-card"
text="上传封面"
bizPath="course"
:accept="['image/*']"
@change="handleVideoCoverChange($event, component)"
/>
</div>
</div>
</div>
<JUpload
v-model:value="component.url"
bizPath="course"
text="上传视频"
@change="handleVideoChange($event, component)"
style="margin-top: 8px;"
/>
</div>
</div>
</template>
</draggable>
<!-- 空状态 -->
<div v-if="contentComponents.length === 0" class="empty-content">
@ -171,9 +213,6 @@
<a-form-item label="页面标题" name="title">
<a-input v-model:value="currentPage.title" placeholder="请输入页面标题" />
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="currentPage.sort" :min="0" placeholder="排序号" style="width: 100%" />
</a-form-item>
<a-form-item label="页面类型" name="type">
<a-select v-model:value="currentPage.type" placeholder="选择类型">
<a-select-option
@ -225,8 +264,16 @@
</div>
<!-- 视频预览 -->
<div v-else-if="component.type === 'video'" class="video-preview">
<video v-if="component.url" :src="component.url" controls></video>
<div v-else class="placeholder-video">视频预览</div>
<div v-if="component.url" class="video-container">
<video :src="component.url" :poster="component.coverUrl" controls></video>
</div>
<div v-else class="placeholder-video">
<div v-if="component.coverUrl" class="video-cover-preview">
<img :src="component.coverUrl" alt="视频封面" />
<div class="play-icon"></div>
</div>
<div v-else>视频预览</div>
</div>
</div>
</div>
</div>
@ -249,6 +296,7 @@ import { Icon } from '/@/components/Icon';
import { JImageUpload, JUpload } from '/@/components/Form';
import { saveOrUpdate, getById, list, deleteOne, add, edit } from './AppletCoursePage.api';
import { initDictOptions } from '/@/utils/dict/JDictSelectUtil';
import draggable from 'vuedraggable';
const route = useRoute();
const router = useRouter();
@ -439,7 +487,7 @@ async function handleAddPage() {
courseId: currentPage.courseId,
title: `新页面 ${pageList.value.length + 1}`,
type: '0',
sort: pageList.value.length,
sort: pageList.value.length + 1, //
pay: 'N',
status: 'N',
content: '[]' //
@ -454,6 +502,9 @@ async function handleAddPage() {
//
pageList.value.push(newPage);
//
updatePageSortOrder();
//
Object.assign(currentPage, newPage);
contentComponents.value = [];
@ -485,6 +536,8 @@ async function deletePage(page: any) {
const index = pageList.value.findIndex(p => p === page);
if (index > -1) {
pageList.value.splice(index, 1);
//
updatePageSortOrder();
//
if (currentPageId.value === page.id || currentPageId.value === '') {
if (pageList.value.length > 0) {
@ -521,6 +574,53 @@ async function deletePage(page: any) {
}
}
/**
* 拖拽开始事件
*/
function onPageDragStart(evt: any) {
console.log('开始拖拽页面:', evt);
}
/**
* 拖拽结束事件
*/
function onPageDragEnd(evt: any) {
console.log('拖拽结束:', evt);
//
updatePageSortOrder();
//
savePageOrder();
createMessage.success('页面排序已更新');
}
/**
* 更新页面排序值
*/
function updatePageSortOrder() {
pageList.value.forEach((page, index) => {
page.sort = index + 1;
});
}
/**
* 保存页面排序
*/
async function savePageOrder() {
try {
//
const updatePromises = pageList.value.map(page => {
if (page.id) {
return edit({ id: page.id, sort: page.sort });
}
}).filter(Boolean);
await Promise.all(updatePromises);
} catch (error) {
console.error('保存页面排序失败:', error);
createMessage.error('保存排序失败');
}
}
/**
* 保存页面数据
*/
@ -580,6 +680,7 @@ function refreshPreview() {
*/
function addTextContent() {
contentComponents.value.push({
id: Date.now() + Math.random(), // ID
type: 'text',
content: '',
language: 'zh' //
@ -591,6 +692,7 @@ function addTextContent() {
*/
function addImageContent() {
contentComponents.value.push({
id: Date.now() + Math.random(), // ID
type: 'image',
imageUrl: '',
alt: ''
@ -602,8 +704,10 @@ function addImageContent() {
*/
function addVideoContent() {
contentComponents.value.push({
id: Date.now() + Math.random(), // ID
type: 'video',
url: ''
url: '',
coverUrl: ''
});
}
@ -629,6 +733,30 @@ function handleVideoChange(value: string, component: any) {
component.url = value;
}
/**
* 视频封面上传变化处理
*/
function handleVideoCoverChange(value: string, component: any) {
component.coverUrl = value;
}
/**
* 拖拽开始事件
*/
function onDragStart(evt: any) {
console.log('拖拽开始:', evt);
}
/**
* 拖拽结束事件
*/
function onDragEnd(evt: any) {
console.log('拖拽结束:', evt);
// Vue Draggable v-model
//
createMessage.success('组件顺序已更新');
}
/**
* 获取页面类型图标
*/
@ -746,9 +874,51 @@ function getPageTypeName(type: string) {
height: 6px;
}
/* 拖拽手柄样式 */
.drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-right: 8px;
cursor: grab;
color: #8c8c8c;
border-radius: 2px;
transition: all 0.2s;
}
.drag-handle:hover {
color: #1890ff;
background: #f0f0f0;
}
.drag-handle:active {
cursor: grabbing;
}
/* 拖拽状态样式 */
.sortable-ghost {
opacity: 0.5;
background: #f0f7ff;
border: 2px dashed #1890ff;
}
.sortable-chosen {
transform: scale(1.02);
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.3);
}
.sortable-drag {
opacity: 0.8;
transform: rotate(2deg);
}
.page-item {
min-width: 140px;
width: 140px;
display: flex;
align-items: center;
min-width: 160px;
width: 160px;
padding: 8px;
background: #fff;
border: 1px solid #d9d9d9;
@ -1002,6 +1172,34 @@ function getPageTypeName(type: string) {
border-radius: 6px;
}
.mobile-screen .video-cover-preview {
position: relative;
display: inline-block;
width: 100%;
}
.mobile-screen .video-cover-preview img {
width: 100%;
height: auto;
border-radius: 6px;
}
.mobile-screen .video-cover-preview .play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
}
.settings-panel,
.preview-panel {
display: flex;
@ -1084,6 +1282,73 @@ function getPageTypeName(type: string) {
flex: 1;
}
/* 拖拽手柄样式 */
.drag-handle {
cursor: move;
padding: 4px;
color: #8c8c8c;
transition: color 0.3s;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
}
.drag-handle:hover {
color: #1890ff;
background-color: #f0f8ff;
}
/* 拖拽列表样式 */
.draggable-list {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 拖拽时的样式 */
.sortable-ghost {
opacity: 0.5;
background-color: #f5f5f5;
border: 2px dashed #d9d9d9;
}
.sortable-chosen {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
border: 2px solid #1890ff;
background-color: #f0f8ff;
}
.sortable-drag {
opacity: 0.8;
transform: rotate(5deg);
}
/* 组件头部样式增强 */
.component-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: #fafafa;
border-radius: 6px 6px 0 0;
border-bottom: 1px solid #f0f0f0;
transition: all 0.3s;
}
.content-component:hover .component-header {
background-color: #f0f8ff;
}
.content-component {
transition: all 0.3s;
border-radius: 6px;
overflow: hidden;
}
/* 语言选择器样式 */
.language-selector {
margin-bottom: 12px;


Loading…
Cancel
Save