四零语境后端代码仓库
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.

1545 lines
36 KiB

1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
  1. <template>
  2. <div class="course-page-editor">
  3. <!-- 顶部导航栏 -->
  4. <div class="editor-header">
  5. <div class="header-left">
  6. <a-button type="text" @click="goBack" class="back-btn">
  7. <Icon icon="ant-design:arrow-left-outlined" />
  8. 返回课程列表
  9. </a-button>
  10. <a-divider type="vertical" />
  11. </div>
  12. <div class="header-right">
  13. <a-button @click="handleAddPage" class="add-btn">
  14. <Icon icon="ant-design:plus-outlined" />
  15. 新增页面
  16. </a-button>
  17. <a-button type="primary" @click="handleSave" :loading="saving">
  18. <Icon icon="ant-design:save-outlined" />
  19. 保存
  20. </a-button>
  21. </div>
  22. </div>
  23. <!-- 上方课程页面列表横向滑动 -->
  24. <div class="page-list-container">
  25. <div class="page-list-scroll">
  26. <draggable
  27. v-model="pageList"
  28. class="page-list"
  29. item-key="id"
  30. handle=".drag-handle"
  31. :animation="200"
  32. @start="onPageDragStart"
  33. @end="onPageDragEnd"
  34. >
  35. <template #item="{ element: page, index }">
  36. <div :class="['page-item', { active: currentPageId === page.id }]" @click="selectPage(page)">
  37. <!-- 拖拽手柄 -->
  38. <div class="drag-handle">
  39. <Icon icon="ant-design:drag-outlined" />
  40. </div>
  41. <div class="page-info">
  42. <div class="page-title">{{ page.title || `${index + 1}` }}</div>
  43. <div class="page-meta">
  44. <span class="page-type">{{ getPageTypeName(page.type) }}</span>
  45. </div>
  46. </div>
  47. <div class="page-actions">
  48. <a-button size="small" type="text" @click.stop="editPage(page)">
  49. <Icon icon="ant-design:edit-outlined" />
  50. </a-button>
  51. <a-button size="small" type="text" danger @click.stop="deletePage(page)">
  52. <Icon icon="ant-design:delete-outlined" />
  53. </a-button>
  54. </div>
  55. </div>
  56. </template>
  57. </draggable>
  58. <!-- 空状态 -->
  59. <div v-if="pageList.length === 0" class="empty-page-list">
  60. <Icon icon="ant-design:file-add-outlined" class="empty-icon" />
  61. <p>暂无页面点击右上角"新增页面"开始创建</p>
  62. </div>
  63. </div>
  64. </div>
  65. <!-- 下方主体编辑区域左右分栏 -->
  66. <div class="editor-main">
  67. <!-- 左侧内容编辑区域 -->
  68. <div class="editor-left">
  69. <div class="editor-panel">
  70. <div class="panel-content">
  71. <a-form :model="currentPage" layout="vertical" class="page-form">
  72. <!-- 内容编辑区域 -->
  73. <div class="content-editor-section">
  74. <div class="section-header">
  75. <span>页面内容</span>
  76. <a-button-group size="small">
  77. <a-button @click="addTextContent">
  78. <Icon icon="ant-design:font-size-outlined" />
  79. 文本
  80. </a-button>
  81. <a-button @click="addImageContent">
  82. <Icon icon="ant-design:picture-outlined" />
  83. 图片
  84. </a-button>
  85. <a-button @click="addVideoContent">
  86. <Icon icon="ant-design:video-camera-outlined" />
  87. 视频
  88. </a-button>
  89. </a-button-group>
  90. </div>
  91. <!-- 内容组件列表 -->
  92. <div class="content-components">
  93. <draggable
  94. v-model="contentComponents"
  95. item-key="id"
  96. handle=".drag-handle"
  97. animation="200"
  98. class="draggable-list"
  99. @start="onDragStart"
  100. @end="onDragEnd"
  101. >
  102. <template #item="{ element: component, index }">
  103. <div class="content-component">
  104. <!-- 文本组件 -->
  105. <div v-if="component.type === 'text'" class="text-component">
  106. <div class="component-header">
  107. <div class="drag-handle">
  108. <Icon icon="ant-design:drag-outlined" />
  109. </div>
  110. <Icon icon="ant-design:font-size-outlined" />
  111. <span>文本内容</span>
  112. <a-button size="small" type="text" danger @click="removeComponent(index)">
  113. <Icon icon="ant-design:delete-outlined" />
  114. </a-button>
  115. </div>
  116. <!-- 语言选择 -->
  117. <div class="language-selector">
  118. <a-radio-group v-model:value="component.language" size="small">
  119. <a-radio value="zh">中文</a-radio>
  120. <a-radio value="en">英文</a-radio>
  121. </a-radio-group>
  122. </div>
  123. <a-textarea v-model:value="component.content" :rows="4" placeholder="请输入文本内容" />
  124. </div>
  125. <!-- 图片组件 -->
  126. <div v-else-if="component.type === 'image'" class="image-component">
  127. <div class="component-header">
  128. <div class="drag-handle">
  129. <Icon icon="ant-design:drag-outlined" />
  130. </div>
  131. <Icon icon="ant-design:picture-outlined" />
  132. <span>图片内容</span>
  133. <a-button size="small" type="text" danger @click="removeComponent(index)">
  134. <Icon icon="ant-design:delete-outlined" />
  135. </a-button>
  136. </div>
  137. <JImageUpload
  138. v-model:value="component.imageUrl"
  139. :fileMax="1"
  140. listType="picture-card"
  141. text="上传图片"
  142. bizPath="course"
  143. :accept="['image/*']"
  144. @change="handleImageChange($event, component)"
  145. />
  146. <a-input v-model:value="component.alt" placeholder="图片描述(可选)" style="margin-top: 8px;" />
  147. </div>
  148. <!-- 视频组件 -->
  149. <div v-else-if="component.type === 'video'" class="video-component">
  150. <div class="component-header">
  151. <div class="drag-handle">
  152. <Icon icon="ant-design:drag-outlined" />
  153. </div>
  154. <Icon icon="ant-design:video-camera-outlined" />
  155. <span>视频内容</span>
  156. <a-button size="small" type="text" danger @click="removeComponent(index)">
  157. <Icon icon="ant-design:delete-outlined" />
  158. </a-button>
  159. </div>
  160. <JUpload
  161. v-model:value="component.url"
  162. bizPath="course"
  163. text="上传视频"
  164. @change="handleVideoChange($event, component)"
  165. style="margin-top: 8px;"
  166. />
  167. <div style="margin-top: 12px;">
  168. <div style="margin-bottom: 8px; font-size: 14px; color: #666;">视频封面</div>
  169. <JImageUpload
  170. v-model:value="component.coverUrl"
  171. :fileMax="1"
  172. listType="picture-card"
  173. text="上传封面"
  174. bizPath="course"
  175. :accept="['image/*']"
  176. @change="handleVideoCoverChange($event, component)"
  177. />
  178. </div>
  179. </div>
  180. </div>
  181. </template>
  182. </draggable>
  183. <!-- 空状态 -->
  184. <div v-if="contentComponents.length === 0" class="empty-content">
  185. <Icon icon="ant-design:plus-circle-outlined" class="empty-icon" />
  186. <p>点击上方按钮添加内容组件</p>
  187. </div>
  188. </div>
  189. </div>
  190. </a-form>
  191. </div>
  192. </div>
  193. </div>
  194. <!-- 右侧设置和预览区域 -->
  195. <div class="editor-right">
  196. <!-- 设置和预览区域左右布局 -->
  197. <div class="settings-preview-container">
  198. <!-- 左侧页面设置 -->
  199. <div class="settings-section">
  200. <div class="panel-header">
  201. <span>页面设置</span>
  202. </div>
  203. <div class="panel-content">
  204. <a-form :model="currentPage" layout="vertical" class="settings-form">
  205. <a-form-item label="页面标题" name="title">
  206. <a-input v-model:value="currentPage.title" placeholder="请输入页面标题" />
  207. </a-form-item>
  208. <a-form-item label="页面类型" name="type">
  209. <a-select v-model:value="currentPage.type" placeholder="选择类型">
  210. <a-select-option
  211. v-for="option in pageTypeOptions"
  212. :key="option.value"
  213. :value="option.value"
  214. >
  215. {{ option.label }}
  216. </a-select-option>
  217. </a-select>
  218. </a-form-item>
  219. <a-form-item label="付费" name="pay">
  220. <a-switch v-model:checked="payChecked" checked-children="" un-checked-children="" />
  221. </a-form-item>
  222. <a-form-item label="上架" name="status">
  223. <a-switch v-model:checked="statusChecked" checked-children="" un-checked-children="" />
  224. </a-form-item>
  225. </a-form>
  226. </div>
  227. </div>
  228. <!-- 右侧手机预览区域 -->
  229. <div class="mobile-preview-section">
  230. <div class="panel-header">
  231. <span>手机预览<span style="font-size: 12px; color: #999;">该预览经供参考请以实际手机为准</span></span>
  232. <a-button size="small" @click="refreshPreview">
  233. <Icon icon="ant-design:reload-outlined" />
  234. </a-button>
  235. </div>
  236. <div class="panel-content">
  237. <div class="mobile-preview-container">
  238. <div class="mobile-screen">
  239. <div class="preview-content">
  240. <div v-if="contentComponents.length === 0" class="empty-preview">
  241. <p>暂无内容请在左侧添加内容组件</p>
  242. </div>
  243. <div v-else class="content-preview">
  244. <div v-for="(component, index) in contentComponents" :key="index" class="preview-component">
  245. <!-- 文本预览 -->
  246. <div v-if="component.type === 'text'" class="text-preview" :class="{ 'text-english': component.language === 'en' }">
  247. <div class="text-language-tag">{{ component.language === 'en' ? 'EN' : '中' }}</div>
  248. <pre>{{ component.content || '文本内容...' }}</pre>
  249. </div>
  250. <!-- 图片预览 -->
  251. <div v-else-if="component.type === 'image'" class="image-preview">
  252. <img v-if="component.imageUrl" :src="component.imageUrl" :alt="component.alt" />
  253. <div v-else class="placeholder-image">图片预览</div>
  254. <p v-if="component.alt" class="image-caption">{{ component.alt }}</p>
  255. </div>
  256. <!-- 视频预览 -->
  257. <div v-else-if="component.type === 'video'" class="video-preview">
  258. <div v-if="component.url" class="video-container">
  259. <video :src="component.url" :poster="component.coverUrl" controls></video>
  260. </div>
  261. <div v-else class="placeholder-video">
  262. <div v-if="component.coverUrl" class="video-cover-preview">
  263. <img :src="component.coverUrl" alt="视频封面" />
  264. <div class="play-icon"></div>
  265. </div>
  266. <div v-else>视频预览</div>
  267. </div>
  268. </div>
  269. </div>
  270. </div>
  271. </div>
  272. </div>
  273. </div>
  274. </div>
  275. </div>
  276. </div>
  277. </div>
  278. </div>
  279. </div>
  280. </template>
  281. <script lang="ts" name="appletCoursePage-appletCoursePage" setup>
  282. import { ref, reactive, computed, unref, onMounted, watch } from 'vue';
  283. import { useRoute, useRouter } from 'vue-router';
  284. import { useMessage } from '/@/hooks/web/useMessage';
  285. import { Icon } from '/@/components/Icon';
  286. import { JImageUpload, JUpload } from '/@/components/Form';
  287. import { saveOrUpdate, getById, list, deleteOne, add, edit } from './AppletCoursePage.api';
  288. import { initDictOptions } from '/@/utils/dict/JDictSelectUtil';
  289. import draggable from 'vuedraggable';
  290. const route = useRoute();
  291. const router = useRouter();
  292. const { createMessage } = useMessage();
  293. // 课程页面列表
  294. const pageList = ref([]);
  295. const currentPageId = ref('');
  296. // 页面类型字典选项
  297. const pageTypeOptions = ref([]);
  298. // 当前编辑的页面数据
  299. const currentPage = reactive({
  300. id: '',
  301. courseId: '',
  302. title: '',
  303. type: '0',
  304. sort: 0,
  305. pay: 'N',
  306. status: 'Y',
  307. });
  308. // 内容组件列表
  309. const contentComponents = ref([]);
  310. // 计算属性:处理付费开关的Y/N字符串与布尔值转换
  311. const payChecked = computed({
  312. get: () => currentPage.pay === 'Y',
  313. set: (value: boolean) => {
  314. currentPage.pay = value ? 'Y' : 'N';
  315. }
  316. });
  317. // 计算属性:处理上架开关的Y/N字符串与布尔值转换
  318. const statusChecked = computed({
  319. get: () => currentPage.status === 'Y',
  320. set: (value: boolean) => {
  321. currentPage.status = value ? 'Y' : 'N';
  322. }
  323. });
  324. // 保存状态
  325. const saving = ref(false);
  326. onMounted(async () => {
  327. // 获取课程ID
  328. if (route.query.courseId) {
  329. currentPage.courseId = route.query.courseId as string;
  330. }
  331. // 加载页面类型字典数据
  332. await loadPageTypeOptions();
  333. // 加载课程页面列表
  334. await loadPageList();
  335. // 如果有页面ID,选择对应页面
  336. if (route.query.id) {
  337. currentPageId.value = route.query.id as string;
  338. await loadPageData(route.query.id as string);
  339. } else if (pageList.value.length > 0) {
  340. // 默认选择第一个页面
  341. selectPage(pageList.value[0]);
  342. }
  343. // 移除自动创建页面的逻辑,因为现在新增页面会立即保存到数据库
  344. });
  345. /**
  346. * 加载页面类型字典数据
  347. */
  348. async function loadPageTypeOptions() {
  349. try {
  350. const dictData = await initDictOptions('applet_course_page_type');
  351. pageTypeOptions.value = dictData || [];
  352. } catch (error) {
  353. createMessage.error('加载页面类型字典失败');
  354. // 如果字典加载失败,使用默认选项
  355. pageTypeOptions.value = [
  356. { label: '纯文本', value: 'text' },
  357. { label: '图文混合', value: 'image' },
  358. { label: '视频', value: 'video' }
  359. ];
  360. }
  361. }
  362. /**
  363. * 加载课程页面列表
  364. */
  365. async function loadPageList() {
  366. try {
  367. const params = {
  368. courseId: currentPage.courseId,
  369. pageNo: 1,
  370. pageSize: 9999999
  371. };
  372. const result = await list(params);
  373. const records = result.records || [];
  374. pageList.value = records
  375. } catch (error) {
  376. createMessage.error('加载页面列表失败', error);
  377. }
  378. }
  379. /**
  380. * 选择页面
  381. */
  382. function selectPage(page: any) {
  383. currentPageId.value = page.id;
  384. // 加载页面数据
  385. loadPageData(page.id);
  386. }
  387. /**
  388. * 加载页面数据
  389. */
  390. async function loadPageData(id: string) {
  391. if (!id) {
  392. return;
  393. }
  394. try {
  395. const result = await getById({ id });
  396. Object.assign(currentPage, result);
  397. // 解析content字段为组件列表
  398. parseContentToComponents(result.content);
  399. } catch (error) {
  400. createMessage.error('加载页面数据失败');
  401. }
  402. }
  403. /**
  404. * 解析content字段为组件列表
  405. */
  406. function parseContentToComponents(content: string) {
  407. contentComponents.value = [];
  408. if (!content) return;
  409. try {
  410. const parsed = JSON.parse(content);
  411. if (Array.isArray(parsed)) {
  412. // 确保每个文本组件都有language字段
  413. contentComponents.value = parsed.map(component => {
  414. if (component.type === 'text' && !component.language) {
  415. return { ...component, language: 'zh' }; // 默认中文
  416. }
  417. return component;
  418. });
  419. } else {
  420. // 如果是字符串,创建一个文本组件
  421. contentComponents.value = [{
  422. type: 'text',
  423. content: content,
  424. language: 'zh' // 默认中文
  425. }];
  426. }
  427. } catch (error) {
  428. // 如果解析失败,作为纯文本处理
  429. contentComponents.value = [{
  430. type: 'text',
  431. content: content,
  432. language: 'zh' // 默认中文
  433. }];
  434. }
  435. }
  436. /**
  437. * 将组件列表转换为content字段
  438. */
  439. function componentsToContent() {
  440. return JSON.stringify(contentComponents.value);
  441. }
  442. /**
  443. * 返回课程列表
  444. */
  445. function goBack() {
  446. router.back();
  447. }
  448. /**
  449. * 新增页面
  450. */
  451. async function handleAddPage() {
  452. try {
  453. const newPage = {
  454. courseId: currentPage.courseId,
  455. title: `新页面 ${pageList.value.length + 1}`,
  456. type: '0',
  457. sort: pageList.value.length + 1, // 自动设置为最后一个位置
  458. pay: 'N',
  459. status: 'N',
  460. content: '[]' // 默认空内容
  461. };
  462. // 调用API添加到数据库
  463. const result = await add(newPage);
  464. if (result.id) {
  465. // 设置新页面的ID
  466. newPage.id = result.id;
  467. // 将新页面添加到页面列表中
  468. pageList.value.push(newPage);
  469. // 重新设置所有页面的排序值
  470. updatePageSortOrder();
  471. // 设置为当前编辑页面
  472. Object.assign(currentPage, newPage);
  473. contentComponents.value = [];
  474. currentPageId.value = newPage.id;
  475. createMessage.success('新增页面成功');
  476. } else {
  477. createMessage.error(result.message || '新增页面失败');
  478. }
  479. } catch (error) {
  480. console.error('新增页面失败:', error);
  481. createMessage.error('新增页面失败');
  482. }
  483. }
  484. /**
  485. * 编辑页面
  486. */
  487. function editPage(page: any) {
  488. selectPage(page);
  489. }
  490. /**
  491. * 删除页面
  492. */
  493. async function deletePage(page: any) {
  494. if (!page.id) {
  495. // 如果是新页面(未保存),直接从列表中移除
  496. const index = pageList.value.findIndex(p => p === page);
  497. if (index > -1) {
  498. pageList.value.splice(index, 1);
  499. // 重新设置排序值
  500. updatePageSortOrder();
  501. // 如果删除的是当前页面,选择其他页面
  502. if (currentPageId.value === page.id || currentPageId.value === '') {
  503. if (pageList.value.length > 0) {
  504. selectPage(pageList.value[0]);
  505. } else {
  506. // 没有页面了,创建新页面
  507. handleAddPage();
  508. }
  509. }
  510. }
  511. return;
  512. }
  513. try {
  514. await deleteOne({ id: page.id }, () => {
  515. createMessage.success('删除成功');
  516. // 重新加载页面列表
  517. loadPageList();
  518. // 如果删除的是当前页面,选择其他页面
  519. if (currentPageId.value === page.id) {
  520. if (pageList.value.length > 1) {
  521. const index = pageList.value.findIndex(p => p.id === page.id);
  522. const nextPage = pageList.value[index === 0 ? 1 : index - 1];
  523. selectPage(nextPage);
  524. } else {
  525. // 没有其他页面了,创建新页面
  526. handleAddPage();
  527. }
  528. }
  529. });
  530. } catch (error) {
  531. console.error('删除页面失败:', error);
  532. createMessage.error('删除失败');
  533. }
  534. }
  535. /**
  536. * 拖拽开始事件
  537. */
  538. function onPageDragStart(evt: any) {
  539. console.log('开始拖拽页面:', evt);
  540. }
  541. /**
  542. * 拖拽结束事件
  543. */
  544. function onPageDragEnd(evt: any) {
  545. console.log('拖拽结束:', evt);
  546. // 更新排序值
  547. updatePageSortOrder();
  548. // 保存排序更改
  549. savePageOrder();
  550. createMessage.success('页面排序已更新');
  551. }
  552. /**
  553. * 更新页面排序值
  554. */
  555. function updatePageSortOrder() {
  556. pageList.value.forEach((page, index) => {
  557. page.sort = index + 1;
  558. });
  559. }
  560. /**
  561. * 保存页面排序
  562. */
  563. async function savePageOrder() {
  564. try {
  565. // 批量更新页面排序
  566. const updatePromises = pageList.value.map(page => {
  567. if (page.id) {
  568. return edit({ id: page.id, sort: page.sort });
  569. }
  570. }).filter(Boolean);
  571. await Promise.all(updatePromises);
  572. } catch (error) {
  573. console.error('保存页面排序失败:', error);
  574. createMessage.error('保存排序失败');
  575. }
  576. }
  577. /**
  578. * 保存页面数据
  579. */
  580. async function handleSave() {
  581. if (!currentPage.title) {
  582. createMessage.warning('请输入页面标题');
  583. return;
  584. }
  585. if (!currentPage.id) {
  586. createMessage.warning('页面ID不存在,无法保存');
  587. return;
  588. }
  589. saving.value = true;
  590. try {
  591. // 将组件列表转换为content字段
  592. const pageData = {
  593. ...currentPage,
  594. content: componentsToContent()
  595. };
  596. // 只处理编辑操作,因为新增已在handleAddPage中处理
  597. const result = await edit(pageData);
  598. if (result) {
  599. // createMessage.success('保存成功');
  600. // 重新加载页面列表以确保数据同步
  601. await loadPageList();
  602. // 保持当前页面选中状态
  603. if (currentPageId.value) {
  604. const currentPageInList = pageList.value.find(p => p.id === currentPageId.value);
  605. if (currentPageInList) {
  606. selectPage(currentPageInList);
  607. }
  608. }
  609. } else {
  610. createMessage.error(result.message || '保存失败');
  611. }
  612. } catch (error) {
  613. createMessage.error('保存失败');
  614. } finally {
  615. saving.value = false;
  616. }
  617. }
  618. /**
  619. * 刷新预览
  620. */
  621. function refreshPreview() {
  622. createMessage.info('预览已刷新');
  623. }
  624. /**
  625. * 添加文本内容
  626. */
  627. function addTextContent() {
  628. contentComponents.value.push({
  629. id: Date.now() + Math.random(), // 生成唯一ID
  630. type: 'text',
  631. content: '',
  632. language: 'zh' // 默认选择中文
  633. });
  634. }
  635. /**
  636. * 添加图片内容
  637. */
  638. function addImageContent() {
  639. contentComponents.value.push({
  640. id: Date.now() + Math.random(), // 生成唯一ID
  641. type: 'image',
  642. imageUrl: '',
  643. alt: ''
  644. });
  645. }
  646. /**
  647. * 添加视频内容
  648. */
  649. function addVideoContent() {
  650. contentComponents.value.push({
  651. id: Date.now() + Math.random(), // 生成唯一ID
  652. type: 'video',
  653. url: '',
  654. coverUrl: ''
  655. });
  656. }
  657. /**
  658. * 移除组件
  659. */
  660. function removeComponent(index: number) {
  661. contentComponents.value.splice(index, 1);
  662. }
  663. /**
  664. * 图片上传变化处理
  665. */
  666. function handleImageChange(value: string, component: any) {
  667. // JImageUpload组件直接返回图片URL字符串
  668. component.imageUrl = value;
  669. }
  670. /**
  671. * 视频上传变化处理
  672. */
  673. function handleVideoChange(value: string, component: any) {
  674. component.url = value;
  675. }
  676. /**
  677. * 视频封面上传变化处理
  678. */
  679. function handleVideoCoverChange(value: string, component: any) {
  680. component.coverUrl = value;
  681. }
  682. /**
  683. * 拖拽开始事件
  684. */
  685. function onDragStart(evt: any) {
  686. console.log('拖拽开始:', evt);
  687. }
  688. /**
  689. * 拖拽结束事件
  690. */
  691. function onDragEnd(evt: any) {
  692. console.log('拖拽结束:', evt);
  693. // Vue Draggable 会自动更新 v-model 绑定的数组
  694. // 这里可以添加额外的处理逻辑,比如保存排序状态
  695. createMessage.success('组件顺序已更新');
  696. }
  697. /**
  698. * 获取页面类型图标
  699. */
  700. function getPageTypeIcon(type: string) {
  701. const iconMap = {
  702. 0: 'ant-design:font-size-outlined',
  703. 1: 'ant-design:picture-outlined',
  704. 2: 'ant-design:video-camera-outlined'
  705. };
  706. return iconMap[type] || 'ant-design:file-outlined';
  707. }
  708. /**
  709. * 获取页面类型名称
  710. */
  711. function getPageTypeName(type: string) {
  712. // 优先从字典数据中获取名称
  713. const dictOption = pageTypeOptions.value.find(option => option.value === type);
  714. if (dictOption) {
  715. return dictOption.label;
  716. }
  717. }
  718. </script>
  719. <style scoped>
  720. /* 主容器 */
  721. .course-page-editor {
  722. min-height: 100vh;
  723. display: flex;
  724. flex-direction: column;
  725. background: #f5f5f5;
  726. }
  727. /* 顶部导航栏 */
  728. .editor-header {
  729. padding: 16px 24px;
  730. background: #fff;
  731. border-bottom: 1px solid #e8e8e8;
  732. display: flex;
  733. justify-content: space-between;
  734. align-items: center;
  735. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  736. }
  737. .header-left {
  738. display: flex;
  739. align-items: center;
  740. gap: 16px;
  741. }
  742. .back-btn {
  743. display: flex;
  744. align-items: center;
  745. gap: 8px;
  746. color: #666;
  747. }
  748. .page-title {
  749. font-size: 18px;
  750. font-weight: 600;
  751. color: #262626;
  752. }
  753. .header-right {
  754. display: flex;
  755. gap: 12px;
  756. }
  757. .add-btn {
  758. display: flex;
  759. align-items: center;
  760. gap: 8px;
  761. }
  762. /* 页面列表容器 */
  763. .page-list-container {
  764. padding: 8px;
  765. background: #fff;
  766. border-bottom: 1px solid #e8e8e8;
  767. flex-shrink: 0;
  768. }
  769. .page-list-header {
  770. display: flex;
  771. justify-content: space-between;
  772. align-items: center;
  773. margin-bottom: 8px;
  774. }
  775. .list-title {
  776. font-size: 16px;
  777. font-weight: 500;
  778. color: #262626;
  779. }
  780. .page-count {
  781. font-size: 14px;
  782. color: #8c8c8c;
  783. }
  784. .page-list-scroll {
  785. overflow-x: auto;
  786. padding-bottom: 8px;
  787. }
  788. .page-list {
  789. display: flex;
  790. gap: 12px;
  791. min-width: max-content;
  792. }
  793. .page-list-container::-webkit-scrollbar {
  794. height: 6px;
  795. }
  796. /* 拖拽手柄样式 */
  797. .drag-handle {
  798. display: flex;
  799. align-items: center;
  800. justify-content: center;
  801. width: 20px;
  802. height: 20px;
  803. margin-right: 8px;
  804. cursor: grab;
  805. color: #8c8c8c;
  806. border-radius: 2px;
  807. transition: all 0.2s;
  808. }
  809. .drag-handle:hover {
  810. color: #1890ff;
  811. background: #f0f0f0;
  812. }
  813. .drag-handle:active {
  814. cursor: grabbing;
  815. }
  816. /* 拖拽状态样式 */
  817. .sortable-ghost {
  818. opacity: 0.5;
  819. background: #f0f7ff;
  820. border: 2px dashed #1890ff;
  821. }
  822. .sortable-chosen {
  823. transform: scale(1.02);
  824. box-shadow: 0 6px 16px rgba(24, 144, 255, 0.3);
  825. }
  826. .sortable-drag {
  827. opacity: 0.8;
  828. transform: rotate(2deg);
  829. }
  830. .page-item {
  831. display: flex;
  832. align-items: center;
  833. min-width: 160px;
  834. width: 160px;
  835. padding: 8px;
  836. background: #fff;
  837. border: 1px solid #d9d9d9;
  838. border-radius: 6px;
  839. cursor: pointer;
  840. transition: all 0.3s;
  841. box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
  842. }
  843. .page-item:hover {
  844. border-color: #1890ff;
  845. box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15);
  846. }
  847. .page-item.active {
  848. border-color: #1890ff;
  849. background: #e6f7ff;
  850. box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
  851. }
  852. .page-thumbnail {
  853. display: flex;
  854. justify-content: center;
  855. align-items: center;
  856. height: 40px;
  857. background: #f5f5f5;
  858. border-radius: 4px;
  859. margin-bottom: 6px;
  860. }
  861. .page-icon {
  862. font-size: 20px;
  863. color: #1890ff;
  864. }
  865. .page-info {
  866. margin-bottom: 6px;
  867. }
  868. .page-title {
  869. font-size: 13px;
  870. font-weight: 500;
  871. color: #262626;
  872. margin-bottom: 6px;
  873. white-space: nowrap;
  874. overflow: hidden;
  875. text-overflow: ellipsis;
  876. }
  877. .page-meta {
  878. display: flex;
  879. flex-direction: column;
  880. gap: 4px;
  881. }
  882. .page-type,
  883. .page-sort {
  884. font-size: 12px;
  885. color: #8c8c8c;
  886. }
  887. .page-actions {
  888. display: flex;
  889. justify-content: center;
  890. gap: 8px;
  891. }
  892. .empty-page-list {
  893. display: flex;
  894. flex-direction: column;
  895. align-items: center;
  896. justify-content: center;
  897. padding: 40px;
  898. color: #8c8c8c;
  899. }
  900. .empty-icon {
  901. font-size: 48px;
  902. margin-bottom: 16px;
  903. color: #d9d9d9;
  904. }
  905. /* 主编辑区域 */
  906. .editor-main {
  907. flex: 1;
  908. display: flex;
  909. overflow: hidden;
  910. }
  911. /* 左侧编辑区 */
  912. .editor-left {
  913. width: 50%;
  914. background: #fff;
  915. border-right: 1px solid #e8e8e8;
  916. display: flex;
  917. flex-direction: column;
  918. }
  919. /* 右侧预览区 */
  920. .editor-right {
  921. width: 50%;
  922. background: #fff;
  923. display: flex;
  924. flex-direction: column;
  925. }
  926. /* 面板通用样式 */
  927. .editor-panel,
  928. /* 设置和预览容器 - 左右布局 */
  929. .settings-preview-container {
  930. display: flex;
  931. height: 100%;
  932. gap: 24px;
  933. }
  934. .settings-section {
  935. flex: 0 0 280px;
  936. display: flex;
  937. flex-direction: column;
  938. border: 1px solid #e8e8e8;
  939. border-radius: 6px;
  940. background: #fff;
  941. }
  942. .mobile-preview-section {
  943. flex: 1;
  944. display: flex;
  945. flex-direction: column;
  946. border: 1px solid #e8e8e8;
  947. border-radius: 6px;
  948. background: #fff;
  949. }
  950. /* 手机预览样式 */
  951. .mobile-preview-container {
  952. display: flex;
  953. justify-content: center;
  954. align-items: flex-start;
  955. padding: 20px;
  956. background: #f5f5f5;
  957. min-height: 500px;
  958. }
  959. .mobile-screen {
  960. width: 375px;
  961. height: 667px;
  962. background: #fff;
  963. border-radius: 20px;
  964. box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
  965. overflow: hidden;
  966. position: relative;
  967. border: 8px solid #333;
  968. }
  969. .mobile-screen::before {
  970. content: '';
  971. position: absolute;
  972. top: 10px;
  973. left: 50%;
  974. transform: translateX(-50%);
  975. width: 60px;
  976. height: 4px;
  977. background: #333;
  978. border-radius: 2px;
  979. z-index: 10;
  980. }
  981. .mobile-screen .preview-header {
  982. padding: 20px 16px 12px;
  983. border-bottom: 1px solid #f0f0f0;
  984. background: #fff;
  985. }
  986. .mobile-screen .preview-header h3 {
  987. font-size: 16px;
  988. margin: 0 0 8px 0;
  989. color: #262626;
  990. font-weight: 600;
  991. }
  992. .mobile-screen .preview-meta {
  993. display: flex;
  994. gap: 6px;
  995. flex-wrap: wrap;
  996. }
  997. .mobile-screen .preview-content {
  998. padding: 16px;
  999. padding-top: 50px;
  1000. height: calc(100% - 30px);
  1001. overflow-y: auto;
  1002. }
  1003. .mobile-screen .empty-preview {
  1004. display: flex;
  1005. align-items: center;
  1006. justify-content: center;
  1007. height: 200px;
  1008. color: #8c8c8c;
  1009. font-size: 14px;
  1010. }
  1011. .mobile-screen .content-preview {
  1012. display: flex;
  1013. flex-direction: column;
  1014. gap: 12px;
  1015. }
  1016. .mobile-screen .preview-component {
  1017. background: #f9f9f9;
  1018. border-radius: 8px;
  1019. padding: 12px;
  1020. }
  1021. .mobile-screen .text-preview pre {
  1022. font-size: 14px;
  1023. line-height: 1.5;
  1024. margin: 0;
  1025. white-space: pre-wrap;
  1026. word-break: break-word;
  1027. color: #262626;
  1028. }
  1029. .mobile-screen .image-preview img {
  1030. width: 100%;
  1031. height: auto;
  1032. border-radius: 6px;
  1033. }
  1034. .mobile-screen .placeholder-image,
  1035. .mobile-screen .placeholder-video {
  1036. display: flex;
  1037. align-items: center;
  1038. justify-content: center;
  1039. height: 120px;
  1040. background: #f0f0f0;
  1041. border-radius: 6px;
  1042. color: #8c8c8c;
  1043. font-size: 14px;
  1044. }
  1045. .mobile-screen .image-caption {
  1046. margin: 8px 0 0 0;
  1047. font-size: 12px;
  1048. color: #8c8c8c;
  1049. }
  1050. .mobile-screen .video-preview video {
  1051. width: 100%;
  1052. height: auto;
  1053. border-radius: 6px;
  1054. }
  1055. .mobile-screen .video-cover-preview {
  1056. position: relative;
  1057. display: inline-block;
  1058. width: 100%;
  1059. }
  1060. .mobile-screen .video-cover-preview img {
  1061. width: 100%;
  1062. height: auto;
  1063. border-radius: 6px;
  1064. }
  1065. .mobile-screen .video-cover-preview .play-icon {
  1066. position: absolute;
  1067. top: 50%;
  1068. left: 50%;
  1069. transform: translate(-50%, -50%);
  1070. width: 40px;
  1071. height: 40px;
  1072. background: rgba(0, 0, 0, 0.6);
  1073. border-radius: 50%;
  1074. display: flex;
  1075. align-items: center;
  1076. justify-content: center;
  1077. color: white;
  1078. font-size: 16px;
  1079. }
  1080. .settings-panel,
  1081. .preview-panel {
  1082. display: flex;
  1083. flex-direction: column;
  1084. height: 100%;
  1085. }
  1086. .panel-header {
  1087. padding: 16px 24px;
  1088. border-bottom: 1px solid #e8e8e8;
  1089. background: #fafafa;
  1090. display: flex;
  1091. justify-content: space-between;
  1092. align-items: center;
  1093. }
  1094. .panel-header span {
  1095. font-size: 16px;
  1096. font-weight: 500;
  1097. color: #262626;
  1098. }
  1099. .panel-content {
  1100. flex: 1;
  1101. padding: 24px;
  1102. overflow-y: auto;
  1103. }
  1104. /* 设置面板特殊样式 */
  1105. .settings-panel {
  1106. max-height: 300px;
  1107. }
  1108. .settings-form {
  1109. margin-bottom: 0;
  1110. }
  1111. /* 内容编辑区域 */
  1112. .content-editor-section {
  1113. margin-top: 24px;
  1114. }
  1115. .section-header {
  1116. display: flex;
  1117. justify-content: space-between;
  1118. align-items: center;
  1119. margin-bottom: 16px;
  1120. }
  1121. .section-header span {
  1122. font-size: 14px;
  1123. font-weight: 500;
  1124. color: #262626;
  1125. }
  1126. .content-components {
  1127. display: flex;
  1128. flex-direction: column;
  1129. gap: 16px;
  1130. }
  1131. .content-component {
  1132. padding: 16px;
  1133. border: 1px solid #e8e8e8;
  1134. border-radius: 6px;
  1135. background: #fafafa;
  1136. }
  1137. .component-header {
  1138. display: flex;
  1139. align-items: center;
  1140. gap: 8px;
  1141. margin-bottom: 12px;
  1142. }
  1143. .component-header span {
  1144. font-size: 14px;
  1145. font-weight: 500;
  1146. color: #262626;
  1147. flex: 1;
  1148. }
  1149. /* 拖拽手柄样式 */
  1150. .drag-handle {
  1151. cursor: move;
  1152. padding: 4px;
  1153. color: #8c8c8c;
  1154. transition: color 0.3s;
  1155. display: flex;
  1156. align-items: center;
  1157. justify-content: center;
  1158. width: 20px;
  1159. height: 20px;
  1160. border-radius: 4px;
  1161. }
  1162. .drag-handle:hover {
  1163. color: #1890ff;
  1164. background-color: #f0f8ff;
  1165. }
  1166. /* 拖拽列表样式 */
  1167. .draggable-list {
  1168. display: flex;
  1169. flex-direction: column;
  1170. gap: 16px;
  1171. }
  1172. /* 拖拽时的样式 */
  1173. .sortable-ghost {
  1174. opacity: 0.5;
  1175. background-color: #f5f5f5;
  1176. border: 2px dashed #d9d9d9;
  1177. }
  1178. .sortable-chosen {
  1179. transform: scale(1.02);
  1180. box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
  1181. border: 2px solid #1890ff;
  1182. background-color: #f0f8ff;
  1183. }
  1184. .sortable-drag {
  1185. opacity: 0.8;
  1186. transform: rotate(5deg);
  1187. }
  1188. /* 组件头部样式增强 */
  1189. .component-header {
  1190. display: flex;
  1191. align-items: center;
  1192. gap: 8px;
  1193. padding: 8px 12px;
  1194. background-color: #fafafa;
  1195. border-radius: 6px 6px 0 0;
  1196. border-bottom: 1px solid #f0f0f0;
  1197. transition: all 0.3s;
  1198. }
  1199. .content-component:hover .component-header {
  1200. background-color: #f0f8ff;
  1201. }
  1202. .content-component {
  1203. transition: all 0.3s;
  1204. border-radius: 6px;
  1205. overflow: hidden;
  1206. }
  1207. /* 语言选择器样式 */
  1208. .language-selector {
  1209. margin-bottom: 12px;
  1210. padding: 8px 12px;
  1211. background: #f0f0f0;
  1212. border-radius: 4px;
  1213. border: 1px solid #d9d9d9;
  1214. }
  1215. .language-selector .ant-radio-group {
  1216. display: flex;
  1217. gap: 16px;
  1218. }
  1219. .empty-content {
  1220. display: flex;
  1221. flex-direction: column;
  1222. align-items: center;
  1223. justify-content: center;
  1224. padding: 40px;
  1225. color: #8c8c8c;
  1226. border: 2px dashed #d9d9d9;
  1227. border-radius: 6px;
  1228. }
  1229. /* 上传相关样式 */
  1230. .image-uploader,
  1231. .uploaded-image {
  1232. width: 100%;
  1233. max-width: 200px;
  1234. }
  1235. .uploaded-image img {
  1236. width: 100%;
  1237. height: auto;
  1238. border-radius: 6px;
  1239. }
  1240. .upload-placeholder {
  1241. display: flex;
  1242. flex-direction: column;
  1243. align-items: center;
  1244. justify-content: center;
  1245. padding: 20px;
  1246. border: 2px dashed #d9d9d9;
  1247. border-radius: 6px;
  1248. color: #8c8c8c;
  1249. }
  1250. /* 预览区域样式 */
  1251. .preview-container {
  1252. border: 1px solid #e8e8e8;
  1253. border-radius: 6px;
  1254. overflow: hidden;
  1255. }
  1256. .preview-header {
  1257. padding: 16px;
  1258. background: #fafafa;
  1259. border-bottom: 1px solid #e8e8e8;
  1260. }
  1261. .preview-header h3 {
  1262. margin: 0 0 8px 0;
  1263. font-size: 16px;
  1264. color: #262626;
  1265. }
  1266. .preview-meta {
  1267. display: flex;
  1268. gap: 8px;
  1269. }
  1270. .preview-content {
  1271. padding: 16px;
  1272. min-height: 200px;
  1273. }
  1274. .empty-preview {
  1275. display: flex;
  1276. align-items: center;
  1277. justify-content: center;
  1278. height: 200px;
  1279. color: #8c8c8c;
  1280. }
  1281. .content-preview {
  1282. display: flex;
  1283. flex-direction: column;
  1284. gap: 16px;
  1285. }
  1286. .preview-component {
  1287. padding: 12px;
  1288. border: 1px solid #f0f0f0;
  1289. border-radius: 4px;
  1290. background: #fafafa;
  1291. }
  1292. .text-preview pre {
  1293. margin: 0;
  1294. white-space: pre-wrap;
  1295. word-break: break-word;
  1296. font-family: inherit;
  1297. }
  1298. /* 文本语言标签样式 */
  1299. .text-language-tag {
  1300. position: absolute;
  1301. top: 4px;
  1302. right: 4px;
  1303. background: #1890ff;
  1304. color: white;
  1305. font-size: 10px;
  1306. padding: 2px 6px;
  1307. border-radius: 2px;
  1308. font-weight: bold;
  1309. }
  1310. .text-preview {
  1311. position: relative;
  1312. }
  1313. .text-preview.text-english pre {
  1314. font-family: 'Arial', 'Helvetica', sans-serif;
  1315. font-style: italic;
  1316. }
  1317. .text-preview.text-english .text-language-tag {
  1318. background: #52c41a;
  1319. }
  1320. .image-preview img {
  1321. max-width: 100%;
  1322. height: auto;
  1323. border-radius: 4px;
  1324. }
  1325. .image-caption {
  1326. margin-top: 8px;
  1327. font-size: 12px;
  1328. color: #8c8c8c;
  1329. text-align: center;
  1330. }
  1331. .placeholder-image,
  1332. .placeholder-video {
  1333. display: flex;
  1334. align-items: center;
  1335. justify-content: center;
  1336. height: 120px;
  1337. background: #f5f5f5;
  1338. border: 2px dashed #d9d9d9;
  1339. border-radius: 4px;
  1340. color: #8c8c8c;
  1341. }
  1342. .video-preview video {
  1343. width: 100%;
  1344. max-height: 200px;
  1345. }
  1346. /* 响应式设计 */
  1347. @media (max-width: 1200px) {
  1348. .editor-left,
  1349. .editor-right {
  1350. width: 50%;
  1351. }
  1352. }
  1353. @media (max-width: 768px) {
  1354. .editor-main {
  1355. flex-direction: column;
  1356. }
  1357. .editor-left,
  1358. .editor-right {
  1359. width: 100%;
  1360. }
  1361. .editor-right {
  1362. border-right: none;
  1363. border-top: 1px solid #e8e8e8;
  1364. }
  1365. .page-list {
  1366. flex-direction: column;
  1367. }
  1368. .page-item {
  1369. min-width: auto;
  1370. }
  1371. }
  1372. </style>