四零语境后端代码仓库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

1546 lines
36 KiB

<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>