<template>
|
|
<div class="course-page-editor">
|
|
<!-- 顶部导航栏 -->
|
|
<div class="editor-header">
|
|
<div class="header-left">
|
|
<a-button type="text" @click="goBack" class="back-btn">
|
|
<Icon icon="ant-design:arrow-left-outlined" />
|
|
返回课程列表
|
|
</a-button>
|
|
<a-divider type="vertical" />
|
|
</div>
|
|
<div class="header-right">
|
|
<a-button @click="handleAddPage" class="add-btn">
|
|
<Icon icon="ant-design:plus-outlined" />
|
|
新增页面
|
|
</a-button>
|
|
<a-button type="primary" @click="handleSave" :loading="saving">
|
|
<Icon icon="ant-design:save-outlined" />
|
|
保存
|
|
</a-button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 上方:课程页面列表(横向滑动) -->
|
|
<div class="page-list-container">
|
|
<div class="page-list-scroll">
|
|
<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>
|
|
</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>
|
|
|
|
<!-- 下方:主体编辑区域(左右分栏) -->
|
|
<div class="editor-main">
|
|
<!-- 左侧:内容编辑区域 -->
|
|
<div class="editor-left">
|
|
<div class="editor-panel">
|
|
<div class="panel-content">
|
|
<a-form :model="currentPage" layout="vertical" class="page-form">
|
|
|
|
<!-- 内容编辑区域 -->
|
|
<div class="content-editor-section">
|
|
<div class="section-header">
|
|
<span>页面内容</span>
|
|
<a-button-group size="small">
|
|
<a-button @click="addTextContent">
|
|
<Icon icon="ant-design:font-size-outlined" />
|
|
文本
|
|
</a-button>
|
|
<a-button @click="addImageContent">
|
|
<Icon icon="ant-design:picture-outlined" />
|
|
图片
|
|
</a-button>
|
|
<a-button @click="addVideoContent">
|
|
<Icon icon="ant-design:video-camera-outlined" />
|
|
视频
|
|
</a-button>
|
|
</a-button-group>
|
|
</div>
|
|
|
|
<!-- 内容组件列表 -->
|
|
<div class="content-components">
|
|
<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">
|
|
<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">
|
|
<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>
|
|
</template>
|
|
</draggable>
|
|
|
|
<!-- 空状态 -->
|
|
<div v-if="contentComponents.length === 0" class="empty-content">
|
|
<Icon icon="ant-design:plus-circle-outlined" class="empty-icon" />
|
|
<p>点击上方按钮添加内容组件</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</a-form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 右侧:设置和预览区域 -->
|
|
<div class="editor-right">
|
|
<!-- 设置和预览区域(左右布局) -->
|
|
<div class="settings-preview-container">
|
|
<!-- 左侧:页面设置 -->
|
|
<div class="settings-section">
|
|
<div class="panel-header">
|
|
<span>页面设置</span>
|
|
</div>
|
|
<div class="panel-content">
|
|
<a-form :model="currentPage" layout="vertical" class="settings-form">
|
|
<a-form-item label="页面标题" name="title">
|
|
<a-input v-model:value="currentPage.title" placeholder="请输入页面标题" />
|
|
</a-form-item>
|
|
<a-form-item label="页面类型" name="type">
|
|
<a-select v-model:value="currentPage.type" placeholder="选择类型">
|
|
<a-select-option
|
|
v-for="option in pageTypeOptions"
|
|
:key="option.value"
|
|
:value="option.value"
|
|
>
|
|
{{ option.label }}
|
|
</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
<a-form-item label="付费" name="pay">
|
|
<a-switch v-model:checked="payChecked" checked-children="是" un-checked-children="否" />
|
|
</a-form-item>
|
|
<a-form-item label="上架" name="status">
|
|
<a-switch v-model:checked="statusChecked" checked-children="是" un-checked-children="否" />
|
|
</a-form-item>
|
|
</a-form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 右侧:手机预览区域 -->
|
|
<div class="mobile-preview-section">
|
|
<div class="panel-header">
|
|
<span>手机预览<span style="font-size: 12px; color: #999;">(该预览经供参考,请以实际手机为准)</span></span>
|
|
<a-button size="small" @click="refreshPreview">
|
|
<Icon icon="ant-design:reload-outlined" />
|
|
</a-button>
|
|
</div>
|
|
<div class="panel-content">
|
|
<div class="mobile-preview-container">
|
|
<div class="mobile-screen">
|
|
<div class="preview-content">
|
|
<div v-if="contentComponents.length === 0" class="empty-preview">
|
|
<p>暂无内容,请在左侧添加内容组件</p>
|
|
</div>
|
|
<div v-else class="content-preview">
|
|
<div v-for="(component, index) in contentComponents" :key="index" class="preview-component">
|
|
<!-- 文本预览 -->
|
|
<div v-if="component.type === 'text'" class="text-preview" :class="{ 'text-english': component.language === 'en' }">
|
|
<div class="text-language-tag">{{ component.language === 'en' ? 'EN' : '中' }}</div>
|
|
<pre>{{ component.content || '文本内容...' }}</pre>
|
|
</div>
|
|
<!-- 图片预览 -->
|
|
<div v-else-if="component.type === 'image'" class="image-preview">
|
|
<img v-if="component.imageUrl" :src="component.imageUrl" :alt="component.alt" />
|
|
<div v-else class="placeholder-image">图片预览</div>
|
|
<p v-if="component.alt" class="image-caption">{{ component.alt }}</p>
|
|
</div>
|
|
<!-- 视频预览 -->
|
|
<div v-else-if="component.type === 'video'" class="video-preview">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" name="appletCoursePage-appletCoursePage" setup>
|
|
import { ref, reactive, computed, unref, onMounted, watch } from 'vue';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
import { useMessage } from '/@/hooks/web/useMessage';
|
|
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();
|
|
const { createMessage } = useMessage();
|
|
|
|
// 课程页面列表
|
|
const pageList = ref([]);
|
|
const currentPageId = ref('');
|
|
|
|
// 页面类型字典选项
|
|
const pageTypeOptions = ref([]);
|
|
|
|
// 当前编辑的页面数据
|
|
const currentPage = reactive({
|
|
id: '',
|
|
courseId: '',
|
|
title: '',
|
|
type: '0',
|
|
sort: 0,
|
|
pay: 'N',
|
|
status: 'Y',
|
|
});
|
|
|
|
// 内容组件列表
|
|
const contentComponents = ref([]);
|
|
|
|
// 计算属性:处理付费开关的Y/N字符串与布尔值转换
|
|
const payChecked = computed({
|
|
get: () => currentPage.pay === 'Y',
|
|
set: (value: boolean) => {
|
|
currentPage.pay = value ? 'Y' : 'N';
|
|
}
|
|
});
|
|
|
|
// 计算属性:处理上架开关的Y/N字符串与布尔值转换
|
|
const statusChecked = computed({
|
|
get: () => currentPage.status === 'Y',
|
|
set: (value: boolean) => {
|
|
currentPage.status = value ? 'Y' : 'N';
|
|
}
|
|
});
|
|
|
|
// 保存状态
|
|
const saving = ref(false);
|
|
|
|
onMounted(async () => {
|
|
// 获取课程ID
|
|
if (route.query.courseId) {
|
|
currentPage.courseId = route.query.courseId as string;
|
|
}
|
|
|
|
// 加载页面类型字典数据
|
|
await loadPageTypeOptions();
|
|
|
|
// 加载课程页面列表
|
|
await loadPageList();
|
|
|
|
// 如果有页面ID,选择对应页面
|
|
if (route.query.id) {
|
|
currentPageId.value = route.query.id as string;
|
|
await loadPageData(route.query.id as string);
|
|
} else if (pageList.value.length > 0) {
|
|
// 默认选择第一个页面
|
|
selectPage(pageList.value[0]);
|
|
}
|
|
// 移除自动创建页面的逻辑,因为现在新增页面会立即保存到数据库
|
|
});
|
|
|
|
/**
|
|
* 加载页面类型字典数据
|
|
*/
|
|
async function loadPageTypeOptions() {
|
|
try {
|
|
const dictData = await initDictOptions('applet_course_page_type');
|
|
pageTypeOptions.value = dictData || [];
|
|
} catch (error) {
|
|
createMessage.error('加载页面类型字典失败');
|
|
// 如果字典加载失败,使用默认选项
|
|
pageTypeOptions.value = [
|
|
{ label: '纯文本', value: 'text' },
|
|
{ label: '图文混合', value: 'image' },
|
|
{ label: '视频', value: 'video' }
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 加载课程页面列表
|
|
*/
|
|
async function loadPageList() {
|
|
try {
|
|
const params = {
|
|
courseId: currentPage.courseId,
|
|
pageNo: 1,
|
|
pageSize: 9999999
|
|
};
|
|
const result = await list(params);
|
|
const records = result.records || [];
|
|
pageList.value = records
|
|
} catch (error) {
|
|
createMessage.error('加载页面列表失败', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 选择页面
|
|
*/
|
|
function selectPage(page: any) {
|
|
currentPageId.value = page.id;
|
|
|
|
// 加载页面数据
|
|
loadPageData(page.id);
|
|
}
|
|
|
|
/**
|
|
* 加载页面数据
|
|
*/
|
|
async function loadPageData(id: string) {
|
|
if (!id) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await getById({ id });
|
|
Object.assign(currentPage, result);
|
|
// 解析content字段为组件列表
|
|
parseContentToComponents(result.content);
|
|
} catch (error) {
|
|
createMessage.error('加载页面数据失败');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 解析content字段为组件列表
|
|
*/
|
|
function parseContentToComponents(content: string) {
|
|
contentComponents.value = [];
|
|
if (!content) return;
|
|
|
|
try {
|
|
const parsed = JSON.parse(content);
|
|
if (Array.isArray(parsed)) {
|
|
// 确保每个文本组件都有language字段
|
|
contentComponents.value = parsed.map(component => {
|
|
if (component.type === 'text' && !component.language) {
|
|
return { ...component, language: 'zh' }; // 默认中文
|
|
}
|
|
return component;
|
|
});
|
|
} else {
|
|
// 如果是字符串,创建一个文本组件
|
|
contentComponents.value = [{
|
|
type: 'text',
|
|
content: content,
|
|
language: 'zh' // 默认中文
|
|
}];
|
|
}
|
|
} catch (error) {
|
|
// 如果解析失败,作为纯文本处理
|
|
contentComponents.value = [{
|
|
type: 'text',
|
|
content: content,
|
|
language: 'zh' // 默认中文
|
|
}];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 将组件列表转换为content字段
|
|
*/
|
|
function componentsToContent() {
|
|
return JSON.stringify(contentComponents.value);
|
|
}
|
|
|
|
/**
|
|
* 返回课程列表
|
|
*/
|
|
function goBack() {
|
|
router.back();
|
|
}
|
|
|
|
/**
|
|
* 新增页面
|
|
*/
|
|
async function handleAddPage() {
|
|
try {
|
|
const newPage = {
|
|
courseId: currentPage.courseId,
|
|
title: `新页面 ${pageList.value.length + 1}`,
|
|
type: '0',
|
|
sort: pageList.value.length + 1, // 自动设置为最后一个位置
|
|
pay: 'N',
|
|
status: 'N',
|
|
content: '[]' // 默认空内容
|
|
};
|
|
|
|
// 调用API添加到数据库
|
|
const result = await add(newPage);
|
|
if (result.id) {
|
|
// 设置新页面的ID
|
|
newPage.id = result.id;
|
|
|
|
// 将新页面添加到页面列表中
|
|
pageList.value.push(newPage);
|
|
|
|
// 重新设置所有页面的排序值
|
|
updatePageSortOrder();
|
|
|
|
// 设置为当前编辑页面
|
|
Object.assign(currentPage, newPage);
|
|
contentComponents.value = [];
|
|
currentPageId.value = newPage.id;
|
|
|
|
createMessage.success('新增页面成功');
|
|
} else {
|
|
createMessage.error(result.message || '新增页面失败');
|
|
}
|
|
} catch (error) {
|
|
console.error('新增页面失败:', error);
|
|
createMessage.error('新增页面失败');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 编辑页面
|
|
*/
|
|
function editPage(page: any) {
|
|
selectPage(page);
|
|
}
|
|
|
|
/**
|
|
* 删除页面
|
|
*/
|
|
async function deletePage(page: any) {
|
|
if (!page.id) {
|
|
// 如果是新页面(未保存),直接从列表中移除
|
|
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) {
|
|
selectPage(pageList.value[0]);
|
|
} else {
|
|
// 没有页面了,创建新页面
|
|
handleAddPage();
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await deleteOne({ id: page.id }, () => {
|
|
createMessage.success('删除成功');
|
|
// 重新加载页面列表
|
|
loadPageList();
|
|
// 如果删除的是当前页面,选择其他页面
|
|
if (currentPageId.value === page.id) {
|
|
if (pageList.value.length > 1) {
|
|
const index = pageList.value.findIndex(p => p.id === page.id);
|
|
const nextPage = pageList.value[index === 0 ? 1 : index - 1];
|
|
selectPage(nextPage);
|
|
} else {
|
|
// 没有其他页面了,创建新页面
|
|
handleAddPage();
|
|
}
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('删除页面失败:', error);
|
|
createMessage.error('删除失败');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 拖拽开始事件
|
|
*/
|
|
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('保存排序失败');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 保存页面数据
|
|
*/
|
|
async function handleSave() {
|
|
if (!currentPage.title) {
|
|
createMessage.warning('请输入页面标题');
|
|
return;
|
|
}
|
|
|
|
if (!currentPage.id) {
|
|
createMessage.warning('页面ID不存在,无法保存');
|
|
return;
|
|
}
|
|
|
|
saving.value = true;
|
|
try {
|
|
// 将组件列表转换为content字段
|
|
const pageData = {
|
|
...currentPage,
|
|
content: componentsToContent()
|
|
};
|
|
|
|
// 只处理编辑操作,因为新增已在handleAddPage中处理
|
|
const result = await edit(pageData);
|
|
if (result) {
|
|
// createMessage.success('保存成功');
|
|
|
|
// 重新加载页面列表以确保数据同步
|
|
await loadPageList();
|
|
|
|
// 保持当前页面选中状态
|
|
if (currentPageId.value) {
|
|
const currentPageInList = pageList.value.find(p => p.id === currentPageId.value);
|
|
if (currentPageInList) {
|
|
selectPage(currentPageInList);
|
|
}
|
|
}
|
|
} else {
|
|
createMessage.error(result.message || '保存失败');
|
|
}
|
|
} catch (error) {
|
|
createMessage.error('保存失败');
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 刷新预览
|
|
*/
|
|
function refreshPreview() {
|
|
createMessage.info('预览已刷新');
|
|
}
|
|
|
|
/**
|
|
* 添加文本内容
|
|
*/
|
|
function addTextContent() {
|
|
contentComponents.value.push({
|
|
id: Date.now() + Math.random(), // 生成唯一ID
|
|
type: 'text',
|
|
content: '',
|
|
language: 'zh' // 默认选择中文
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 添加图片内容
|
|
*/
|
|
function addImageContent() {
|
|
contentComponents.value.push({
|
|
id: Date.now() + Math.random(), // 生成唯一ID
|
|
type: 'image',
|
|
imageUrl: '',
|
|
alt: ''
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 添加视频内容
|
|
*/
|
|
function addVideoContent() {
|
|
contentComponents.value.push({
|
|
id: Date.now() + Math.random(), // 生成唯一ID
|
|
type: 'video',
|
|
url: '',
|
|
coverUrl: ''
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 移除组件
|
|
*/
|
|
function removeComponent(index: number) {
|
|
contentComponents.value.splice(index, 1);
|
|
}
|
|
|
|
/**
|
|
* 图片上传变化处理
|
|
*/
|
|
function handleImageChange(value: string, component: any) {
|
|
// JImageUpload组件直接返回图片URL字符串
|
|
component.imageUrl = value;
|
|
}
|
|
|
|
/**
|
|
* 视频上传变化处理
|
|
*/
|
|
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('组件顺序已更新');
|
|
}
|
|
|
|
/**
|
|
* 获取页面类型图标
|
|
*/
|
|
function getPageTypeIcon(type: string) {
|
|
const iconMap = {
|
|
0: 'ant-design:font-size-outlined',
|
|
1: 'ant-design:picture-outlined',
|
|
2: 'ant-design:video-camera-outlined'
|
|
};
|
|
return iconMap[type] || 'ant-design:file-outlined';
|
|
}
|
|
|
|
/**
|
|
* 获取页面类型名称
|
|
*/
|
|
function getPageTypeName(type: string) {
|
|
// 优先从字典数据中获取名称
|
|
const dictOption = pageTypeOptions.value.find(option => option.value === type);
|
|
if (dictOption) {
|
|
return dictOption.label;
|
|
}
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* 主容器 */
|
|
.course-page-editor {
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: #f5f5f5;
|
|
}
|
|
|
|
/* 顶部导航栏 */
|
|
.editor-header {
|
|
padding: 16px 24px;
|
|
background: #fff;
|
|
border-bottom: 1px solid #e8e8e8;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.back-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
color: #666;
|
|
}
|
|
|
|
.page-title {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: #262626;
|
|
}
|
|
|
|
.header-right {
|
|
display: flex;
|
|
gap: 12px;
|
|
}
|
|
|
|
.add-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
/* 页面列表容器 */
|
|
.page-list-container {
|
|
padding: 8px;
|
|
background: #fff;
|
|
border-bottom: 1px solid #e8e8e8;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.page-list-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.list-title {
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
color: #262626;
|
|
}
|
|
|
|
.page-count {
|
|
font-size: 14px;
|
|
color: #8c8c8c;
|
|
}
|
|
|
|
.page-list-scroll {
|
|
overflow-x: auto;
|
|
padding-bottom: 8px;
|
|
}
|
|
|
|
.page-list {
|
|
display: flex;
|
|
gap: 12px;
|
|
min-width: max-content;
|
|
}
|
|
|
|
.page-list-container::-webkit-scrollbar {
|
|
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 {
|
|
display: flex;
|
|
align-items: center;
|
|
min-width: 160px;
|
|
width: 160px;
|
|
padding: 8px;
|
|
background: #fff;
|
|
border: 1px solid #d9d9d9;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.page-item:hover {
|
|
border-color: #1890ff;
|
|
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15);
|
|
}
|
|
|
|
.page-item.active {
|
|
border-color: #1890ff;
|
|
background: #e6f7ff;
|
|
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
|
|
}
|
|
|
|
.page-thumbnail {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
height: 40px;
|
|
background: #f5f5f5;
|
|
border-radius: 4px;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.page-icon {
|
|
font-size: 20px;
|
|
color: #1890ff;
|
|
}
|
|
|
|
.page-info {
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.page-title {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: #262626;
|
|
margin-bottom: 6px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.page-meta {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.page-type,
|
|
.page-sort {
|
|
font-size: 12px;
|
|
color: #8c8c8c;
|
|
}
|
|
|
|
.page-actions {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.empty-page-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 40px;
|
|
color: #8c8c8c;
|
|
}
|
|
|
|
.empty-icon {
|
|
font-size: 48px;
|
|
margin-bottom: 16px;
|
|
color: #d9d9d9;
|
|
}
|
|
|
|
/* 主编辑区域 */
|
|
.editor-main {
|
|
flex: 1;
|
|
display: flex;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* 左侧编辑区 */
|
|
.editor-left {
|
|
width: 50%;
|
|
background: #fff;
|
|
border-right: 1px solid #e8e8e8;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* 右侧预览区 */
|
|
.editor-right {
|
|
width: 50%;
|
|
background: #fff;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* 面板通用样式 */
|
|
.editor-panel,
|
|
/* 设置和预览容器 - 左右布局 */
|
|
.settings-preview-container {
|
|
display: flex;
|
|
height: 100%;
|
|
gap: 24px;
|
|
}
|
|
|
|
.settings-section {
|
|
flex: 0 0 280px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
border: 1px solid #e8e8e8;
|
|
border-radius: 6px;
|
|
background: #fff;
|
|
}
|
|
|
|
.mobile-preview-section {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
border: 1px solid #e8e8e8;
|
|
border-radius: 6px;
|
|
background: #fff;
|
|
}
|
|
|
|
/* 手机预览样式 */
|
|
.mobile-preview-container {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
padding: 20px;
|
|
background: #f5f5f5;
|
|
min-height: 500px;
|
|
}
|
|
|
|
.mobile-screen {
|
|
width: 375px;
|
|
height: 667px;
|
|
background: #fff;
|
|
border-radius: 20px;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
|
overflow: hidden;
|
|
position: relative;
|
|
border: 8px solid #333;
|
|
}
|
|
|
|
.mobile-screen::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 10px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
width: 60px;
|
|
height: 4px;
|
|
background: #333;
|
|
border-radius: 2px;
|
|
z-index: 10;
|
|
}
|
|
|
|
.mobile-screen .preview-header {
|
|
padding: 20px 16px 12px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
background: #fff;
|
|
}
|
|
|
|
.mobile-screen .preview-header h3 {
|
|
font-size: 16px;
|
|
margin: 0 0 8px 0;
|
|
color: #262626;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.mobile-screen .preview-meta {
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.mobile-screen .preview-content {
|
|
padding: 16px;
|
|
padding-top: 50px;
|
|
height: calc(100% - 30px);
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.mobile-screen .empty-preview {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 200px;
|
|
color: #8c8c8c;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.mobile-screen .content-preview {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.mobile-screen .preview-component {
|
|
background: #f9f9f9;
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
}
|
|
|
|
.mobile-screen .text-preview pre {
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
margin: 0;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
color: #262626;
|
|
}
|
|
|
|
.mobile-screen .image-preview img {
|
|
width: 100%;
|
|
height: auto;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.mobile-screen .placeholder-image,
|
|
.mobile-screen .placeholder-video {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 120px;
|
|
background: #f0f0f0;
|
|
border-radius: 6px;
|
|
color: #8c8c8c;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.mobile-screen .image-caption {
|
|
margin: 8px 0 0 0;
|
|
font-size: 12px;
|
|
color: #8c8c8c;
|
|
}
|
|
|
|
.mobile-screen .video-preview video {
|
|
width: 100%;
|
|
height: auto;
|
|
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;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
|
|
.panel-header {
|
|
padding: 16px 24px;
|
|
border-bottom: 1px solid #e8e8e8;
|
|
background: #fafafa;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.panel-header span {
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
color: #262626;
|
|
}
|
|
|
|
.panel-content {
|
|
flex: 1;
|
|
padding: 24px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/* 设置面板特殊样式 */
|
|
.settings-panel {
|
|
max-height: 300px;
|
|
}
|
|
|
|
.settings-form {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
/* 内容编辑区域 */
|
|
.content-editor-section {
|
|
margin-top: 24px;
|
|
}
|
|
|
|
.section-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.section-header span {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: #262626;
|
|
}
|
|
|
|
.content-components {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.content-component {
|
|
padding: 16px;
|
|
border: 1px solid #e8e8e8;
|
|
border-radius: 6px;
|
|
background: #fafafa;
|
|
}
|
|
|
|
.component-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.component-header span {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: #262626;
|
|
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;
|
|
padding: 8px 12px;
|
|
background: #f0f0f0;
|
|
border-radius: 4px;
|
|
border: 1px solid #d9d9d9;
|
|
}
|
|
|
|
.language-selector .ant-radio-group {
|
|
display: flex;
|
|
gap: 16px;
|
|
}
|
|
|
|
.empty-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 40px;
|
|
color: #8c8c8c;
|
|
border: 2px dashed #d9d9d9;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
/* 上传相关样式 */
|
|
.image-uploader,
|
|
.uploaded-image {
|
|
width: 100%;
|
|
max-width: 200px;
|
|
}
|
|
|
|
.uploaded-image img {
|
|
width: 100%;
|
|
height: auto;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.upload-placeholder {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
border: 2px dashed #d9d9d9;
|
|
border-radius: 6px;
|
|
color: #8c8c8c;
|
|
}
|
|
|
|
/* 预览区域样式 */
|
|
.preview-container {
|
|
border: 1px solid #e8e8e8;
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.preview-header {
|
|
padding: 16px;
|
|
background: #fafafa;
|
|
border-bottom: 1px solid #e8e8e8;
|
|
}
|
|
|
|
.preview-header h3 {
|
|
margin: 0 0 8px 0;
|
|
font-size: 16px;
|
|
color: #262626;
|
|
}
|
|
|
|
.preview-meta {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.preview-content {
|
|
padding: 16px;
|
|
min-height: 200px;
|
|
}
|
|
|
|
.empty-preview {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 200px;
|
|
color: #8c8c8c;
|
|
}
|
|
|
|
.content-preview {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.preview-component {
|
|
padding: 12px;
|
|
border: 1px solid #f0f0f0;
|
|
border-radius: 4px;
|
|
background: #fafafa;
|
|
}
|
|
|
|
.text-preview pre {
|
|
margin: 0;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
font-family: inherit;
|
|
}
|
|
|
|
/* 文本语言标签样式 */
|
|
.text-language-tag {
|
|
position: absolute;
|
|
top: 4px;
|
|
right: 4px;
|
|
background: #1890ff;
|
|
color: white;
|
|
font-size: 10px;
|
|
padding: 2px 6px;
|
|
border-radius: 2px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.text-preview {
|
|
position: relative;
|
|
}
|
|
|
|
.text-preview.text-english pre {
|
|
font-family: 'Arial', 'Helvetica', sans-serif;
|
|
font-style: italic;
|
|
}
|
|
|
|
.text-preview.text-english .text-language-tag {
|
|
background: #52c41a;
|
|
}
|
|
|
|
.image-preview img {
|
|
max-width: 100%;
|
|
height: auto;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.image-caption {
|
|
margin-top: 8px;
|
|
font-size: 12px;
|
|
color: #8c8c8c;
|
|
text-align: center;
|
|
}
|
|
|
|
.placeholder-image,
|
|
.placeholder-video {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 120px;
|
|
background: #f5f5f5;
|
|
border: 2px dashed #d9d9d9;
|
|
border-radius: 4px;
|
|
color: #8c8c8c;
|
|
}
|
|
|
|
.video-preview video {
|
|
width: 100%;
|
|
max-height: 200px;
|
|
}
|
|
|
|
/* 响应式设计 */
|
|
@media (max-width: 1200px) {
|
|
|
|
.editor-left,
|
|
.editor-right {
|
|
width: 50%;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.editor-main {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.editor-left,
|
|
.editor-right {
|
|
width: 100%;
|
|
}
|
|
|
|
.editor-right {
|
|
border-right: none;
|
|
border-top: 1px solid #e8e8e8;
|
|
}
|
|
|
|
.page-list {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.page-item {
|
|
min-width: auto;
|
|
}
|
|
}
|
|
</style>
|