MiniProgramEditor.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597
  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. hasInternalTabs: false,
  259. introBlockDescs: [],
  260. introBlocks: [],
  261. tabs: [],
  262. },
  263. };
  264. }
  265. function onAddPageMenuClick(e: { key: string }) {
  266. const template = e.key as 'Home' | 'CommonList' | 'Details';
  267. addPageTemplate.value = template;
  268. const prefixMap = { Home: 'home', CommonList: 'list', Details: 'detail' } as const;
  269. const titleMap = { Home: '首页', CommonList: '列表页', Details: '详情页' } as const;
  270. addPageKey.value = getUniquePageName(prefixMap[template] ?? 'page');
  271. addPageTitle.value = titleMap[template] ?? '页面';
  272. addPageModalVisible.value = true;
  273. }
  274. function confirmAddPage(): Promise<void> | void {
  275. const key = addPageKey.value?.trim();
  276. const title = addPageTitle.value?.trim();
  277. if (!key) {
  278. message.warning('请输入 key');
  279. return Promise.reject();
  280. }
  281. if (!title) {
  282. message.warning('请输入 title');
  283. return Promise.reject();
  284. }
  285. const pages = currentEditorJson.value?.page ?? [];
  286. const exists = pages.some(p => p.name === key);
  287. if (exists) {
  288. message.warning(`key「${key}」已存在,请使用其他 key`);
  289. return Promise.reject();
  290. }
  291. const template = addPageTemplate.value;
  292. if (!template) return Promise.reject();
  293. addPageLoading.value = true;
  294. let content: IHomeCommonCategoryHomeDefine | IHomeCommonCategoryListDefine | IHomeCommonCategoryDetailDefine;
  295. switch (template) {
  296. case 'Home':
  297. content = createHomePageTemplate();
  298. break;
  299. case 'CommonList':
  300. content = createCommonListPageTemplate();
  301. break;
  302. case 'Details':
  303. content = createDetailPageTemplate();
  304. break;
  305. default:
  306. message.warning(`不支持的模板:${template}`);
  307. return Promise.reject();
  308. }
  309. currentEditorJson.value = {
  310. ...currentEditorJson.value,
  311. page: [...pages, { name: key, title, content }],
  312. };
  313. const added = currentEditorJson.value.page[currentEditorJson.value.page.length - 1];
  314. selectedPage.value = added;
  315. addPageModalVisible.value = false;
  316. addPageLoading.value = false;
  317. message.success(`已添加页面 ${title} (${key})`);
  318. }
  319. function confirmDeletePage(page: IHomeCommonCategoryDefine['page'][0]) {
  320. Modal.confirm({
  321. title: '确认删除',
  322. content: `确定要删除页面「${page.title || page.name}」吗?删除后不可恢复。`,
  323. okText: '删除',
  324. okType: 'danger',
  325. cancelText: '取消',
  326. onOk() {
  327. const pages = currentEditorJson.value?.page ?? [];
  328. const index = pages.findIndex(p => p.name === page.name);
  329. if (index === -1) return;
  330. const next = pages.filter(p => p.name !== page.name);
  331. currentEditorJson.value = { ...currentEditorJson.value, page: next };
  332. if (selectedPage.value?.name === page.name)
  333. selectedPage.value = next[0] ?? null;
  334. message.success('已删除页面');
  335. },
  336. });
  337. }
  338. async function loadEditorJson(selectDefault = false) {
  339. try {
  340. //加载基础配置
  341. currentConfig.value = await CommonCategoryApi.getConfigWithoutCache();
  342. if (!currentConfig.value)
  343. throw new Error('加载基础配置失败');
  344. if (selectDefault)
  345. currentHistoryId.value = currentConfig.value.activeHistoryId;
  346. //根据activeHistoryId选择当前激活的历史版本
  347. if (currentHistoryId.value > 0) {
  348. const activeHistory = historyList.value.find(item => item.id === currentHistoryId.value);
  349. if (activeHistory)
  350. currentEditorJson.value = ObjectUtils.clone(activeHistory.data!) as IHomeCommonCategoryDefine;
  351. else
  352. throw new Error('当前激活的历史版本不存在');
  353. } else {
  354. currentEditorJson.value = ObjectUtils.clone(currentConfig.value.data) as IHomeCommonCategoryDefine;
  355. }
  356. message.success('加载分类成功');
  357. } catch (error) {
  358. Modal.error({
  359. title: '加载分类失败',
  360. content: '' + error,
  361. });
  362. }
  363. }
  364. async function loadEditorJsonHistorys() {
  365. try {
  366. const res = await CommonCategoryApi.getConfigHistoryList(1, 10);
  367. const items = res?.items ?? [];
  368. historyList.value = Array.isArray(items) ? items : [];
  369. } catch (error) {
  370. historyList.value = [];
  371. Modal.error({
  372. title: '加载历史版本列表失败',
  373. content: '' + error,
  374. });
  375. }
  376. }
  377. function onSelectDefault() {
  378. currentHistoryId.value = 0;
  379. loadEditorJson(true);
  380. }
  381. function onSelectHistory(id: number) {
  382. currentHistoryId.value = id;
  383. loadEditorJson(false);
  384. }
  385. /** 默认保存:根据 currentHistoryId 保存到默认配置或指定历史版本 */
  386. async function saveEditorJson() {
  387. try {
  388. const saveToHistoryId = currentHistoryId.value === 0 ? undefined : currentHistoryId.value;
  389. await CommonCategoryApi.editConfig(
  390. currentEditorJson.value,
  391. undefined,
  392. saveToHistoryId
  393. );
  394. message.success('保存成功');
  395. } catch (error) {
  396. Modal.error({
  397. title: '保存失败',
  398. content: '' + error,
  399. });
  400. }
  401. }
  402. function openSaveAsModal() {
  403. saveAsVersionName.value = '';
  404. saveAsModalVisible.value = true;
  405. }
  406. async function confirmSaveAs() {
  407. const name = saveAsVersionName.value?.trim();
  408. if (!name) {
  409. message.warning('请输入版本名称');
  410. return;
  411. }
  412. saveAsLoading.value = true;
  413. try {
  414. await CommonCategoryApi.editConfig(
  415. currentEditorJson.value,
  416. name,
  417. 0
  418. );
  419. message.success('已另存为历史版本');
  420. saveAsModalVisible.value = false;
  421. await loadEditorJsonHistorys();
  422. } catch (error) {
  423. Modal.error({
  424. title: '另存为失败',
  425. content: '' + error,
  426. });
  427. } finally {
  428. saveAsLoading.value = false;
  429. }
  430. }
  431. async function setActiveHistory() {
  432. await CommonCategoryApi.setActiveConfigHistory(currentHistoryId.value);
  433. message.success('设置为激活版本成功');
  434. }
  435. async function deleteHistory() {
  436. Modal.confirm({
  437. title: '删除历史版本',
  438. content: '确定要删除该历史版本吗?',
  439. onOk: async () => {
  440. await CommonCategoryApi.deleteConfigHistory(currentHistoryId.value);
  441. message.success('删除历史版本成功');
  442. await loadEditorJsonHistorys();
  443. await loadEditorJson(true);
  444. },
  445. });
  446. }
  447. function exportToJsonFile() {
  448. const json = JSON.stringify(currentEditorJson.value);
  449. const blob = new Blob([json], { type: 'application/json' });
  450. const url = URL.createObjectURL(blob);
  451. const a = document.createElement('a');
  452. a.href = url;
  453. a.download = 'editor.json';
  454. a.click();
  455. }
  456. onMounted(async () => {
  457. await loadEditorJsonHistorys();
  458. await loadEditorJson(true);
  459. window.addEventListener('beforeunload', function(e) {
  460. const message = '你还有未保存的内容,确定要离开吗?';
  461. e.returnValue = message;
  462. return message;
  463. });
  464. })
  465. </script>
  466. <style scoped>
  467. .miniprogram-editor {
  468. height: 100vh;
  469. display: flex;
  470. flex-direction: column;
  471. background: #f5f5f5;
  472. }
  473. .editor-toolbar {
  474. padding: 8px 16px;
  475. background: #fff;
  476. border-bottom: 1px solid #eee;
  477. display: flex;
  478. justify-content: space-between;
  479. align-items: center;
  480. }
  481. .editor-body {
  482. flex: 1;
  483. display: flex;
  484. overflow: hidden;
  485. min-height: 0;
  486. }
  487. .panel {
  488. background: #fff;
  489. display: flex;
  490. flex-direction: column;
  491. overflow: hidden;
  492. border-right: 1px solid #eee;
  493. }
  494. .panel:last-of-type {
  495. border-right: none;
  496. }
  497. .panel-title {
  498. padding: 8px 12px;
  499. font-weight: 600;
  500. border-bottom: 1px solid #eee;
  501. flex-shrink: 0;
  502. }
  503. .panel-title-with-action {
  504. display: flex;
  505. align-items: center;
  506. justify-content: space-between;
  507. gap: 8px;
  508. }
  509. .panel-pages {
  510. width: 220px;
  511. flex-shrink: 0;
  512. }
  513. .panel-props {
  514. width: 720px;
  515. flex-shrink: 0;
  516. }
  517. .panel-preview {
  518. flex: 1;
  519. min-width: 0;
  520. display: flex;
  521. flex-direction: column;
  522. align-items: center;
  523. justify-content: center;
  524. padding: 16px;
  525. overflow: auto;
  526. }
  527. .panel-empty {
  528. padding: 16px;
  529. color: #999;
  530. font-size: 12px;
  531. }
  532. .page-list {
  533. flex: 1;
  534. overflow: auto;
  535. }
  536. .page-list :deep(.ant-list-item) {
  537. cursor: pointer;
  538. padding: 8px 12px;
  539. }
  540. .page-list-item :deep(.ant-list-item-meta) {
  541. flex: 1;
  542. min-width: 0;
  543. }
  544. .page-list-item {
  545. display: flex;
  546. align-items: center;
  547. gap: 4px;
  548. }
  549. .page-item-delete {
  550. flex-shrink: 0;
  551. padding: 0 4px;
  552. }
  553. .page-item-active {
  554. background: #e6f7ff;
  555. }
  556. .preview-wrap {
  557. width: 100%;
  558. max-width: 450px;
  559. aspect-ratio: 9 / 16;
  560. overflow: auto;
  561. background: #fff;
  562. border-radius: 8px;
  563. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  564. margin: 0 auto;
  565. }
  566. </style>