MiniProgramEditor.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. <template>
  2. <a-config-provider :locale="zhCN">
  3. <div class="miniprogram-editor">
  4. <div class="editor-toolbar">
  5. <a-space>
  6. <a-dropdown>
  7. <a-button>
  8. 加载
  9. <DownOutlined />
  10. </a-button>
  11. <template #overlay>
  12. <a-menu v-if="historyList.length">
  13. <a-menu-item @click="onSelectDefault">
  14. 选择默认配置
  15. <a-badge v-if="currentConfig?.activeHistoryId === 0" count="激活" />
  16. </a-menu-item>
  17. <a-menu-item
  18. v-for="(item, index) in historyList"
  19. :key="index"
  20. @click="onSelectHistory(item.id!)"
  21. >
  22. {{ item.name }}
  23. <a-badge v-if="item.id === currentConfig?.activeHistoryId" count="激活" />
  24. </a-menu-item>
  25. </a-menu>
  26. <a-menu v-else>
  27. <a-menu-item disabled>暂无历史版本</a-menu-item>
  28. </a-menu>
  29. </template>
  30. </a-dropdown>
  31. <a-dropdown-button type="primary" @click="saveEditorJson">
  32. 保存
  33. <template #overlay>
  34. <a-menu>
  35. <a-menu-item @click="openSaveAsModal">另存为历史版本</a-menu-item>
  36. </a-menu>
  37. </template>
  38. </a-dropdown-button>
  39. <div>
  40. <InfoCircleFilled />
  41. 当前显示配置:
  42. <span>{{ currentShowConfigName }}</span>
  43. </div>
  44. <a-button v-if="currentConfig" @click="setActiveHistory">
  45. {{ currentConfig.activeHistoryId === currentHistoryId ? '已经是激活版本' : '设置为激活版本' }}
  46. </a-button>
  47. <a-button v-if="currentConfig && currentHistoryId !== 0" danger @click="deleteHistory">删除历史版本</a-button>
  48. </a-space>
  49. <a-button @click="exportToJsonFile">
  50. 导出为JSON文件
  51. <DownloadOutlined />
  52. </a-button>
  53. </div>
  54. <!-- 另存为历史版本弹框 -->
  55. <a-modal
  56. v-model:open="saveAsModalVisible"
  57. title="另存为历史版本"
  58. ok-text="保存"
  59. cancel-text="取消"
  60. :confirm-loading="saveAsLoading"
  61. @ok="confirmSaveAs"
  62. >
  63. <a-form layout="vertical" class="save-as-form">
  64. <a-form-item label="版本名称">
  65. <a-input
  66. v-model:value="saveAsVersionName"
  67. placeholder="请输入版本名称"
  68. allow-clear
  69. />
  70. </a-form-item>
  71. </a-form>
  72. </a-modal>
  73. <!-- 添加页面弹框 -->
  74. <a-modal
  75. v-model:open="addPageModalVisible"
  76. title="添加页面"
  77. ok-text="添加"
  78. cancel-text="取消"
  79. :confirm-loading="addPageLoading"
  80. @ok="confirmAddPage"
  81. >
  82. <a-form layout="vertical" class="add-page-form">
  83. <a-form-item label="key(页面唯一标识)" required>
  84. <a-input
  85. v-model:value="addPageKey"
  86. placeholder="如 home、list_1"
  87. allow-clear
  88. />
  89. </a-form-item>
  90. <a-form-item label="title(页面标题)" required>
  91. <a-input
  92. v-model:value="addPageTitle"
  93. placeholder="如 首页、列表页"
  94. allow-clear
  95. />
  96. </a-form-item>
  97. <a-form-item v-if="addPageTemplate" label="模板">
  98. <span>{{ addPageTemplate }}</span>
  99. </a-form-item>
  100. </a-form>
  101. </a-modal>
  102. <div class="editor-body">
  103. <!-- 左一:页面列表 -->
  104. <div class="panel panel-pages">
  105. <div class="panel-title panel-title-with-action">
  106. <span>页面列表</span>
  107. <a-dropdown size="small">
  108. <PlusOutlined />
  109. <template #overlay>
  110. <a-menu @click="onAddPageMenuClick">
  111. <a-menu-item key="Home">Home</a-menu-item>
  112. <a-menu-item key="CommonList">CommonList</a-menu-item>
  113. <a-menu-item key="Details">Details</a-menu-item>
  114. </a-menu>
  115. </template>
  116. </a-dropdown>
  117. </div>
  118. <a-list
  119. :data-source="currentEditorJson?.page ?? []"
  120. size="small"
  121. class="page-list"
  122. >
  123. <template #renderItem="{ item }">
  124. <a-list-item
  125. :class="{ 'page-item-active': selectedPage?.name === item.name }"
  126. class="page-list-item"
  127. @click="selectedPage = item"
  128. >
  129. <a-list-item-meta>
  130. <template #title>{{ item.title || item.name }}</template>
  131. <template #description>{{ item.name }} · {{ item.content?.type }}</template>
  132. </a-list-item-meta>
  133. <a-button
  134. type="text"
  135. danger
  136. size="small"
  137. class="page-item-delete"
  138. @click.stop="confirmDeletePage(item)"
  139. >
  140. <DeleteOutlined />
  141. </a-button>
  142. </a-list-item>
  143. </template>
  144. </a-list>
  145. </div>
  146. <!-- 左二:属性编辑器 -->
  147. <div class="panel panel-props">
  148. <div class="panel-title panel-title-with-action">
  149. 属性编辑
  150. <a-button type="primary" size="small" @click="($refs.previewRef as any)?.refresh()">刷新</a-button>
  151. </div>
  152. <div v-if="!selectedPage" class="panel-empty">请选择页面</div>
  153. <PropsEditorTree
  154. v-else
  155. :page="selectedPage"
  156. v-model:testDetailId="testDetailId"
  157. />
  158. </div>
  159. <!-- 中间:预览 -->
  160. <div class="panel panel-preview">
  161. <div class="panel-title">小程序预览</div>
  162. <div class="preview-wrap">
  163. <EditorPreview
  164. ref="previewRef"
  165. :editor-json="currentEditorJson"
  166. :selected-page="selectedPage"
  167. :test-detail-id="testDetailId"
  168. />
  169. </div>
  170. </div>
  171. </div>
  172. </div>
  173. </a-config-provider>
  174. </template>
  175. <script setup lang="ts">
  176. import { computed, onMounted, provide, ref } from 'vue';
  177. import { ObjectUtils } from '@imengyu/imengyu-utils';
  178. import { DownOutlined, DownloadOutlined, InfoCircleFilled, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
  179. import { message, Modal } from 'ant-design-vue';
  180. import type { IHomeCommonCategoryDefine, IHomeCommonCategoryDetailDefine, IHomeCommonCategoryListDefine, IHomeCommonCategoryHomeDefine } from '../CommonCategoryDefine';
  181. import DefaultEditorJson from '../DefaultCategory.json';
  182. import PropsEditorTree from './subpart/PropsEditorTree.vue';
  183. import EditorPreview from './subpart/EditorPreview.vue';
  184. import zhCN from 'ant-design-vue/es/locale/zh_CN';
  185. import CommonCategoryApi, { type ICommonCategoryConfigItem } from '../api/CommonCategoryApi';
  186. /** 历史版本列表项(接口返回的 items 元素) */
  187. interface IHistoryListItem {
  188. id?: number;
  189. data?: IHomeCommonCategoryDefine;
  190. createTime?: string;
  191. [key: string]: any;
  192. }
  193. const currentEditorJson = ref<IHomeCommonCategoryDefine>(DefaultEditorJson as IHomeCommonCategoryDefine);
  194. const selectedPage = ref<(IHomeCommonCategoryDefine['page'][0]) | null>(null);
  195. const pageList = computed(() => currentEditorJson.value.page || []);
  196. const historyList = ref<IHistoryListItem[]>([]);
  197. const currentConfig = ref<ICommonCategoryConfigItem>();
  198. const currentHistoryId = ref<number>(0);
  199. const currentShowConfigName = computed(() => {
  200. if (currentHistoryId.value === 0)
  201. return '默认配置';
  202. else
  203. return historyList.value.find(item => item.id === currentHistoryId.value)?.name ?? '未知';
  204. });
  205. const testDetailId = ref<number>(0);
  206. const saveAsModalVisible = ref(false);
  207. const saveAsVersionName = ref('');
  208. const saveAsLoading = ref(false);
  209. const addPageModalVisible = ref(false);
  210. const addPageTemplate = ref<'Home' | 'CommonList' | 'Details' | null>(null);
  211. const addPageKey = ref('');
  212. const addPageTitle = ref('');
  213. const addPageLoading = ref(false);
  214. provide('pageList', pageList);
  215. /** 生成新页面唯一 name */
  216. function getUniquePageName(prefix: string): string {
  217. const pages = currentEditorJson.value?.page ?? [];
  218. const names = new Set(pages.map(p => p.name));
  219. let name = prefix;
  220. let n = 1;
  221. while (names.has(name)) {
  222. name = `${prefix}_${n}`;
  223. n++;
  224. }
  225. return name;
  226. }
  227. /** Home 模板 */
  228. function createHomePageTemplate(): IHomeCommonCategoryHomeDefine {
  229. return {
  230. type: 'Home',
  231. props: {
  232. title: '首页',
  233. subTitle: '',
  234. homeBanner: '',
  235. homeButtons: [],
  236. categorys: [],
  237. },
  238. };
  239. }
  240. /** CommonList 模板 */
  241. function createCommonListPageTemplate(): IHomeCommonCategoryListDefine {
  242. return {
  243. type: 'CommonList',
  244. props: {
  245. showTab: true,
  246. showSearch: true,
  247. showTotal: true,
  248. tabs: [],
  249. },
  250. };
  251. }
  252. /** Details 模板 */
  253. function createDetailPageTemplate(): IHomeCommonCategoryDetailDefine {
  254. return {
  255. type: 'Details',
  256. props: {
  257. showHead: true,
  258. introBlockDescs: [],
  259. introBlocks: [],
  260. tabs: [],
  261. },
  262. };
  263. }
  264. function onAddPageMenuClick(e: { key: string }) {
  265. const template = e.key as 'Home' | 'CommonList' | 'Details';
  266. addPageTemplate.value = template;
  267. const prefixMap = { Home: 'home', CommonList: 'list', Details: 'detail' } as const;
  268. const titleMap = { Home: '首页', CommonList: '列表页', Details: '详情页' } as const;
  269. addPageKey.value = getUniquePageName(prefixMap[template] ?? 'page');
  270. addPageTitle.value = titleMap[template] ?? '页面';
  271. addPageModalVisible.value = true;
  272. }
  273. function confirmAddPage(): Promise<void> | void {
  274. const key = addPageKey.value?.trim();
  275. const title = addPageTitle.value?.trim();
  276. if (!key) {
  277. message.warning('请输入 key');
  278. return Promise.reject();
  279. }
  280. if (!title) {
  281. message.warning('请输入 title');
  282. return Promise.reject();
  283. }
  284. const pages = currentEditorJson.value?.page ?? [];
  285. const exists = pages.some(p => p.name === key);
  286. if (exists) {
  287. message.warning(`key「${key}」已存在,请使用其他 key`);
  288. return Promise.reject();
  289. }
  290. const template = addPageTemplate.value;
  291. if (!template) return Promise.reject();
  292. addPageLoading.value = true;
  293. let content: IHomeCommonCategoryHomeDefine | IHomeCommonCategoryListDefine | IHomeCommonCategoryDetailDefine;
  294. switch (template) {
  295. case 'Home':
  296. content = createHomePageTemplate();
  297. break;
  298. case 'CommonList':
  299. content = createCommonListPageTemplate();
  300. break;
  301. case 'Details':
  302. content = createDetailPageTemplate();
  303. break;
  304. default:
  305. message.warning(`不支持的模板:${template}`);
  306. return Promise.reject();
  307. }
  308. currentEditorJson.value = {
  309. ...currentEditorJson.value,
  310. page: [...pages, { name: key, title, content }],
  311. };
  312. const added = currentEditorJson.value.page[currentEditorJson.value.page.length - 1];
  313. selectedPage.value = added;
  314. addPageModalVisible.value = false;
  315. addPageLoading.value = false;
  316. message.success(`已添加页面 ${title} (${key})`);
  317. }
  318. function confirmDeletePage(page: IHomeCommonCategoryDefine['page'][0]) {
  319. Modal.confirm({
  320. title: '确认删除',
  321. content: `确定要删除页面「${page.title || page.name}」吗?删除后不可恢复。`,
  322. okText: '删除',
  323. okType: 'danger',
  324. cancelText: '取消',
  325. onOk() {
  326. const pages = currentEditorJson.value?.page ?? [];
  327. const index = pages.findIndex(p => p.name === page.name);
  328. if (index === -1) return;
  329. const next = pages.filter(p => p.name !== page.name);
  330. currentEditorJson.value = { ...currentEditorJson.value, page: next };
  331. if (selectedPage.value?.name === page.name)
  332. selectedPage.value = next[0] ?? null;
  333. message.success('已删除页面');
  334. },
  335. });
  336. }
  337. async function loadEditorJson(selectDefault = false) {
  338. try {
  339. //加载基础配置
  340. currentConfig.value = await CommonCategoryApi.getConfigWithoutCache();
  341. if (!currentConfig.value)
  342. throw new Error('加载基础配置失败');
  343. if (selectDefault)
  344. currentHistoryId.value = currentConfig.value.activeHistoryId;
  345. //根据activeHistoryId选择当前激活的历史版本
  346. if (currentHistoryId.value > 0) {
  347. const activeHistory = historyList.value.find(item => item.id === currentHistoryId.value);
  348. if (activeHistory)
  349. currentEditorJson.value = ObjectUtils.clone(activeHistory.data!) as IHomeCommonCategoryDefine;
  350. else
  351. throw new Error('当前激活的历史版本不存在');
  352. } else {
  353. currentEditorJson.value = ObjectUtils.clone(currentConfig.value.data) as IHomeCommonCategoryDefine;
  354. }
  355. message.success('加载分类成功');
  356. } catch (error) {
  357. Modal.error({
  358. title: '加载分类失败',
  359. content: '' + error,
  360. });
  361. }
  362. }
  363. async function loadEditorJsonHistorys() {
  364. try {
  365. const res = await CommonCategoryApi.getConfigHistoryList(1, 10);
  366. const items = res?.items ?? [];
  367. historyList.value = Array.isArray(items) ? items : [];
  368. } catch (error) {
  369. historyList.value = [];
  370. Modal.error({
  371. title: '加载历史版本列表失败',
  372. content: '' + error,
  373. });
  374. }
  375. }
  376. function onSelectDefault() {
  377. currentHistoryId.value = 0;
  378. loadEditorJson(true);
  379. }
  380. function onSelectHistory(id: number) {
  381. currentHistoryId.value = id;
  382. loadEditorJson(false);
  383. }
  384. /** 默认保存:根据 currentHistoryId 保存到默认配置或指定历史版本 */
  385. async function saveEditorJson() {
  386. try {
  387. const saveToHistoryId = currentHistoryId.value === 0 ? undefined : currentHistoryId.value;
  388. await CommonCategoryApi.editConfig(
  389. currentEditorJson.value,
  390. undefined,
  391. saveToHistoryId
  392. );
  393. message.success('保存成功');
  394. } catch (error) {
  395. Modal.error({
  396. title: '保存失败',
  397. content: '' + error,
  398. });
  399. }
  400. }
  401. function openSaveAsModal() {
  402. saveAsVersionName.value = '';
  403. saveAsModalVisible.value = true;
  404. }
  405. async function confirmSaveAs() {
  406. const name = saveAsVersionName.value?.trim();
  407. if (!name) {
  408. message.warning('请输入版本名称');
  409. return;
  410. }
  411. saveAsLoading.value = true;
  412. try {
  413. await CommonCategoryApi.editConfig(
  414. currentEditorJson.value,
  415. name,
  416. 0
  417. );
  418. message.success('已另存为历史版本');
  419. saveAsModalVisible.value = false;
  420. await loadEditorJsonHistorys();
  421. } catch (error) {
  422. Modal.error({
  423. title: '另存为失败',
  424. content: '' + error,
  425. });
  426. } finally {
  427. saveAsLoading.value = false;
  428. }
  429. }
  430. async function setActiveHistory() {
  431. await CommonCategoryApi.setActiveConfigHistory(currentHistoryId.value);
  432. message.success('设置为激活版本成功');
  433. }
  434. async function deleteHistory() {
  435. Modal.confirm({
  436. title: '删除历史版本',
  437. content: '确定要删除该历史版本吗?',
  438. onOk: async () => {
  439. await CommonCategoryApi.deleteConfigHistory(currentHistoryId.value);
  440. message.success('删除历史版本成功');
  441. await loadEditorJsonHistorys();
  442. await loadEditorJson(true);
  443. },
  444. });
  445. }
  446. function exportToJsonFile() {
  447. const json = JSON.stringify(currentEditorJson.value);
  448. const blob = new Blob([json], { type: 'application/json' });
  449. const url = URL.createObjectURL(blob);
  450. const a = document.createElement('a');
  451. a.href = url;
  452. a.download = 'editor.json';
  453. a.click();
  454. }
  455. onMounted(async () => {
  456. await loadEditorJsonHistorys();
  457. await loadEditorJson(true);
  458. window.addEventListener('beforeunload', function(e) {
  459. const message = '你还有未保存的内容,确定要离开吗?';
  460. e.returnValue = message;
  461. return message;
  462. });
  463. })
  464. </script>
  465. <style scoped>
  466. .miniprogram-editor {
  467. height: 100vh;
  468. display: flex;
  469. flex-direction: column;
  470. background: #f5f5f5;
  471. }
  472. .editor-toolbar {
  473. padding: 8px 16px;
  474. background: #fff;
  475. border-bottom: 1px solid #eee;
  476. display: flex;
  477. justify-content: space-between;
  478. align-items: center;
  479. }
  480. .editor-body {
  481. flex: 1;
  482. display: flex;
  483. overflow: hidden;
  484. min-height: 0;
  485. }
  486. .panel {
  487. background: #fff;
  488. display: flex;
  489. flex-direction: column;
  490. overflow: hidden;
  491. border-right: 1px solid #eee;
  492. }
  493. .panel:last-of-type {
  494. border-right: none;
  495. }
  496. .panel-title {
  497. padding: 8px 12px;
  498. font-weight: 600;
  499. border-bottom: 1px solid #eee;
  500. flex-shrink: 0;
  501. }
  502. .panel-title-with-action {
  503. display: flex;
  504. align-items: center;
  505. justify-content: space-between;
  506. gap: 8px;
  507. }
  508. .panel-pages {
  509. width: 220px;
  510. flex-shrink: 0;
  511. }
  512. .panel-props {
  513. width: 720px;
  514. flex-shrink: 0;
  515. }
  516. .panel-preview {
  517. flex: 1;
  518. min-width: 0;
  519. display: flex;
  520. flex-direction: column;
  521. align-items: center;
  522. justify-content: center;
  523. padding: 16px;
  524. overflow: auto;
  525. }
  526. .panel-empty {
  527. padding: 16px;
  528. color: #999;
  529. font-size: 12px;
  530. }
  531. .page-list {
  532. flex: 1;
  533. overflow: auto;
  534. }
  535. .page-list :deep(.ant-list-item) {
  536. cursor: pointer;
  537. padding: 8px 12px;
  538. }
  539. .page-list-item :deep(.ant-list-item-meta) {
  540. flex: 1;
  541. min-width: 0;
  542. }
  543. .page-list-item {
  544. display: flex;
  545. align-items: center;
  546. gap: 4px;
  547. }
  548. .page-item-delete {
  549. flex-shrink: 0;
  550. padding: 0 4px;
  551. }
  552. .page-item-active {
  553. background: #e6f7ff;
  554. }
  555. .preview-wrap {
  556. width: 100%;
  557. max-width: 450px;
  558. aspect-ratio: 9 / 16;
  559. overflow: auto;
  560. background: #fff;
  561. border-radius: 8px;
  562. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  563. margin: 0 auto;
  564. }
  565. </style>