| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597 |
- <template>
- <a-config-provider :locale="zhCN">
- <div class="miniprogram-editor">
- <div class="editor-toolbar">
- <a-space>
- <a-dropdown>
- <a-button>
- 加载
- <DownOutlined />
- </a-button>
- <template #overlay>
- <a-menu v-if="historyList.length">
- <a-menu-item @click="onSelectDefault">
- 选择默认配置
- <a-badge v-if="currentConfig?.activeHistoryId === 0" count="激活" />
- </a-menu-item>
- <a-menu-item
- v-for="(item, index) in historyList"
- :key="index"
- @click="onSelectHistory(item.id!)"
- >
- {{ item.name }}
- <a-badge v-if="item.id === currentConfig?.activeHistoryId" count="激活" />
- </a-menu-item>
- </a-menu>
- <a-menu v-else>
- <a-menu-item disabled>暂无历史版本</a-menu-item>
- </a-menu>
- </template>
- </a-dropdown>
- <a-dropdown-button type="primary" @click="saveEditorJson">
- 保存
- <template #overlay>
- <a-menu>
- <a-menu-item @click="openSaveAsModal">另存为历史版本</a-menu-item>
- </a-menu>
- </template>
- </a-dropdown-button>
- <div>
- <InfoCircleFilled />
- 当前显示配置:
- <span>{{ currentShowConfigName }}</span>
- </div>
- <a-button v-if="currentConfig" @click="setActiveHistory">
- {{ currentConfig.activeHistoryId === currentHistoryId ? '已经是激活版本' : '设置为激活版本' }}
- </a-button>
- <a-button v-if="currentConfig && currentHistoryId !== 0" danger @click="deleteHistory">删除历史版本</a-button>
- </a-space>
- <a-button @click="exportToJsonFile">
- 导出为JSON文件
- <DownloadOutlined />
- </a-button>
- </div>
- <!-- 另存为历史版本弹框 -->
- <a-modal
- v-model:open="saveAsModalVisible"
- title="另存为历史版本"
- ok-text="保存"
- cancel-text="取消"
- :confirm-loading="saveAsLoading"
- @ok="confirmSaveAs"
- >
- <a-form layout="vertical" class="save-as-form">
- <a-form-item label="版本名称">
- <a-input
- v-model:value="saveAsVersionName"
- placeholder="请输入版本名称"
- allow-clear
- />
- </a-form-item>
- </a-form>
- </a-modal>
- <!-- 添加页面弹框 -->
- <a-modal
- v-model:open="addPageModalVisible"
- title="添加页面"
- ok-text="添加"
- cancel-text="取消"
- :confirm-loading="addPageLoading"
- @ok="confirmAddPage"
- >
- <a-form layout="vertical" class="add-page-form">
- <a-form-item label="key(页面唯一标识)" required>
- <a-input
- v-model:value="addPageKey"
- placeholder="如 home、list_1"
- allow-clear
- />
- </a-form-item>
- <a-form-item label="title(页面标题)" required>
- <a-input
- v-model:value="addPageTitle"
- placeholder="如 首页、列表页"
- allow-clear
- />
- </a-form-item>
- <a-form-item v-if="addPageTemplate" label="模板">
- <span>{{ addPageTemplate }}</span>
- </a-form-item>
- </a-form>
- </a-modal>
- <div class="editor-body">
- <!-- 左一:页面列表 -->
- <div class="panel panel-pages">
- <div class="panel-title panel-title-with-action">
- <span>页面列表</span>
- <a-dropdown size="small">
- <PlusOutlined />
- <template #overlay>
- <a-menu @click="onAddPageMenuClick">
- <a-menu-item key="Home">Home</a-menu-item>
- <a-menu-item key="CommonList">CommonList</a-menu-item>
- <a-menu-item key="Details">Details</a-menu-item>
- </a-menu>
- </template>
- </a-dropdown>
- </div>
- <a-list
- :data-source="currentEditorJson?.page ?? []"
- size="small"
- class="page-list"
- >
- <template #renderItem="{ item }">
- <a-list-item
- :class="{ 'page-item-active': selectedPage?.name === item.name }"
- class="page-list-item"
- @click="selectedPage = item"
- >
- <a-list-item-meta>
- <template #title>{{ item.title || item.name }}</template>
- <template #description>{{ item.name }} · {{ item.content?.type }}</template>
- </a-list-item-meta>
- <a-button
- type="text"
- danger
- size="small"
- class="page-item-delete"
- @click.stop="confirmDeletePage(item)"
- >
- <DeleteOutlined />
- </a-button>
- </a-list-item>
- </template>
- </a-list>
- </div>
- <!-- 左二:属性编辑器 -->
- <div class="panel panel-props">
- <div class="panel-title panel-title-with-action">
- 属性编辑
- <a-button type="primary" size="small" @click="($refs.previewRef as any)?.refresh()">刷新</a-button>
- </div>
- <div v-if="!selectedPage" class="panel-empty">请选择页面</div>
- <PropsEditorTree
- v-else
- :page="selectedPage"
- v-model:testDetailId="testDetailId"
- />
- </div>
- <!-- 中间:预览 -->
- <div class="panel panel-preview">
- <div class="panel-title">小程序预览</div>
- <div class="preview-wrap">
- <EditorPreview
- ref="previewRef"
- :editor-json="currentEditorJson"
- :selected-page="selectedPage"
- :test-detail-id="testDetailId"
- />
- </div>
- </div>
- </div>
- </div>
- </a-config-provider>
- </template>
- <script setup lang="ts">
- import { computed, onMounted, provide, ref } from 'vue';
- import { ObjectUtils } from '@imengyu/imengyu-utils';
- import { DownOutlined, DownloadOutlined, InfoCircleFilled, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
- import { message, Modal } from 'ant-design-vue';
- import type { IHomeCommonCategoryDefine, IHomeCommonCategoryDetailDefine, IHomeCommonCategoryListDefine, IHomeCommonCategoryHomeDefine } from '../CommonCategoryDefine';
- import DefaultEditorJson from '../DefaultCategory.json';
- import PropsEditorTree from './subpart/PropsEditorTree.vue';
- import EditorPreview from './subpart/EditorPreview.vue';
- import zhCN from 'ant-design-vue/es/locale/zh_CN';
- import CommonCategoryApi, { type ICommonCategoryConfigItem } from '../api/CommonCategoryApi';
- /** 历史版本列表项(接口返回的 items 元素) */
- interface IHistoryListItem {
- id?: number;
- data?: IHomeCommonCategoryDefine;
- createTime?: string;
- [key: string]: any;
- }
- const currentEditorJson = ref<IHomeCommonCategoryDefine>(DefaultEditorJson as IHomeCommonCategoryDefine);
- const selectedPage = ref<(IHomeCommonCategoryDefine['page'][0]) | null>(null);
- const pageList = computed(() => currentEditorJson.value.page || []);
- const historyList = ref<IHistoryListItem[]>([]);
- const currentConfig = ref<ICommonCategoryConfigItem>();
- const currentHistoryId = ref<number>(0);
- const currentShowConfigName = computed(() => {
- if (currentHistoryId.value === 0)
- return '默认配置';
- else
- return historyList.value.find(item => item.id === currentHistoryId.value)?.name ?? '未知';
- });
- const testDetailId = ref<number>(0);
- const saveAsModalVisible = ref(false);
- const saveAsVersionName = ref('');
- const saveAsLoading = ref(false);
- const addPageModalVisible = ref(false);
- const addPageTemplate = ref<'Home' | 'CommonList' | 'Details' | null>(null);
- const addPageKey = ref('');
- const addPageTitle = ref('');
- const addPageLoading = ref(false);
- provide('pageList', pageList);
- /** 生成新页面唯一 name */
- function getUniquePageName(prefix: string): string {
- const pages = currentEditorJson.value?.page ?? [];
- const names = new Set(pages.map(p => p.name));
- let name = prefix;
- let n = 1;
- while (names.has(name)) {
- name = `${prefix}_${n}`;
- n++;
- }
- return name;
- }
- /** Home 模板 */
- function createHomePageTemplate(): IHomeCommonCategoryHomeDefine {
- return {
- type: 'Home',
- props: {
- title: '首页',
- subTitle: '',
- homeBanner: '',
- homeButtons: [],
- categorys: [],
- },
- };
- }
- /** CommonList 模板 */
- function createCommonListPageTemplate(): IHomeCommonCategoryListDefine {
- return {
- type: 'CommonList',
- props: {
- showTab: true,
- showSearch: true,
- showTotal: true,
- tabs: [],
- },
- };
- }
- /** Details 模板 */
- function createDetailPageTemplate(): IHomeCommonCategoryDetailDefine {
- return {
- type: 'Details',
- props: {
- showHead: true,
- hasInternalTabs: false,
- introBlockDescs: [],
- introBlocks: [],
- tabs: [],
- },
- };
- }
- function onAddPageMenuClick(e: { key: string }) {
- const template = e.key as 'Home' | 'CommonList' | 'Details';
- addPageTemplate.value = template;
- const prefixMap = { Home: 'home', CommonList: 'list', Details: 'detail' } as const;
- const titleMap = { Home: '首页', CommonList: '列表页', Details: '详情页' } as const;
- addPageKey.value = getUniquePageName(prefixMap[template] ?? 'page');
- addPageTitle.value = titleMap[template] ?? '页面';
- addPageModalVisible.value = true;
- }
- function confirmAddPage(): Promise<void> | void {
- const key = addPageKey.value?.trim();
- const title = addPageTitle.value?.trim();
- if (!key) {
- message.warning('请输入 key');
- return Promise.reject();
- }
- if (!title) {
- message.warning('请输入 title');
- return Promise.reject();
- }
- const pages = currentEditorJson.value?.page ?? [];
- const exists = pages.some(p => p.name === key);
- if (exists) {
- message.warning(`key「${key}」已存在,请使用其他 key`);
- return Promise.reject();
- }
- const template = addPageTemplate.value;
- if (!template) return Promise.reject();
- addPageLoading.value = true;
- let content: IHomeCommonCategoryHomeDefine | IHomeCommonCategoryListDefine | IHomeCommonCategoryDetailDefine;
- switch (template) {
- case 'Home':
- content = createHomePageTemplate();
- break;
- case 'CommonList':
- content = createCommonListPageTemplate();
- break;
- case 'Details':
- content = createDetailPageTemplate();
- break;
- default:
- message.warning(`不支持的模板:${template}`);
- return Promise.reject();
- }
- currentEditorJson.value = {
- ...currentEditorJson.value,
- page: [...pages, { name: key, title, content }],
- };
- const added = currentEditorJson.value.page[currentEditorJson.value.page.length - 1];
- selectedPage.value = added;
- addPageModalVisible.value = false;
- addPageLoading.value = false;
- message.success(`已添加页面 ${title} (${key})`);
- }
- function confirmDeletePage(page: IHomeCommonCategoryDefine['page'][0]) {
- Modal.confirm({
- title: '确认删除',
- content: `确定要删除页面「${page.title || page.name}」吗?删除后不可恢复。`,
- okText: '删除',
- okType: 'danger',
- cancelText: '取消',
- onOk() {
- const pages = currentEditorJson.value?.page ?? [];
- const index = pages.findIndex(p => p.name === page.name);
- if (index === -1) return;
- const next = pages.filter(p => p.name !== page.name);
- currentEditorJson.value = { ...currentEditorJson.value, page: next };
- if (selectedPage.value?.name === page.name)
- selectedPage.value = next[0] ?? null;
- message.success('已删除页面');
- },
- });
- }
- async function loadEditorJson(selectDefault = false) {
- try {
- //加载基础配置
- currentConfig.value = await CommonCategoryApi.getConfigWithoutCache();
- if (!currentConfig.value)
- throw new Error('加载基础配置失败');
- if (selectDefault)
- currentHistoryId.value = currentConfig.value.activeHistoryId;
- //根据activeHistoryId选择当前激活的历史版本
- if (currentHistoryId.value > 0) {
- const activeHistory = historyList.value.find(item => item.id === currentHistoryId.value);
- if (activeHistory)
- currentEditorJson.value = ObjectUtils.clone(activeHistory.data!) as IHomeCommonCategoryDefine;
- else
- throw new Error('当前激活的历史版本不存在');
- } else {
- currentEditorJson.value = ObjectUtils.clone(currentConfig.value.data) as IHomeCommonCategoryDefine;
- }
- message.success('加载分类成功');
- } catch (error) {
- Modal.error({
- title: '加载分类失败',
- content: '' + error,
- });
- }
- }
- async function loadEditorJsonHistorys() {
- try {
- const res = await CommonCategoryApi.getConfigHistoryList(1, 10);
- const items = res?.items ?? [];
- historyList.value = Array.isArray(items) ? items : [];
- } catch (error) {
- historyList.value = [];
- Modal.error({
- title: '加载历史版本列表失败',
- content: '' + error,
- });
- }
- }
- function onSelectDefault() {
- currentHistoryId.value = 0;
- loadEditorJson(true);
- }
- function onSelectHistory(id: number) {
- currentHistoryId.value = id;
- loadEditorJson(false);
- }
- /** 默认保存:根据 currentHistoryId 保存到默认配置或指定历史版本 */
- async function saveEditorJson() {
- try {
- const saveToHistoryId = currentHistoryId.value === 0 ? undefined : currentHistoryId.value;
- await CommonCategoryApi.editConfig(
- currentEditorJson.value,
- undefined,
- saveToHistoryId
- );
- message.success('保存成功');
- } catch (error) {
- Modal.error({
- title: '保存失败',
- content: '' + error,
- });
- }
- }
- function openSaveAsModal() {
- saveAsVersionName.value = '';
- saveAsModalVisible.value = true;
- }
- async function confirmSaveAs() {
- const name = saveAsVersionName.value?.trim();
- if (!name) {
- message.warning('请输入版本名称');
- return;
- }
- saveAsLoading.value = true;
- try {
- await CommonCategoryApi.editConfig(
- currentEditorJson.value,
- name,
- 0
- );
- message.success('已另存为历史版本');
- saveAsModalVisible.value = false;
- await loadEditorJsonHistorys();
- } catch (error) {
- Modal.error({
- title: '另存为失败',
- content: '' + error,
- });
- } finally {
- saveAsLoading.value = false;
- }
- }
- async function setActiveHistory() {
- await CommonCategoryApi.setActiveConfigHistory(currentHistoryId.value);
- message.success('设置为激活版本成功');
- }
- async function deleteHistory() {
- Modal.confirm({
- title: '删除历史版本',
- content: '确定要删除该历史版本吗?',
- onOk: async () => {
- await CommonCategoryApi.deleteConfigHistory(currentHistoryId.value);
- message.success('删除历史版本成功');
- await loadEditorJsonHistorys();
- await loadEditorJson(true);
- },
- });
- }
- function exportToJsonFile() {
- const json = JSON.stringify(currentEditorJson.value);
- const blob = new Blob([json], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = 'editor.json';
- a.click();
- }
- onMounted(async () => {
- await loadEditorJsonHistorys();
- await loadEditorJson(true);
- window.addEventListener('beforeunload', function(e) {
- const message = '你还有未保存的内容,确定要离开吗?';
- e.returnValue = message;
- return message;
- });
- })
- </script>
- <style scoped>
- .miniprogram-editor {
- height: 100vh;
- display: flex;
- flex-direction: column;
- background: #f5f5f5;
- }
- .editor-toolbar {
- padding: 8px 16px;
- background: #fff;
- border-bottom: 1px solid #eee;
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .editor-body {
- flex: 1;
- display: flex;
- overflow: hidden;
- min-height: 0;
- }
- .panel {
- background: #fff;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- border-right: 1px solid #eee;
- }
- .panel:last-of-type {
- border-right: none;
- }
- .panel-title {
- padding: 8px 12px;
- font-weight: 600;
- border-bottom: 1px solid #eee;
- flex-shrink: 0;
- }
- .panel-title-with-action {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- }
- .panel-pages {
- width: 220px;
- flex-shrink: 0;
- }
- .panel-props {
- width: 720px;
- flex-shrink: 0;
- }
- .panel-preview {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 16px;
- overflow: auto;
- }
- .panel-empty {
- padding: 16px;
- color: #999;
- font-size: 12px;
- }
- .page-list {
- flex: 1;
- overflow: auto;
- }
- .page-list :deep(.ant-list-item) {
- cursor: pointer;
- padding: 8px 12px;
- }
- .page-list-item :deep(.ant-list-item-meta) {
- flex: 1;
- min-width: 0;
- }
- .page-list-item {
- display: flex;
- align-items: center;
- gap: 4px;
- }
- .page-item-delete {
- flex-shrink: 0;
- padding: 0 4px;
- }
- .page-item-active {
- background: #e6f7ff;
- }
- .preview-wrap {
- width: 100%;
- max-width: 450px;
- aspect-ratio: 9 / 16;
- overflow: auto;
- background: #fff;
- border-radius: 8px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- margin: 0 auto;
- }
- </style>
|