快乐的梦鱼 před 3 dny
rodič
revize
fcb47d4078

+ 1 - 1
src/pages/article/data/defines/Home.ts

@@ -38,7 +38,7 @@ export interface IHomeCommonCategoryHomeDefine {
       /**
        * 按钮跳转链接
        */
-      link: [string, object],
+      link: string,
       /**
        * 按钮大小
        */

+ 121 - 549
src/pages/article/data/editor/MiniProgramEditor.vue

@@ -1,495 +1,144 @@
 <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>
+      <EditorToolbar
+        :history-list="historyList"
+        :current-config="currentConfig"
+        :current-history-id="currentHistoryId"
+        :current-show-config-name="currentShowConfigName"
+        v-model:save-as-modal-visible="saveAsModalVisible"
+        v-model:save-as-version-name="saveAsVersionName"
+        :save-as-loading="saveAsLoading"
+        @select-default="onSelectDefault"
+        @select-history="onSelectHistory"
+        @save="saveEditorJson"
+        @open-save-as="openSaveAsModal"
+        @confirm-save-as="confirmSaveAs"
+        @set-active="setActiveHistory"
+        @delete-history="deleteHistory"
+        @export="exportToJsonFile"
+      />
+
+      <AddPageModal
+        :open="addPageModalVisible"
+        :model-key="addPageKey"
+        :model-title="addPageTitle"
+        :template="addPageTemplate"
+        :loading="addPageLoading"
+        @update:open="setAddPageModalVisible"
+        @update:model-key="setAddPageKey"
+        @update:model-title="setAddPageTitle"
+        @ok="onConfirmAddPage"
+      />
 
       <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>
+        <PageListPanel
+          :page-list="pageList"
+          :selected-page="selectedPage"
+          @update:selected-page="setSelectedPage"
+          @move-up="movePageUp"
+          @move-down="movePageDown"
+          @duplicate="duplicatePage"
+          @delete="confirmDeletePage"
+          @add-page="onAddPageMenuClick"
+        />
+
+        <PropsPanel
+          :selected-page="selectedPage"
+          v-model:test-detail-id="testDetailId"
+          @refresh="previewPanelRef?.refresh()"
+        />
+
+        <PreviewPanel
+          ref="previewPanelRef"
+          :editor-json="currentEditorJson"
+          :selected-page="selectedPage"
+          :test-detail-id="testDetailId"
+        />
       </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 { onMounted, provide, ref } from '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;
+import EditorToolbar from './subpart/EditorToolbar.vue';
+import AddPageModal from './subpart/AddPageModal.vue';
+import PageListPanel from './subpart/PageListPanel.vue';
+import PropsPanel from './subpart/PropsPanel.vue';
+import PreviewPanel from './subpart/PreviewPanel.vue';
+import { useEditorConfig } from './composables/useEditorConfig';
+import { usePageList } from './composables/usePageList';
+
+const {
+  currentEditorJson,
+  selectedPage,
+  historyList,
+  currentConfig,
+  currentHistoryId,
+  currentShowConfigName,
+  saveAsModalVisible,
+  saveAsVersionName,
+  saveAsLoading,
+  onSelectDefault,
+  onSelectHistory,
+  saveEditorJson,
+  openSaveAsModal,
+  confirmSaveAs,
+  setActiveHistory,
+  deleteHistory,
+  exportToJsonFile,
+  init,
+} = useEditorConfig();
+
+const testDetailId = ref(0);
+
+const {
+  pageList,
+  movePageUp,
+  movePageDown,
+  duplicatePage,
+  confirmDeletePage,
+  addPageModalVisible,
+  addPageTemplate,
+  addPageKey,
+  addPageTitle,
+  addPageLoading,
+  onAddPageMenuClick,
+  confirmAddPage,
+} = usePageList(currentEditorJson, selectedPage);
+
+const previewPanelRef = ref<InstanceType<typeof PreviewPanel> | null>(null);
+
+function setSelectedPage(page: (typeof selectedPage.value) | null) {
+  selectedPage.value = page;
+}
+function setAddPageModalVisible(v: boolean) {
+  addPageModalVisible.value = v;
+}
+function setAddPageKey(v: string) {
+  addPageKey.value = v;
+}
+function setAddPageTitle(v: string) {
+  addPageTitle.value = v;
 }
 
-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);
+provide('editorPreviewMark', true);
 
-/** 生成新页面唯一 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,
-      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();
+function onConfirmAddPage() {
+  return confirmAddPage();
 }
 
 onMounted(async () => {
-  await loadEditorJsonHistorys();
-  await loadEditorJson(true);
-  window.addEventListener('beforeunload', function(e) {
-    const message = '你还有未保存的内容,确定要离开吗?';
-    e.returnValue = message;
-    return message;
+  await init();
+  window.addEventListener('beforeunload', (e) => {
+    const msg = '你还有未保存的内容,确定要离开吗?';
+    e.returnValue = msg;
+    return msg;
   });
-})
+});
 </script>
 
 <style scoped>
@@ -499,14 +148,6 @@ onMounted(async () => {
   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;
@@ -523,73 +164,4 @@ onMounted(async () => {
 .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>

+ 19 - 3
src/pages/article/data/editor/components/DynamicDataEditor.vue

@@ -1,6 +1,9 @@
 <template>
   <div class="dynamic-data-editor">
-    <a-form layout="vertical" size="small">
+
+    <LinkedinOutlined class="icon" />
+
+    <a-form class="form" layout="vertical" size="small">
       <a-form-item label="数据源类型">
         <a-select
           :value="currentType"
@@ -145,6 +148,7 @@ import type {
 } from '@/pages/article/data/CommonCategoryDynamicData';
 import { SerializedApiMap } from '@/pages/article/data/CommonCategoryDynamicData';
 import KeyValueEditor from './KeyValueEditor.vue';
+import { LinkedinOutlined } from '@ant-design/icons-vue';
 
 const props = defineProps<{
   modelValue?: IHomeCommonCategoryDynamicData | null;
@@ -297,9 +301,21 @@ function setDetailContent(key: 'id' | 'modelId', value: number | undefined) {
 
 </script>
 
-<style scoped>
+<style lang="scss" scoped>
 .dynamic-data-editor {
   font-size: 12px;
-  margin-left: 14px;
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+
+  .form {
+    flex: 1;
+    
+  }
+  .icon {
+    color: #c55900;
+    font-size: 16px;
+    margin-top: 5px;
+  }
 }
 </style>

+ 25 - 0
src/pages/article/data/editor/components/FlatCollapse.vue

@@ -0,0 +1,25 @@
+<template>
+  <a-collapse v-model:activeKey="activeKey" ghost class="flat-collapse" >
+    <a-collapse-panel key="1" :header="label">
+      <slot />
+    </a-collapse-panel>
+  </a-collapse>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+
+const activeKey = ref(['']);
+const props = defineProps<{
+  label: string;
+}>();
+
+</script>
+
+<style lang="scss" scoped>
+.flat-collapse {
+  ::v-deep .ant-collapse-header {
+    padding: 0 !important;
+  }
+}
+</style>

+ 96 - 17
src/pages/article/data/editor/components/LinkPathEditor.vue

@@ -1,8 +1,29 @@
 <template>
-  <a-form-item class="link-path-editor" label="跳转路径">
-    <a-collapse v-model:activeKey="activeKey" ghost>
-      <a-collapse-panel key="1" header="点击展开">
-        <a-form-item>
+  <div class="link-path-editor">
+
+    <DatabaseOutlined class="icon" />
+
+    <a-input
+      :value="displayPath"
+      readonly
+      placeholder="点击设置跳转路径"
+      class="path-trigger"
+      @click="openModal"
+    >
+      <template #suffix>
+        <EditOutlined class="trigger-icon" @click.stop="openModal" />
+      </template>
+    </a-input>
+
+    <a-modal
+      v-model:open="modalVisible"
+      title="编辑跳转路径"
+      :width="520"
+      @cancel="onModalCancel"
+      @ok="onModalOk"
+    >
+      <a-form layout="vertical" class="modal-form">
+        <a-form-item label="路径类型">
           <div class="path-selector">
             <a-select 
               v-model:value="pathType" 
@@ -19,7 +40,6 @@
               v-if="pathType === 'dynamic-list' || pathType === 'dynamic-detail'"
               v-model:value="localPageConfigName"
               style="flex: 1"
-              @change="updateLink"
               placeholder="请选择动态页面"
             >
               <a-select-option 
@@ -36,7 +56,6 @@
               v-else-if="pathType === 'internal'"
               v-model:value="linkPath"
               style="flex: 1"
-              @change="updateLink"
               placeholder="请选择内置页面"
             >
               <a-select-option 
@@ -53,11 +72,10 @@
               v-else-if="pathType === 'custom'"
               v-model:value="linkPath"
               placeholder="请输入跳转路径"
-              @change="updateLink"
               style="flex: 1"
             />
           </div>
-        </a-form-item>    
+        </a-form-item>
         <a-form-item v-if="!props.noParams && pathType !== 'dynamic-list' && pathType !== 'dynamic-detail'" label="参数设置">
           <KeyValueEditor
             :modelValue="localParams"
@@ -66,13 +84,15 @@
             @update:modelValue="localParams = $event"
           />
         </a-form-item>
-      </a-collapse-panel>
-    </a-collapse>
-  </a-form-item>
+      </a-form>
+    </a-modal>
+
+  </div>
 </template>
 
 <script setup lang="ts">
 import { inject, ref, watch, computed } from 'vue';
+import { EditOutlined, DatabaseOutlined } from '@ant-design/icons-vue';
 import KeyValueEditor from './KeyValueEditor.vue';
 import type { IHomeCommonCategoryDefine } from '@/pages/article/data/CommonCategoryDefine';
 import PagesJson from '@/pages.json';
@@ -129,7 +149,14 @@ const emit = defineEmits<{
 }>();
 
 const pageList = inject<(IHomeCommonCategoryDefine['page'][0])[]>('pageList', []);
-const activeKey = ref('');
+const modalVisible = ref(false);
+
+// 触发框显示文案:有值时显示路径,否则占位
+const displayPath = computed(() => {
+  const v = props.modelValue;
+  if (v == null || String(v).trim() === '') return '';
+  return String(v).trim();
+});
 
 // 跳转路径类型
 const pathType = ref<'dynamic-list' | 'dynamic-detail' | 'internal' | 'custom'>('custom');
@@ -171,6 +198,25 @@ watch(
   { deep: true, immediate: true }
 );
 
+// 根据 modelValue 重置本地状态(取消时恢复)
+function resetFromModelValue() {
+  const urlStr = typeof props.modelValue === 'string' ? props.modelValue : '';
+  const { path, params } = parseUrlParams(urlStr);
+  linkPath.value = path;
+  localParams.value = params;
+  if (linkPath.value === CommonCategoryListPath) {
+    pathType.value = 'dynamic-list';
+    localPageConfigName.value = localParams.value.pageConfigName || '';
+  } else if (linkPath.value === CommonCategoryDetailPath) {
+    pathType.value = 'dynamic-detail';
+    localPageConfigName.value = localParams.value.pageConfigName || '';
+  } else if (linkPath.value.startsWith('/pages/')) {
+    pathType.value = 'internal';
+  } else {
+    pathType.value = 'custom';
+  }
+}
+
 // 更新链接:输出为带 ? 参数的完整 URL 字符串
 const updateLink = () => {
   if (props.noParams) {
@@ -190,16 +236,49 @@ const updateLink = () => {
   }
   emit('update:modelValue', buildUrlWithParams(linkPath.value, localParams.value));
 };
+
+function openModal() {
+  modalVisible.value = true;
+}
+
+function onModalCancel() {
+  resetFromModelValue();
+  modalVisible.value = false;
+}
+
+function onModalOk() {
+  updateLink();
+  modalVisible.value = false;
+}
 </script>
 
 <style lang="scss" scoped>
 .link-path-editor {
-  ::v-deep .ant-collapse-header {
-    padding: 0 !important;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+
+  .icon {
+    color: #006cc5;
+    font-size: 16px;
   }
-  .path-selector {
-    display: flex;
-    align-items: center;
+
+  .path-trigger {
+    cursor: pointer;
+    :deep(.ant-input) {
+      cursor: pointer;
+    }
+    .trigger-icon {
+      cursor: pointer;
+      color: rgba(0, 0, 0, 0.45);
+      &:hover {
+        color: var(--ant-primary-color);
+      }
+    }
   }
 }
+.modal-form .path-selector {
+  display: flex;
+  align-items: center;
+}
 </style>

+ 181 - 0
src/pages/article/data/editor/composables/useEditorConfig.ts

@@ -0,0 +1,181 @@
+import { computed, ref } from 'vue';
+import { message, Modal } from 'ant-design-vue';
+import { ObjectUtils } from '@imengyu/imengyu-utils';
+import type { IHomeCommonCategoryDefine } from '../../CommonCategoryDefine';
+import DefaultEditorJson from '../../DefaultCategory.json';
+import CommonCategoryApi, { type ICommonCategoryConfigItem } from '../../api/CommonCategoryApi';
+
+/** 历史版本列表项(接口返回的 items 元素) */
+export interface IHistoryListItem {
+  id?: number;
+  data?: IHomeCommonCategoryDefine;
+  createTime?: string;
+  name?: string;
+  [key: string]: any;
+}
+
+export function useEditorConfig() {
+  const currentEditorJson = ref<IHomeCommonCategoryDefine>(DefaultEditorJson as IHomeCommonCategoryDefine);
+  const selectedPage = ref<(IHomeCommonCategoryDefine['page'][0]) | null>(null);
+  const historyList = ref<IHistoryListItem[]>([]);
+  const currentConfig = ref<ICommonCategoryConfigItem>();
+  const currentHistoryId = ref<number>(0);
+
+  const currentShowConfigName = computed(() => {
+    if (currentHistoryId.value === 0) return '默认配置';
+    return historyList.value.find(item => item.id === currentHistoryId.value)?.name ?? '未知';
+  });
+
+  // 另存为弹窗
+  const saveAsModalVisible = ref(false);
+  const saveAsVersionName = ref('');
+  const saveAsLoading = ref(false);
+
+  async function loadEditorJson(selectDefault = false) {
+    try {
+      currentConfig.value = await CommonCategoryApi.getConfigWithoutCache();
+      if (!currentConfig.value) throw new Error('加载基础配置失败');
+      if (selectDefault) currentHistoryId.value = currentConfig.value.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);
+  }
+
+  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('设置为激活版本成功');
+  }
+
+  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();
+  }
+
+  async function init() {
+    await loadEditorJsonHistorys();
+    await loadEditorJson(true);
+  }
+
+  return {
+    currentEditorJson,
+    selectedPage,
+    historyList,
+    currentConfig,
+    currentHistoryId,
+    currentShowConfigName,
+    saveAsModalVisible,
+    saveAsVersionName,
+    saveAsLoading,
+    loadEditorJson,
+    loadEditorJsonHistorys,
+    onSelectDefault,
+    onSelectHistory,
+    saveEditorJson,
+    openSaveAsModal,
+    confirmSaveAs,
+    setActiveHistory,
+    deleteHistory,
+    exportToJsonFile,
+    init,
+  };
+}

+ 191 - 0
src/pages/article/data/editor/composables/usePageList.ts

@@ -0,0 +1,191 @@
+import { computed, ref, type Ref } from 'vue';
+import { message, Modal } from 'ant-design-vue';
+import { ArrayUtils, ObjectUtils } from '@imengyu/imengyu-utils';
+import type {
+  IHomeCommonCategoryDefine,
+  IHomeCommonCategoryDetailDefine,
+  IHomeCommonCategoryHomeDefine,
+  IHomeCommonCategoryListDefine,
+} from '../../CommonCategoryDefine';
+
+export type PageTemplateType = 'Home' | 'CommonList' | 'Details';
+
+function createHomePageTemplate(): IHomeCommonCategoryHomeDefine {
+  return {
+    type: 'Home',
+    props: {
+      title: '首页',
+      subTitle: '',
+      homeBanner: '',
+      homeButtons: [],
+      categorys: [],
+    },
+  };
+}
+
+function createCommonListPageTemplate(): IHomeCommonCategoryListDefine {
+  return {
+    type: 'CommonList',
+    props: {
+      showTab: true,
+      showSearch: true,
+      showTotal: true,
+      tabs: [],
+    },
+  };
+}
+
+function createDetailPageTemplate(): IHomeCommonCategoryDetailDefine {
+  return {
+    type: 'Details',
+    props: {
+      showHead: true,
+      introBlockDescs: [],
+      introBlocks: [],
+      tabs: [],
+    },
+  };
+}
+
+export function usePageList(
+  editorJson: Ref<IHomeCommonCategoryDefine>,
+  selectedPage: Ref<(IHomeCommonCategoryDefine['page'][0]) | null>
+) {
+  const pageList = computed(() => editorJson.value?.page ?? []);
+
+  function getUniquePageName(prefix: string): string {
+    const pages = editorJson.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;
+  }
+
+  function movePageUp(page: IHomeCommonCategoryDefine['page'][0]) {
+    const pages = editorJson.value?.page ?? [];
+    ArrayUtils.upData(pages, pages.indexOf(page));
+  }
+
+  function movePageDown(page: IHomeCommonCategoryDefine['page'][0]) {
+    const pages = editorJson.value?.page ?? [];
+    ArrayUtils.downData(pages, pages.indexOf(page));
+  }
+
+  function duplicatePage(page: IHomeCommonCategoryDefine['page'][0]) {
+    const pages = editorJson.value?.page ?? [];
+    const newPage = ObjectUtils.clone(page);
+    newPage.name = getUniquePageName(newPage.name);
+    newPage.title = newPage.title + ' (克隆)';
+    ArrayUtils.insert(pages, pages.indexOf(page) + 1, newPage);
+    selectedPage.value = newPage;
+    message.success('已克隆页面');
+  }
+
+  function confirmDeletePage(page: IHomeCommonCategoryDefine['page'][0]) {
+    Modal.confirm({
+      title: '确认删除',
+      content: `确定要删除页面「${page.title || page.name}」吗?删除后不可恢复。`,
+      okText: '删除',
+      okType: 'danger',
+      cancelText: '取消',
+      onOk() {
+        const pages = editorJson.value?.page ?? [];
+        const next = pages.filter(p => p.name !== page.name);
+        editorJson.value = { ...editorJson.value, page: next };
+        if (selectedPage.value?.name === page.name) {
+          selectedPage.value = next[0] ?? null;
+        }
+        message.success('已删除页面');
+      },
+    });
+  }
+
+  // 添加页面弹窗
+  const addPageModalVisible = ref(false);
+  const addPageTemplate = ref<PageTemplateType | null>(null);
+  const addPageKey = ref('');
+  const addPageTitle = ref('');
+  const addPageLoading = ref(false);
+
+  const templateLabelMap: Record<PageTemplateType, string> = {
+    Home: 'Home',
+    CommonList: 'CommonList',
+    Details: 'Details',
+  };
+
+  function onAddPageMenuClick(template: PageTemplateType) {
+    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> {
+    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 = editorJson.value?.page ?? [];
+    if (pages.some(p => p.name === key)) {
+      message.warning(`key「${key}」已存在,请使用其他 key`);
+      return Promise.reject();
+    }
+    const template = addPageTemplate.value;
+    if (!template) return Promise.reject();
+
+    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();
+    }
+
+    editorJson.value = {
+      ...editorJson.value,
+      page: [...pages, { name: key, title, content }],
+    };
+    const added = editorJson.value.page[editorJson.value.page.length - 1];
+    selectedPage.value = added;
+    addPageModalVisible.value = false;
+    message.success(`已添加页面 ${title} (${key})`);
+    return Promise.resolve();
+  }
+
+  return {
+    pageList,
+    getUniquePageName,
+    movePageUp,
+    movePageDown,
+    duplicatePage,
+    confirmDeletePage,
+    addPageModalVisible,
+    addPageTemplate,
+    addPageKey,
+    addPageTitle,
+    addPageLoading,
+    templateLabelMap,
+    onAddPageMenuClick,
+    confirmAddPage,
+  };
+}

+ 9 - 7
src/pages/article/data/editor/editors/CommonListPropsEditor.vue

@@ -28,6 +28,14 @@
                   <ArrowUpOutlined title="上移" @click.stop="moveTabUp(i)" />
                   <ArrowDownOutlined title="下移" @click.stop="moveTabDown(i)" />
                   <CopyOutlined title="复制" @click.stop="copyTab(i)" />
+                  <a-popconfirm
+                    title="确认删除吗?"
+                    @confirm="removeTab(i)"
+                  >
+                    <a-button type="text" danger size="small" @click.stop="">
+                      <DeleteOutlined title="删除" />
+                    </a-button>
+                  </a-popconfirm>
                 </div>
               </template>
               <a-form :labelCol="{ span: 4 }" size="small">
@@ -88,12 +96,6 @@
                     <NestCategoryEditor v-model:categorys="tab.categorys" />
                   </a-form-item>
                 </template>
-                <a-popconfirm
-                  title="确认删除吗?"
-                  @confirm="removeTab(i)"
-                >
-                  <a-button type="link" danger size="small">删除 Tab</a-button>
-                </a-popconfirm>
               </a-form>
             </a-collapse-panel>
           </a-collapse>
@@ -117,7 +119,7 @@ import DataSolveEditor from '../components/DataSolveEditor.vue';
 import NestCategoryEditor from '../subpart/NestCategoryEditor.vue';
 import ItemTypeEditor from '../components/ItemTypeEditor.vue';
 import DropdownDefinesEditor from '../components/DropdownDefinesEditor.vue';
-import { ArrowUpOutlined, ArrowDownOutlined, CopyOutlined } from '@ant-design/icons-vue';
+import { ArrowUpOutlined, ArrowDownOutlined, CopyOutlined, DeleteOutlined } from '@ant-design/icons-vue';
 import { ArrayUtils } from '@imengyu/imengyu-utils';
 
 type TabItem = IHomeCommonCategoryListTabItemDefine;

+ 18 - 15
src/pages/article/data/editor/editors/DetailPropsEditor.vue

@@ -1,6 +1,13 @@
 <template>
   <div class="detail-props-editor">
     <a-form :labelCol="{ span: 6 }" size="small">
+      <a-form-item label="测试内容ID">
+        <a-input-number 
+          :value="props.testDetailId" 
+          @update:value="emit('update:testDetailId', $event)"
+          style="width: 100%"
+        />
+      </a-form-item>
       <a-form-item label="显示头部">
         <a-checkbox v-model:checked="props.props.showHead" :indeterminate="props.props.showHead === undefined">
           默认:显示
@@ -12,13 +19,6 @@
       <a-form-item label="显示死亡框">
         <a-checkbox v-model:checked="props.props.showDeadBox"  />
       </a-form-item>
-      <a-form-item label="测试内容ID">
-        <a-input-number 
-          :value="props.testDetailId" 
-          @update:value="emit('update:testDetailId', $event)"
-          style="width: 100%"
-        />
-      </a-form-item>
     </a-form>
 
     <a-collapse v-model:activeKey="activeKeys" class="props-collapse">
@@ -84,6 +84,11 @@
                   <ArrowUpOutlined title="上移" @click.stop="moveTabUp(i)" />
                   <ArrowDownOutlined title="下移" @click.stop="moveTabDown(i)" />
                   <CopyOutlined title="复制" @click.stop="copyTab(i)" />
+                  <a-popconfirm title="确认删除该 Tab?" @confirm="removeTab(i)">
+                    <a-button type="text" danger size="small" @click.stop="">
+                      <DeleteOutlined title="删除" />
+                    </a-button>
+                  </a-popconfirm>
                 </div>
               </template>
               <a-form :labelCol="{ span: 4 }" size="small">
@@ -101,14 +106,16 @@
                 <a-form-item v-if="tab.type === 'images'" label="前缀文字">
                   <a-input v-model:value="tab.prefix" />
                 </a-form-item>
-                <a-form-item label="数据键 key">
+                <a-form-item label="数据键 key" help="用于根据数据判断当前 TAB 是否显示,如果需要一直显示,可填写 “id“">
                   <a-input v-model:value="tab.key" placeholder="对应内容数据键" />
                 </a-form-item>
-                <a-form-item label="TAB 宽度">
+                <a-form-item label="TAB 宽度" help="TAB 宽度,单位:像素(推荐130~300之间)">
                   <a-input-number v-model:value="tab.width" style="width: 100%" />
                 </a-form-item>
                 <a-form-item label="可见">
-                  <a-checkbox v-model:checked="tab.visible" :indeterminate="tab.visible === undefined" />
+                  <a-checkbox v-model:checked="tab.visible" :indeterminate="tab.visible === undefined">
+                    默认可见
+                  </a-checkbox>
                 </a-form-item>
 
                 <template v-if="tab.type === 'list'">
@@ -125,10 +132,6 @@
                     <a-input v-model:value="tab.key" placeholder="与上方数据键一致" />
                   </a-form-item>
                 </template>
-
-                <a-popconfirm title="确认删除该 Tab?" @confirm="removeTab(i)">
-                  <a-button type="link" danger size="small">删除 Tab</a-button>
-                </a-popconfirm>
               </a-form>
             </a-collapse-panel>
           </a-collapse>
@@ -154,7 +157,7 @@ import type { IHomeCommonCategoryListDefine } from '../../CommonCategoryDefine';
 import CommonListPropsEditor from './CommonListPropsEditor.vue';
 import NestCategoryEditor from '../subpart/NestCategoryEditor.vue';
 import KeyValueEditor from '../components/KeyValueEditor.vue';
-import { ArrowUpOutlined, ArrowDownOutlined, CopyOutlined } from '@ant-design/icons-vue';
+import { ArrowUpOutlined, ArrowDownOutlined, CopyOutlined, DeleteOutlined } from '@ant-design/icons-vue';
 import { ArrayUtils } from '@imengyu/imengyu-utils';
 
 const props = defineProps<{

+ 5 - 4
src/pages/article/data/editor/editors/HomePropsEditor.vue

@@ -36,7 +36,9 @@
             </a-form-item>
             <LinkPathEditor v-model="btn.link" />
             <a-popconfirm title="确定要删除这个按钮吗?" @confirm="removeHomeButton(i)">
-              <a-button type="link" danger size="small">删除</a-button>
+              <a-button type="text" danger size="small" @click.stop="">
+                <DeleteOutlined title="删除" />
+              </a-button>
             </a-popconfirm>
           </a-form>
         </div>
@@ -51,11 +53,10 @@
 
 <script setup lang="ts">
 import { ref } from 'vue';
-import { PlusOutlined } from '@ant-design/icons-vue';
+import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
 import type { IHomeCommonCategoryHomeDefine, IHomeCommonCategoryListTabNestCategoryItemDefine } from '../../CommonCategoryDefine';
 import LinkPathEditor from '../components/LinkPathEditor.vue';
 import IconEditor from '../components/IconEditor.vue';
-import DynamicDataEditor from '../components/DynamicDataEditor.vue';
 import NestCategoryEditor from '../subpart/NestCategoryEditor.vue';
 
 const props = defineProps<{
@@ -66,7 +67,7 @@ const activeKeys = ref<string[]>(['homeButtons', 'categorys']);
 function addHomeButton(event: MouseEvent) {
   event.stopPropagation();
   props.props.homeButtons = props.props.homeButtons || [];
-  props.props.homeButtons.push({ title: '新按钮', icon: '', style: 'text', link: ['', {}], size: 50 });
+  props.props.homeButtons.push({ title: '新按钮', icon: '', style: 'text', link: '', size: 50 });
 }
 function removeHomeButton(i: number) {
   props.props.homeButtons.splice(i, 1);

+ 52 - 0
src/pages/article/data/editor/subpart/AddPageModal.vue

@@ -0,0 +1,52 @@
+<template>
+  <a-modal
+    :open="open"
+    title="添加页面"
+    ok-text="添加"
+    cancel-text="取消"
+    :confirm-loading="loading"
+    @update:open="emit('update:open', $event)"
+    @ok="emit('ok')"
+  >
+    <a-form layout="vertical" class="add-page-form">
+      <a-form-item label="key(页面唯一标识)" required>
+        <a-input
+          :model-value="modelKey"
+          placeholder="如 home、list_1"
+          allow-clear
+          @update:model-value="emit('update:modelKey', $event)"
+        />
+      </a-form-item>
+      <a-form-item label="title(页面标题)" required>
+        <a-input
+          :model-value="modelTitle"
+          placeholder="如 首页、列表页"
+          allow-clear
+          @update:model-value="emit('update:modelTitle', $event)"
+        />
+      </a-form-item>
+      <a-form-item v-if="template" label="模板">
+        <span>{{ template }}</span>
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>
+
+<script setup lang="ts">
+import type { PageTemplateType } from '../composables/usePageList';
+
+defineProps<{
+  open: boolean;
+  modelKey: string;
+  modelTitle: string;
+  template: PageTemplateType | null;
+  loading: boolean;
+}>();
+
+const emit = defineEmits<{
+  (e: 'update:open', v: boolean): void;
+  (e: 'update:modelKey', v: string): void;
+  (e: 'update:modelTitle', v: string): void;
+  (e: 'ok'): void;
+}>();
+</script>

+ 117 - 0
src/pages/article/data/editor/subpart/EditorToolbar.vue

@@ -0,0 +1,117 @@
+<template>
+  <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="emit('select-default')">
+              选择默认配置
+              <a-badge v-if="currentConfig?.activeHistoryId === 0" count="激活" />
+            </a-menu-item>
+            <a-menu-item
+              v-for="(item, index) in historyList"
+              :key="index"
+              @click="emit('select-history', 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="emit('save')">
+        保存
+        <template #overlay>
+          <a-menu>
+            <a-menu-item @click="emit('open-save-as')">另存为历史版本</a-menu-item>
+          </a-menu>
+        </template>
+      </a-dropdown-button>
+
+      <div>
+        <InfoCircleFilled />
+        当前显示配置:
+        <span>{{ currentShowConfigName }}</span>
+      </div>
+
+      <a-button v-if="currentConfig" @click="emit('set-active')">
+        {{ currentConfig.activeHistoryId === currentHistoryId ? '已经是激活版本' : '设置为激活版本' }}
+      </a-button>
+      <a-button v-if="currentConfig && currentHistoryId !== 0" danger @click="emit('delete-history')">
+        删除历史版本
+      </a-button>
+    </a-space>
+    <a-button @click="emit('export')">
+      导出为JSON文件
+      <DownloadOutlined />
+    </a-button>
+
+    <a-modal
+      :open="saveAsModalVisible"
+      title="另存为历史版本"
+      ok-text="保存"
+      cancel-text="取消"
+      :confirm-loading="saveAsLoading"
+      @update:open="emit('update:saveAsModalVisible', $event)"
+      @ok="emit('confirm-save-as')"
+    >
+      <a-form layout="vertical" class="save-as-form">
+        <a-form-item label="版本名称">
+          <a-input
+            :value="saveAsVersionName"
+            placeholder="请输入版本名称"
+            allow-clear
+            @update:value="emit('update:saveAsVersionName', $event)"
+          />
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { DownOutlined, DownloadOutlined, InfoCircleFilled } from '@ant-design/icons-vue';
+import type { IHistoryListItem } from '../composables/useEditorConfig';
+import type { ICommonCategoryConfigItem } from '../../api/CommonCategoryApi';
+
+defineProps<{
+  historyList: IHistoryListItem[];
+  currentConfig?: ICommonCategoryConfigItem;
+  currentHistoryId: number;
+  currentShowConfigName: string;
+  saveAsModalVisible: boolean;
+  saveAsVersionName: string;
+  saveAsLoading: boolean;
+}>();
+
+const emit = defineEmits<{
+  (e: 'select-default'): void;
+  (e: 'select-history', id: number): void;
+  (e: 'save'): void;
+  (e: 'open-save-as'): void;
+  (e: 'confirm-save-as'): void;
+  (e: 'set-active'): void;
+  (e: 'delete-history'): void;
+  (e: 'export'): void;
+  (e: 'update:saveAsModalVisible', v: boolean): void;
+  (e: 'update:saveAsVersionName', v: string): void;
+}>();
+</script>
+
+<style scoped>
+.editor-toolbar {
+  padding: 8px 16px;
+  background: #fff;
+  border-bottom: 1px solid #eee;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+</style>

+ 18 - 181
src/pages/article/data/editor/subpart/NestCategoryEditor.vue

@@ -2,110 +2,20 @@
   <div class="nest-category-editor">
     <div v-for="(cat, i) in categorys" :key="i" class="nested-item">
       <a-collapse>
-        <a-collapse-panel :key="i" :header="cat.text || `子分类 ${i + 1}`">
+        <a-collapse-panel :header="cat.text || `子分类`">
           <template #extra>
-            <div class="nest-category-editor-buttons">
+            <div class="actions">
               <ArrowUpOutlined title="上移" @click.stop="moveUp(i)" />
               <ArrowDownOutlined title="下移" @click.stop="moveDown(i)" />
               <CopyOutlined title="复制" @click.stop="copy(i)" />
+              <a-popconfirm title="确定删除该子分类吗?" @confirm="remove(i)">
+                <a-button type="text" danger size="small" @click.stop>
+                  <DeleteOutlined title="删除" />
+                </a-button>
+              </a-popconfirm>
             </div>
           </template>
-          <a-form :labelCol="{ span: 6 }" size="small">
-            <a-form-item label="显示标题">
-              <a-checkbox 
-                v-model:checked="cat.showTitle"
-                :indeterminate="cat.showTitle === undefined"
-              />
-            </a-form-item>
-            <a-form-item label="标题">
-              <a-input v-model:value="cat.text" />
-            </a-form-item>
-            <a-form-item label="标题级别">
-              <a-select v-model:value="cat.textLevel" style="width: 100%" allowClear placeholder="请选择标题级别">
-                <a-select-option v-for="level in titleLevels" :key="level" :value="level">
-                  {{ level }}
-                </a-select-option>
-              </a-select>
-            </a-form-item>
-            <a-form-item label="显示更多">
-              <a-checkbox 
-                v-model:checked="cat.showMore" 
-                :indeterminate="cat.showMore === undefined"
-              />
-            </a-form-item>
-            <a-form-item label="更多页">
-              <LinkPathEditor v-model="cat.morePage" />
-            </a-form-item>
-            <a-form-item label="更多文本">
-              <a-input v-model:value="cat.moreText" />
-            </a-form-item>
-            <a-form-item label="可见">
-              <a-checkbox v-model:checked="cat.visible" :indeterminate="cat.visible === undefined" />
-            </a-form-item>
-            <a-form-item label="类型">
-              <a-select
-                v-model:value="cat.type"
-                style="width: 100%"
-                allowClear
-                placeholder="请选择类型"
-                @change="onTypeChange(cat, $event)"
-              >
-                <a-select-option v-for="type in blockTypes" :key="type" :value="type">
-                  {{ type }}
-                </a-select-option>
-              </a-select>
-            </a-form-item>
-            <template v-if="cat.type?.startsWith('speicalMergeItem:getColumns')">
-              <a-divider>合并配置 (getColumns)</a-divider>
-              <a-form-item label="模型ID">
-                <a-input-number v-model:value="getMergeParams(cat, 'getColumns').modelId" style="width: 100%" placeholder="模型ID" />
-              </a-form-item>
-              <a-form-item label="栏目ID">
-                <a-input-number v-model:value="getMergeParams(cat, 'getColumns').mainBodyColumnId" style="width: 100%" placeholder="栏目ID" />
-              </a-form-item>
-              <a-form-item label="每个单元其他参数">
-                <KeyValueEditor v-model="getMergeParams(cat, 'getColumns').otherParams" />
-              </a-form-item>
-              <a-form-item label="替换项 (overrideItems)">
-                <div v-for="(item, oi) in getOverrideItems(cat)" :key="oi" class="override-item-row">
-                  <a-input-number v-model:value="item.id" placeholder="数据ID" size="small" style="width: 100px" />
-                  <KeyValueEditor :modelValue="item" class="override-item-kv" @update:modelValue="(v) => setOverrideItem(cat, oi, v)" />
-                  <a-button type="link" danger size="small" @click="removeOverrideItem(cat, oi)">删除</a-button>
-                </div>
-                <a-button type="dashed" size="small" @click="addOverrideItem(cat)">+ 添加替换项</a-button>
-              </a-form-item>
-            </template>
-            <template v-else-if="cat.type?.startsWith('speicalMergeItem:getColumn')">
-              <a-divider>合并配置 (getColumn)</a-divider>
-              <a-form-item label="模型ID">
-                <a-input-number v-model:value="getMergeParams(cat, 'getColumn').modelId" style="width: 100%" placeholder="模型ID" />
-              </a-form-item>
-              <a-form-item label="栏目ID">
-                <a-input-number v-model:value="getMergeParams(cat, 'getColumn').mainBodyColumnId" style="width: 100%" placeholder="栏目ID" />
-              </a-form-item>
-            </template>
-            <a-form-item v-else label="数据源">
-              <DynamicDataEditor v-model="cat.data" />
-            </a-form-item>
-            <a-form-item label="单元属性">
-              <KeyValueEditor v-model="cat.blockProps" />
-            </a-form-item>
-            <a-form-item label="加载数量">
-              <a-input-number v-model:value="cat.count" :min="1" style="width: 100%" />
-            </a-form-item>
-            <a-form-item label="数据展示">
-              <DataSolveEditor v-model="cat.dataSolve" />
-            </a-form-item>
-            <a-form-item label="列表页项类型">
-              <ItemTypeEditor v-model="cat.itemType" />
-            </a-form-item>
-            <a-form-item label="列表页详情页">
-              <LinkPathEditor v-model="cat.detailsPage" />
-            </a-form-item>
-            <a-popconfirm title="确定删除该子分类吗?" @confirm="remove(i)">
-              <a-button type="link" danger size="small">删除子分类</a-button>
-            </a-popconfirm>
-          </a-form>
+          <NestCategoryEditorItem :cat="cat" />
         </a-collapse-panel>
       </a-collapse>
     </div>
@@ -119,15 +29,10 @@
 <script setup lang="ts">
 import type { IHomeCommonCategoryListTabNestCategoryItemDefine } from '../../CommonCategoryDefine';
 import type { IHomeCommonCategoryDynamicData } from '../../CommonCategoryDynamicData';
-import type { IHomeCommonCategoryCategoryDynamicDataMergeTypeGetColumns, IHomeCommonCategoryCategoryDynamicDataMergeTypeGetColumn } from '../../data-defines/Category';
-import LinkPathEditor from '../components/LinkPathEditor.vue';
-import DynamicDataEditor from '../components/DynamicDataEditor.vue';
-import DataSolveEditor from '../components/DataSolveEditor.vue';
-import { CommonCategoryBlockType } from '../../CommonCategoryBlocks';
-import ItemTypeEditor from '../components/ItemTypeEditor.vue';
-import { ArrowUpOutlined, ArrowDownOutlined, CopyOutlined } from '@ant-design/icons-vue';
 import { ArrayUtils } from '@imengyu/imengyu-utils';
-import KeyValueEditor from '../components/KeyValueEditor.vue';
+import { message } from 'ant-design-vue';
+import { ArrowUpOutlined, ArrowDownOutlined, CopyOutlined, DeleteOutlined } from '@ant-design/icons-vue';
+import NestCategoryEditorItem from './NestCategoryEditorItem.vue';
 
 const props = defineProps<{
   categorys: IHomeCommonCategoryListTabNestCategoryItemDefine[]|undefined;
@@ -136,17 +41,6 @@ const emit = defineEmits<{
   (e: 'update:categorys', categorys: IHomeCommonCategoryListTabNestCategoryItemDefine[]): void;
 }>();
 
-const blockTypes = (CommonCategoryBlockType as string[]).concat(
-  [
-    'speicalMergeItem:getColumns',
-    'speicalMergeItem:getColumn',
-  ].map((type) => (
-    CommonCategoryBlockType.map(p => `${type}:${p}`)
-  ))
-  .flat()
-);
-const titleLevels = ['h1', 'h2', 'h3'];
-
 function getCategorys() {
   if (!props.categorys) {
     const newArray = [] as IHomeCommonCategoryListTabNestCategoryItemDefine[];
@@ -188,52 +82,7 @@ function copy(i: number) {
       data: getCategorys()[i]
     }),
   });
-  uni.showToast({
-    title: '复制成功',
-    icon: 'success',
-  });
-}
-
-/** 确保并返回合并类型的 params,用于 speicalMergeItem:getColumns / getColumn */
-function getMergeParams(cat: IHomeCommonCategoryListTabNestCategoryItemDefine, kind: 'getColumns'): IHomeCommonCategoryCategoryDynamicDataMergeTypeGetColumns['params'];
-function getMergeParams(cat: IHomeCommonCategoryListTabNestCategoryItemDefine, kind: 'getColumn'): IHomeCommonCategoryCategoryDynamicDataMergeTypeGetColumn['params'];
-function getMergeParams(cat: IHomeCommonCategoryListTabNestCategoryItemDefine, kind: 'getColumns' | 'getColumn') {
-  if (!cat.params) cat.params = {} as Record<string, any>;
-  const p = cat.params as Record<string, any>;
-  if (kind === 'getColumns') {
-    if (!Array.isArray(p.overrideItems)) p.overrideItems = [];
-    if (p.otherParams === undefined) p.otherParams = {};
-  }
-  return cat.params as any;
-}
-
-function onTypeChange(cat: IHomeCommonCategoryListTabNestCategoryItemDefine, type: string | undefined) {
-  if (type === 'speicalMergeItem:getColumns') {
-    getMergeParams(cat, 'getColumns');
-  } else if (type === 'speicalMergeItem:getColumn') {
-    getMergeParams(cat, 'getColumn');
-  }
-}
-
-function getOverrideItems(cat: IHomeCommonCategoryListTabNestCategoryItemDefine) {
-  const params = getMergeParams(cat, 'getColumns');
-  return params.overrideItems as ({ id: number } & IHomeCommonCategoryListTabNestCategoryItemDefine)[];
-}
-
-function addOverrideItem(cat: IHomeCommonCategoryListTabNestCategoryItemDefine) {
-  const params = getMergeParams(cat, 'getColumns');
-  (params.overrideItems as any[]).push({ id: 0 });
-}
-
-function removeOverrideItem(cat: IHomeCommonCategoryListTabNestCategoryItemDefine, index: number) {
-  const params = getMergeParams(cat, 'getColumns');
-  (params.overrideItems as any[]).splice(index, 1);
-}
-
-function setOverrideItem(cat: IHomeCommonCategoryListTabNestCategoryItemDefine, index: number, value: Record<string, any>) {
-  const params = getMergeParams(cat, 'getColumns');
-  const arr = params.overrideItems as any[];
-  if (arr[index]) arr[index] = { ...arr[index], ...value };
+  message.success('复制成功');
 }
 </script>
 
@@ -254,11 +103,12 @@ function setOverrideItem(cat: IHomeCommonCategoryListTabNestCategoryItemDefine,
   :deep(.ant-collapse-item) {
     border: none;
   }
-}
-.nest-category-editor-buttons {
-  display: flex;
-  flex-direction: row;
-  gap: 8px;
+
+  .actions {
+    display: flex;
+    flex-direction: row;
+    gap: 8px;
+  }
 }
 .nest-category-editor-footer {
   display: flex;
@@ -271,17 +121,4 @@ function setOverrideItem(cat: IHomeCommonCategoryListTabNestCategoryItemDefine,
     flex: 4
   }
 }
-.override-item-row {
-  display: flex;
-  align-items: flex-start;
-  gap: 8px;
-  margin-bottom: 8px;
-  padding: 6px;
-  background: #f5f5f5;
-  border-radius: 4px;
-
-  .override-item-kv {
-    flex: 1;
-  }
-}
 </style>

+ 189 - 0
src/pages/article/data/editor/subpart/NestCategoryEditorItem.vue

@@ -0,0 +1,189 @@
+<template>
+  <a-form :labelCol="{ span: 6 }" size="small">
+    <a-form-item label="显示标题">
+      <a-checkbox 
+        v-model:checked="cat.showTitle"
+        :indeterminate="cat.showTitle === undefined"
+      />
+    </a-form-item>
+    <a-form-item v-if="cat.showTitle !== false" label="标题">
+      <a-input v-model:value="cat.text" />
+    </a-form-item>
+    <a-form-item v-if="cat.showTitle !== false" label="标题级别">
+      <a-select v-model:value="cat.textLevel" style="width: 100%" allowClear placeholder="请选择标题级别">
+        <a-select-option v-for="level in titleLevels" :key="level" :value="level">
+          {{ level }}
+        </a-select-option>
+      </a-select>
+    </a-form-item>
+    <a-form-item v-if="cat.showTitle !== false" label="显示更多">
+      <a-checkbox 
+        v-model:checked="cat.showMore" 
+        :indeterminate="cat.showMore === undefined"
+      />
+    </a-form-item>
+    <a-form-item v-if="cat.showTitle !== false && cat.showMore !== false" label="更多页">
+      <LinkPathEditor v-model="cat.morePage" />
+    </a-form-item>
+    <a-form-item v-if="cat.showTitle !== false && cat.showMore !== false" label="更多文本">
+      <a-input v-model:value="cat.moreText" />
+    </a-form-item>
+    <a-form-item label="可见">
+      <a-checkbox v-model:checked="cat.visible" :indeterminate="cat.visible === undefined">
+        默认可见
+      </a-checkbox>
+    </a-form-item>
+    <a-form-item label="类型">
+      <a-select
+        v-model:value="cat.type"
+        style="width: 100%"
+        allowClear
+        placeholder="请选择类型"
+        @change="onTypeChange(cat, $event)"
+      >
+        <a-select-option v-for="type in blockTypes" :key="type" :value="type">
+          {{ type }}
+        </a-select-option>
+      </a-select>
+    </a-form-item>
+    <template v-if="cat.type?.startsWith('speicalMergeItem:getColumns')">
+      <a-divider>合并配置 (getColumns)</a-divider>
+      <a-form-item label="模型ID">
+        <a-input-number v-model:value="getMergeParams(cat, 'getColumns').modelId" style="width: 100%" placeholder="模型ID" />
+      </a-form-item>
+      <a-form-item label="栏目ID">
+        <a-input-number v-model:value="getMergeParams(cat, 'getColumns').mainBodyColumnId" style="width: 100%" placeholder="栏目ID" />
+      </a-form-item>
+      <a-form-item label="每个单元其他参数">
+        <KeyValueEditor v-model="getMergeParams(cat, 'getColumns').otherParams" />
+      </a-form-item>
+      <a-form-item label="替换项 (overrideItems)">
+        <div v-for="(item, oi) in getOverrideItems(cat)" :key="oi" class="override-item-row">
+          <a-input-number v-model:value="item.id" placeholder="数据ID" size="small" style="width: 100px" />
+          <KeyValueEditor :modelValue="item" class="override-item-kv" @update:modelValue="(v) => setOverrideItem(cat, oi, v)" />
+          <a-button type="link" danger size="small" @click="removeOverrideItem(cat, oi)">删除</a-button>
+        </div>
+        <a-button type="dashed" size="small" @click="addOverrideItem(cat)">+ 添加替换项</a-button>
+      </a-form-item>
+    </template>
+    <template v-else-if="cat.type?.startsWith('speicalMergeItem:getColumn')">
+      <a-divider>合并配置 (getColumn)</a-divider>
+      <a-form-item label="模型ID">
+        <a-input-number v-model:value="getMergeParams(cat, 'getColumn').modelId" style="width: 100%" placeholder="模型ID" />
+      </a-form-item>
+      <a-form-item label="栏目ID">
+        <a-input-number v-model:value="getMergeParams(cat, 'getColumn').mainBodyColumnId" style="width: 100%" placeholder="栏目ID" />
+      </a-form-item>
+    </template>
+    <template v-else-if="!isBlockType">
+      <a-form-item label="数据源">
+        <DynamicDataEditor v-model="cat.data" />
+      </a-form-item>
+      <a-form-item label="加载数量">
+        <a-input-number v-model:value="cat.count" :min="1" style="width: 100%" />
+      </a-form-item>
+      <a-form-item label="数据展示">
+        <DataSolveEditor v-model="cat.dataSolve" />
+      </a-form-item>
+      <a-form-item label="列表页项类型">
+        <ItemTypeEditor v-model="cat.itemType" />
+      </a-form-item>
+      <a-form-item label="列表页详情页">
+        <LinkPathEditor v-model="cat.detailsPage" />
+      </a-form-item>
+    </template>
+    <a-form-item v-if="isBlockType" label="单元属性">
+      <KeyValueEditor v-model="cat.blockProps" />
+    </a-form-item>
+  </a-form>
+</template>
+
+<script setup lang="ts">
+import type { IHomeCommonCategoryListTabNestCategoryItemDefine } from '../../CommonCategoryDefine';
+import type { IHomeCommonCategoryCategoryDynamicDataMergeTypeGetColumns, IHomeCommonCategoryCategoryDynamicDataMergeTypeGetColumn } from '../../data-defines/Category';
+import LinkPathEditor from '../components/LinkPathEditor.vue';
+import DynamicDataEditor from '../components/DynamicDataEditor.vue';
+import DataSolveEditor from '../components/DataSolveEditor.vue';
+import KeyValueEditor from '../components/KeyValueEditor.vue';
+import ItemTypeEditor from '../components/ItemTypeEditor.vue';
+import { CommonCategoryBlockType } from '../../CommonCategoryBlocks';
+import { computed } from 'vue';
+
+const props = defineProps<{
+  cat: IHomeCommonCategoryListTabNestCategoryItemDefine;
+}>();
+const emit = defineEmits<{
+  (e: 'remove'): void;
+  (e: 'moveUp'): void;
+  (e: 'moveDown'): void;
+  (e: 'copy'): void;
+}>();
+
+const blockTypes = (CommonCategoryBlockType as string[]).concat(
+  [
+    'speicalMergeItem:getColumns',
+    'speicalMergeItem:getColumn',
+  ].map((type) => (
+    CommonCategoryBlockType.map(p => `${type}:${p}`)
+  ))
+  .flat()
+);
+const titleLevels = ['h1', 'h2', 'h3'];
+
+const isBlockType = computed(() => props.cat.type?.endsWith('Block'));
+
+/** 确保并返回合并类型的 params,用于 speicalMergeItem:getColumns / getColumn */
+function getMergeParams(cat: IHomeCommonCategoryListTabNestCategoryItemDefine, kind: 'getColumns'): IHomeCommonCategoryCategoryDynamicDataMergeTypeGetColumns['params'];
+function getMergeParams(cat: IHomeCommonCategoryListTabNestCategoryItemDefine, kind: 'getColumn'): IHomeCommonCategoryCategoryDynamicDataMergeTypeGetColumn['params'];
+function getMergeParams(cat: IHomeCommonCategoryListTabNestCategoryItemDefine, kind: 'getColumns' | 'getColumn') {
+  if (!cat.params) cat.params = {} as Record<string, any>;
+  const p = cat.params as Record<string, any>;
+  if (kind === 'getColumns') {
+    if (!Array.isArray(p.overrideItems)) p.overrideItems = [];
+    if (p.otherParams === undefined) p.otherParams = {};
+  }
+  return cat.params as any;
+}
+
+function onTypeChange(cat: IHomeCommonCategoryListTabNestCategoryItemDefine, type: string | undefined) {
+  if (type === 'speicalMergeItem:getColumns') {
+    getMergeParams(cat, 'getColumns');
+  } else if (type === 'speicalMergeItem:getColumn') {
+    getMergeParams(cat, 'getColumn');
+  }
+}
+function getOverrideItems(cat: IHomeCommonCategoryListTabNestCategoryItemDefine) {
+  const params = getMergeParams(cat, 'getColumns');
+  return params.overrideItems as ({ id: number } & IHomeCommonCategoryListTabNestCategoryItemDefine)[];
+}
+function addOverrideItem(cat: IHomeCommonCategoryListTabNestCategoryItemDefine) {
+  const params = getMergeParams(cat, 'getColumns');
+  (params.overrideItems as any[]).push({ id: 0 });
+}
+
+function removeOverrideItem(cat: IHomeCommonCategoryListTabNestCategoryItemDefine, index: number) {
+  const params = getMergeParams(cat, 'getColumns');
+  (params.overrideItems as any[]).splice(index, 1);
+}
+function setOverrideItem(cat: IHomeCommonCategoryListTabNestCategoryItemDefine, index: number, value: Record<string, any>) {
+  const params = getMergeParams(cat, 'getColumns');
+  const arr = params.overrideItems as any[];
+  if (arr[index]) arr[index] = { ...arr[index], ...value };
+}
+</script>
+
+<style lang="scss" scoped>
+.override-item-row {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+  margin-bottom: 8px;
+  padding: 6px;
+  background: #f5f5f5;
+  border-radius: 4px;
+
+  .override-item-kv {
+    flex: 1;
+  }
+}
+</style>

+ 111 - 0
src/pages/article/data/editor/subpart/PageListPanel.vue

@@ -0,0 +1,111 @@
+<template>
+  <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="onMenuClick">
+            <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="pageList" size="small" class="page-list">
+      <template #renderItem="{ item }">
+        <a-list-item
+          :class="{ 'page-item-active': selectedPage?.name === item.name }"
+          class="page-list-item"
+          @click="emit('update: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>
+          <div class="page-item-actions">
+            <ArrowUpOutlined title="上移" @click.stop="emit('move-up', item)" />
+            <ArrowDownOutlined title="下移" @click.stop="emit('move-down', item)" />
+            <CopyOutlined title="克隆页面" @click.stop="emit('duplicate', item)" />
+            <a-popconfirm title="确认删除吗?" @confirm="emit('delete', item)">
+              <a-button type="text" danger size="small" @click.stop="">
+                <DeleteOutlined title="删除" />
+              </a-button>
+            </a-popconfirm>
+          </div>
+        </a-list-item>
+      </template>
+    </a-list>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { PlusOutlined, ArrowUpOutlined, ArrowDownOutlined, CopyOutlined, DeleteOutlined } from '@ant-design/icons-vue';
+import type { IHomeCommonCategoryDefine } from '../../CommonCategoryDefine';
+
+defineProps<{
+  pageList: IHomeCommonCategoryDefine['page'];
+  selectedPage: (IHomeCommonCategoryDefine['page'][0]) | null;
+}>();
+
+const emit = defineEmits<{
+  (e: 'update:selectedPage', page: IHomeCommonCategoryDefine['page'][0]): void;
+  (e: 'move-up', page: IHomeCommonCategoryDefine['page'][0]): void;
+  (e: 'move-down', page: IHomeCommonCategoryDefine['page'][0]): void;
+  (e: 'duplicate', page: IHomeCommonCategoryDefine['page'][0]): void;
+  (e: 'delete', page: IHomeCommonCategoryDefine['page'][0]): void;
+  (e: 'add-page', template: 'Home' | 'CommonList' | 'Details'): void;
+}>();
+
+function onMenuClick(e: { key: string }) {
+  emit('add-page', e.key as 'Home' | 'CommonList' | 'Details');
+}
+</script>
+
+<style scoped>
+.panel-pages {
+  width: 300px;
+  flex-shrink: 0;
+}
+.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;
+}
+.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-actions {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 4px;
+  flex-shrink: 0;
+  padding: 0 4px;
+}
+.page-item-active {
+  background: #e6f7ff;
+}
+</style>

+ 62 - 0
src/pages/article/data/editor/subpart/PreviewPanel.vue

@@ -0,0 +1,62 @@
+<template>
+  <div class="panel panel-preview">
+    <div class="panel-title">小程序预览</div>
+    <div class="preview-wrap">
+      <EditorPreview
+        ref="previewRef"
+        :editor-json="editorJson"
+        :selected-page="selectedPage"
+        :test-detail-id="testDetailId"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import EditorPreview from './EditorPreview.vue';
+import type { IHomeCommonCategoryDefine } from '../../CommonCategoryDefine';
+
+defineProps<{
+  editorJson: IHomeCommonCategoryDefine;
+  selectedPage: (IHomeCommonCategoryDefine['page'][0]) | null;
+  testDetailId: number;
+}>();
+
+const previewRef = ref<InstanceType<typeof EditorPreview> | null>(null);
+
+defineExpose({
+  refresh() {
+    previewRef.value?.refresh?.();
+  },
+});
+</script>
+
+<style scoped>
+.panel-preview {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 16px;
+  overflow: auto;
+}
+.panel-title {
+  padding: 8px 12px;
+  font-weight: 600;
+  border-bottom: 1px solid #eee;
+  flex-shrink: 0;
+}
+.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>

+ 59 - 0
src/pages/article/data/editor/subpart/PropsPanel.vue

@@ -0,0 +1,59 @@
+<template>
+  <div class="panel panel-props">
+    <div class="panel-title panel-title-with-action">
+      属性编辑
+      <a-button type="primary" size="small" @click="emit('refresh')">刷新</a-button>
+    </div>
+    <a-empty v-if="!selectedPage" class="panel-empty" description="请选择页面" />
+    <PropsEditorTree
+      v-else
+      :page="selectedPage"
+      v-model:testDetailId="testDetailIdModel"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import PropsEditorTree from './PropsEditorTree.vue';
+import type { IHomeCommonCategoryDefine } from '../../CommonCategoryDefine';
+
+const props = defineProps<{
+  selectedPage: (IHomeCommonCategoryDefine['page'][0]) | null;
+  testDetailId: number;
+}>();
+
+const emit = defineEmits<{
+  (e: 'refresh'): void;
+  (e: 'update:testDetailId', v: number): void;
+}>();
+
+const testDetailIdModel = computed({
+  get: () => props.testDetailId,
+  set: (v) => emit('update:testDetailId', v),
+});
+</script>
+
+<style scoped>
+.panel-props {
+  width: 720px;
+  flex-shrink: 0;
+}
+.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-empty {
+  padding: 16px;
+  color: #999;
+  font-size: 12px;
+}
+</style>

+ 4 - 3
src/pages/home/index.vue

@@ -35,7 +35,7 @@
           :icon="item.icon"
           :size="item.size"
           :buttonStyle="item.style"
-          @click="navTo(item.link[0], item.link[1] as Record<string, unknown>)"
+          @click="navTo(item.link)"
         />
       </view>
 
@@ -43,11 +43,11 @@
       <CommonCategoryBlocks :categoryDefine="categoryDefine" />
     </view>
   </view>
-  <Tabbar :current="0" />
+  <Tabbar v-if="!isEditorPreview" :current="0" />
 </template>
 
 <script setup lang="ts">
-import { computed } from 'vue';
+import { computed, inject } from 'vue';
 import { onShareTimeline, onShareAppMessage } from '@dcloudio/uni-app';
 import { navTo } from '@/components/utils/PageAction';
 import { injectCommonCategory } from '../article/data/CommonCategoryGlobalLoader';
@@ -61,6 +61,7 @@ import type { CategoryDefine } from '../article/data/CommonCategoryBlocks';
 const commonCategory = injectCommonCategory();
 const pageDefine = computed(() => commonCategory.value.page.find((p) => p.name === 'home'));
 const pageContentDefine = computed(() => pageDefine.value?.content as IHomeCommonCategoryHomeDefine);
+const isEditorPreview = inject('editorPreviewMark', false);
 
 const categoryDefine = computed(() => pageContentDefine.value?.props.categorys
   .filter((item) => item.visible !== false)