快乐的梦鱼 1 неделя назад
Родитель
Сommit
0bd6de348b

+ 33 - 26
src/pages/article/data/CommonCategoryDynamicData.ts

@@ -93,36 +93,43 @@ export type IHomeCommonCategoryDynamicData = IHomeCommonCategoryDynamicDataCommo
   | IHomeCommonCategoryDynamicDataRequest;
 
 /**
+ * 序列化接口映射表
+ */
+export const SerializedApiMap = {
+  BulidingContent,
+  ActivityContent,
+  InheritorContent,
+  MoveableContent,
+  ProductsContent,
+  ProjectsContent,
+  SeminarContent,
+  UnitContent,
+  UnmoveableContent,
+  CalendarContent,
+  ScenicSpotContent,
+  CharacterContent,
+  FeatureContent,
+  HistoryContent,
+  IndexContent,
+  LanguageContent,
+  PolicyContent,
+  SeaContent,
+  VictualsContent,
+  CustomContent,
+  TeamsContent,
+  DiscussContent,
+  ResultContent,
+};
+
+/**
  * 动态数据序列化接口
  */
 export function CommonCategoryDynamicDataSerializedApi(item: IHomeCommonCategoryDynamicDataSerializedApi) {
-  switch (item.name) {
-    case 'BulidingContent': return BulidingContent;
-    case 'ActivityContent': return ActivityContent;
-    case 'InheritorContent': return InheritorContent;
-    case 'MoveableContent': return MoveableContent;
-    case 'ProductsContent': return ProductsContent;
-    case 'ProjectsContent': return ProjectsContent;
-    case 'SeminarContent': return SeminarContent;
-    case 'UnitContent': return UnitContent;
-    case 'UnmoveableContent': return UnmoveableContent;
-    case 'CalendarContent': return CalendarContent;
-    case 'ScenicSpotContent': return ScenicSpotContent;
-    case 'CharacterContent': return CharacterContent;
-    case 'FeatureContent': return FeatureContent;
-    case 'HistoryContent': return HistoryContent;
-    case 'IndexContent': return IndexContent;
-    case 'LanguageContent': return LanguageContent;
-    case 'PolicyContent': return PolicyContent;
-    case 'SeaContent': return SeaContent;
-    case 'VictualsContent': return VictualsContent;
-    case 'CustomContent': return CustomContent;
-    case 'TeamsContent': return TeamsContent;
-    case 'DiscussContent': return DiscussContent;
-    case 'ResultContent': return ResultContent;
-    default:
-      throw new Error(`未实现的序列化接口 ${item.name}`);
+  const api = SerializedApiMap[item.name as keyof typeof SerializedApiMap];
+  if (!api) {
+    throw new Error(`未实现的序列化接口 ${item.name}`);
   }
+  return api;
 }
 //加载接口
 export async function doLoadDynamicListData(

+ 1 - 0
src/pages/article/data/CommonCategoryPathDefine.ts

@@ -0,0 +1 @@
+export const CommonCategoryListPath = '/pages/article/data/list';

+ 20 - 6
src/pages/article/data/defines/List.ts

@@ -22,12 +22,7 @@ export interface IHomeCommonCategoryListDefine {
     /**
      * 列表选项卡定义
      */
-    tabs?: (IHomeCommonCategoryListTabDefine & {
-      text: string,
-      width?: number,
-      visible?: boolean,
-      detailsPage?: string,
-    })[],
+    tabs?: IHomeCommonCategoryListTabItemDefine[],
     /**
      * 列表选项卡数据解决方法
      */
@@ -39,6 +34,25 @@ export interface IHomeCommonCategoryListDefine {
   },
 }
 
+export type IHomeCommonCategoryListTabItemDefine = IHomeCommonCategoryListTabDefine & {
+  /**
+   * 列表选项卡文本
+   */
+  text: string,
+  /**
+   * 列表选项卡宽度
+   */
+  width?: number,
+  /**
+   * 列表选项卡是否可见
+   */
+  visible?: boolean,
+  /**
+   * 列表选项卡详情页
+   */
+  detailsPage?: string,
+}
+
 /**
  * 列表选项卡数据处理方法
  * * none: 不处理

+ 4 - 0
src/pages/editor/MiniProgramEditor.php

@@ -0,0 +1,4 @@
+<?php
+
+const MINI_PROGRAM_EDITOR_JSON_PATH = '/www/wwwroot/minnanCE/public/app_static/minnan/data/dynamicCategoryConfig.json';
+

+ 154 - 1
src/pages/editor/MiniProgramEditor.vue

@@ -1,9 +1,162 @@
 <template>
+  <a-config-provider :locale="zhCN">
+    <div class="miniprogram-editor">
+      <div class="editor-toolbar">
+        <a-space>
+          <a-button type="primary" @click="loadEditorJson">加载</a-button>
+          <a-button @click="saveEditorJson">保存</a-button>
+        </a-space>
+      </div>
+      <div class="editor-body">
+        <!-- 左一:页面列表 -->
+        <div class="panel panel-pages">
+          <div class="panel-title">页面列表</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 }"
+                @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-list-item>
+            </template>
+          </a-list>
+        </div>
+
+        <!-- 左二:属性编辑器 -->
+        <div class="panel panel-props">
+          <div class="panel-title">属性编辑</div>
+          <div v-if="!selectedPage" class="panel-empty">请选择页面</div>
+          <PropsEditorTree
+            v-else
+            :page="selectedPage"
+          />
+        </div>
+
+        <!-- 中间:预览 -->
+        <div class="panel panel-preview">
+          <div class="panel-title">小程序预览</div>
+          <div class="preview-wrap">
+            <EditorPreview
+              :editor-json="currentEditorJson"
+              :selected-page="selectedPage"
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+  </a-config-provider>
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue';
+import { computed, provide, ref } from 'vue';
+import { ObjectUtils } from '@imengyu/imengyu-utils';
+import type { IHomeCommonCategoryDefine } from '../article/data/CommonCategoryDefine';
+import DefaultEditorJson from '../article/data/DefaultCategory.json';
+import PropsEditorTree from './subpart/PropsEditorTree.vue';
+import EditorPreview from './subpart/EditorPreview.vue';
+import zhCN from 'ant-design-vue/es/locale/zh_CN';
+
+const currentEditorJson = ref<IHomeCommonCategoryDefine>(DefaultEditorJson as IHomeCommonCategoryDefine);
+const selectedPage = ref<(IHomeCommonCategoryDefine['page'][0]) | null>(null);
+const pageList = computed(() => currentEditorJson.value.page || []);
+
+provide('pageList', pageList);
+
+async function loadEditorJson() {
+  // 临时使用本地文件,后续接入后端接口
+  currentEditorJson.value = ObjectUtils.clone(DefaultEditorJson) as IHomeCommonCategoryDefine;
+}
+
+async function saveEditorJson() {
+  // 临时测试,后续接入后端接口
+  console.log(currentEditorJson.value);
+}
 </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;
+}
+.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-pages {
+  width: 220px;
+  flex-shrink: 0;
+}
+.panel-props {
+  width: 620px;
+  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-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>

+ 109 - 0
src/pages/editor/components/ArrayEditor.vue

@@ -0,0 +1,109 @@
+<template>
+  <div class="array-editor">
+    <div class="array-container">
+      <div 
+        v-for="(item, index) in localArray" 
+        :key="index"
+        class="array-item"
+      >
+        <value-editor
+          v-model:modelValue="localArray[index]"
+          @update:modelValue="updateArray"
+          :forceOneLevel="forceOneLevel"
+          class="array-item-input"
+        />
+        <a-popconfirm
+          title="确定要删除这个项吗?"
+          ok-text="确认"
+          cancel-text="取消"
+          @confirm="removeItem(index)"
+        >
+          <a-button 
+            type="text" 
+            danger 
+            class="item-remove"
+          >
+            删除
+          </a-button>
+        </a-popconfirm>
+      </div>
+      <a-button type="dashed" block @click="addItem">
+        <plus-outlined /> 添加项
+      </a-button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch } from 'vue';
+import { PlusOutlined } from '@ant-design/icons-vue';
+import ValueEditor from './ValueEditor.vue';
+
+// 定义props
+const props = defineProps<{
+  modelValue?: any[];
+  /**
+   * 这会限制用户只能创建简单的值,而不能嵌套对象或数组。
+   * @default false
+   */
+  forceOneLevel?: boolean;
+}>();
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: any[]): void;
+}>();
+
+// 本地数据
+const localArray = ref<any[]>([]);
+
+// 监听props变化
+watch(
+  () => props.modelValue,
+  (newValue) => {
+    if (newValue) {
+      localArray.value = [...newValue];
+    } else {
+      localArray.value = [];
+    }
+  },
+  { deep: true, immediate: true }
+);
+
+// 更新数组
+const updateArray = () => {
+  emit('update:modelValue', [...localArray.value]);
+};
+
+// 添加项
+const addItem = () => {
+  localArray.value.push('');
+  updateArray();
+};
+
+// 删除项
+const removeItem = (index: number) => {
+  localArray.value.splice(index, 1);
+  updateArray();
+};
+</script>
+
+<style scoped>
+.array-editor {
+  width: 100%;
+}
+
+.array-container {
+  width: 100%;
+}
+
+.array-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 8px;
+}
+
+.array-item-input {
+  flex: 1;
+  margin-right: 8px;
+}
+</style>

+ 274 - 0
src/pages/editor/components/DynamicDataEditor.vue

@@ -0,0 +1,274 @@
+<template>
+  <div class="dynamic-data-editor">
+    <a-form layout="vertical" size="small">
+      <a-form-item label="数据源类型">
+        <a-select
+          :value="currentType"
+          style="width: 100%"
+          :options="typeOptions"
+          placeholder="选择类型"
+          @change="onTypeChange"
+        />
+      </a-form-item>
+
+      <template v-if="currentType === 'commonContent'">
+        <a-form-item label="模型 ID (modelId)">
+          <a-input-number
+            :value="(modelValue as IHomeCommonCategoryDynamicDataCommonContent)?.params?.modelId"
+            style="width: 100%"
+            :min="1"
+            placeholder="模型ID"
+            @update:value="(v: number | undefined) => setCommonContent('modelId', v)"
+          />
+        </a-form-item>
+        <a-form-item label="栏目 ID (mainBodyColumnId)">
+          <a-input
+            :value="mainBodyColumnIdDisplay('commonContent')"
+            placeholder="数字或逗号分隔的多个ID,如 320 或 315,316"
+            @change="(e: Event) => setMainBodyColumnId('commonContent', (e.target as HTMLInputElement)?.value)"
+          />
+        </a-form-item>
+        <a-form-item label="分类类型 ID (typeId)">
+          <a-input-number
+            :value="(modelValue as IHomeCommonCategoryDynamicDataCommonContent)?.params?.typeId"
+            style="width: 100%"
+            placeholder="可选"
+            @update:value="(v: number | undefined) => setCommonContent('typeId', v)"
+          />
+        </a-form-item>
+      </template>
+
+      <template v-else-if="currentType === 'serializedApi'">
+        <a-form-item label="序列化接口名称 (name)">
+          <a-select
+            :value="(modelValue as IHomeCommonCategoryDynamicDataSerializedApi)?.name"
+            style="width: 100%"
+            show-search
+            :filter-option="filterOption"
+            :options="serializedApiOptions"
+            placeholder="选择或输入接口名"
+            @change="(v: string) => setSerializedApi('name', v)"
+          />
+        </a-form-item>
+        <a-form-item label="栏目 ID (params.mainBodyColumnId)">
+          <a-input
+            :value="mainBodyColumnIdDisplay('serializedApi')"
+            placeholder="可选,数字或逗号分隔"
+            @change="(e: Event) => setSerializedApiMainBodyColumnId((e.target as HTMLInputElement)?.value)"
+          />
+        </a-form-item>
+      </template>
+
+      <template v-else-if="currentType === 'request'">
+        <a-form-item label="请求方法 (method)">
+          <a-select
+            :value="(modelValue as IHomeCommonCategoryDynamicDataRequest)?.method"
+            style="width: 100%"
+            :options="methodOptions"
+            @change="(v: 'OPTIONS' | 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE') => setRequest('method', v)"
+          />
+        </a-form-item>
+        <a-form-item label="请求 URL (url)">
+          <a-input
+            :value="(modelValue as IHomeCommonCategoryDynamicDataRequest)?.url"
+            placeholder="https://..."
+            @change="(e: Event) => setRequest('url', (e.target as HTMLInputElement)?.value)"
+          />
+        </a-form-item>
+        <a-form-item label="查询参数 (querys) JSON">
+          <a-input
+            :value="requestQuerysJson"
+            placeholder='{"key": "value"}'
+            :rows="2"
+            @change="(e: Event) => setRequestQuerys((e.target as HTMLInputElement)?.value)"
+          />
+        </a-form-item>
+        <a-form-item label="请求体参数 (params) JSON">
+          <a-input
+            :value="requestParamsJson"
+            placeholder='{"key": "value"}'
+            :rows="2"
+            @change="(e: Event) => setRequestParams((e.target as HTMLInputElement)?.value)"
+          />
+        </a-form-item>
+      </template>
+    </a-form>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import type {
+  IHomeCommonCategoryDynamicData,
+  IHomeCommonCategoryDynamicDataCommonContent,
+  IHomeCommonCategoryDynamicDataSerializedApi,
+  IHomeCommonCategoryDynamicDataRequest,
+} from '@/pages/article/data/CommonCategoryDynamicData';
+import { SerializedApiMap } from '@/pages/article/data/CommonCategoryDynamicData';
+
+const props = defineProps<{
+  modelValue?: IHomeCommonCategoryDynamicData | null;
+}>();
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', v: IHomeCommonCategoryDynamicData | undefined): void;
+}>();
+
+const typeOptions = [
+  { value: 'commonContent', label: '通用内容 (commonContent)' },
+  { value: 'serializedApi', label: '序列化接口 (serializedApi)' },
+  { value: 'request', label: '请求 (request)' },
+];
+
+const methodOptions = [
+  { value: 'GET', label: 'GET' },
+  { value: 'POST', label: 'POST' },
+  { value: 'PUT', label: 'PUT' },
+  { value: 'DELETE', label: 'DELETE' },
+  { value: 'HEAD', label: 'HEAD' },
+  { value: 'OPTIONS', label: 'OPTIONS' },
+];
+
+const serializedApiOptions = computed(() =>
+  Object.keys(SerializedApiMap).map((name) => ({ value: name, label: name }))
+);
+
+const currentType = computed(() => props.modelValue?.type ?? null);
+
+function filterOption(input: string, option: { value: string; label: string }) {
+  return option.label.toLowerCase().includes(input.toLowerCase());
+}
+
+function mainBodyColumnIdDisplay(source: 'commonContent' | 'serializedApi'): string {
+  const val = source === 'commonContent'
+    ? (props.modelValue as IHomeCommonCategoryDynamicDataCommonContent)?.params?.mainBodyColumnId
+    : (props.modelValue as IHomeCommonCategoryDynamicDataSerializedApi)?.params?.mainBodyColumnId;
+  if (val == null) return '';
+  if (Array.isArray(val)) return val.join(',');
+  return String(val);
+}
+
+function parseMainBodyColumnId(input: string): string | number | number[] | undefined {
+  const s = input?.trim();
+  if (!s) return undefined;
+  const parts = s.split(',').map((p) => p.trim()).filter(Boolean);
+  if (parts.length === 0) return undefined;
+  if (parts.length === 1) {
+    const n = Number(parts[0]);
+    return Number.isNaN(n) ? parts[0] : n;
+  }
+  const nums = parts.map((p) => Number(p));
+  return nums.every((n) => !Number.isNaN(n)) ? nums : undefined;
+}
+
+function onTypeChange(type: 'commonContent' | 'serializedApi' | 'request') {
+  if (type === 'commonContent') {
+    emit('update:modelValue', {
+      type: 'commonContent',
+      params: { modelId: 1, mainBodyColumnId: undefined, typeId: undefined },
+    });
+  } else if (type === 'serializedApi') {
+    emit('update:modelValue', {
+      type: 'serializedApi',
+      name: Object.keys(SerializedApiMap)[0] ?? 'ProjectsContent',
+    });
+  } else if (type === 'request') {
+    emit('update:modelValue', {
+      type: 'request',
+      method: 'GET',
+      url: '',
+    });
+  }
+}
+
+function setCommonContent(key: 'modelId' | 'typeId', value: number | undefined) {
+  const cur = props.modelValue as IHomeCommonCategoryDynamicDataCommonContent | undefined;
+  if (!cur || cur.type !== 'commonContent') return;
+  const next = { ...cur, params: { ...cur.params, [key]: value } };
+  emit('update:modelValue', next);
+}
+
+function setMainBodyColumnId(source: 'commonContent' | 'serializedApi', input: string) {
+  const parsed = parseMainBodyColumnId(input);
+  if (source === 'commonContent') {
+    const cur = props.modelValue as IHomeCommonCategoryDynamicDataCommonContent | undefined;
+    if (!cur || cur.type !== 'commonContent') return;
+    emit('update:modelValue', { ...cur, params: { ...cur.params, mainBodyColumnId: parsed } });
+  } else {
+    const cur = props.modelValue as IHomeCommonCategoryDynamicDataSerializedApi | undefined;
+    if (!cur || cur.type !== 'serializedApi') return;
+    emit('update:modelValue', { ...cur, params: { ...cur.params, mainBodyColumnId: parsed } });
+  }
+}
+
+function setSerializedApi(key: 'name', value: string) {
+  const cur = props.modelValue as IHomeCommonCategoryDynamicDataSerializedApi | undefined;
+  if (!cur || cur.type !== 'serializedApi') return;
+  emit('update:modelValue', { ...cur, [key]: value });
+}
+
+function setSerializedApiMainBodyColumnId(input: string) {
+  const parsed = parseMainBodyColumnId(input);
+  const cur = props.modelValue as IHomeCommonCategoryDynamicDataSerializedApi | undefined;
+  if (!cur || cur.type !== 'serializedApi') return;
+  const params = { ...cur.params, mainBodyColumnId: parsed };
+  emit('update:modelValue', { ...cur, params: Object.keys(params).length ? params : undefined });
+}
+
+function setRequest(key: 'method' | 'url', value: string) {
+  const cur = props.modelValue as IHomeCommonCategoryDynamicDataRequest | undefined;
+  if (!cur || cur.type !== 'request') return;
+  emit('update:modelValue', { ...cur, [key]: value });
+}
+
+const requestQuerysJson = computed(() => {
+  const cur = props.modelValue as IHomeCommonCategoryDynamicDataRequest | undefined;
+  if (!cur?.querys) return '';
+  try {
+    return typeof cur.querys === 'string' ? cur.querys : JSON.stringify(cur.querys, null, 2);
+  } catch {
+    return '';
+  }
+});
+
+const requestParamsJson = computed(() => {
+  const cur = props.modelValue as IHomeCommonCategoryDynamicDataRequest | undefined;
+  if (!cur?.params) return '';
+  try {
+    return typeof cur.params === 'string' ? cur.params : JSON.stringify(cur.params, null, 2);
+  } catch {
+    return '';
+  }
+});
+
+function setRequestQuerys(str: string) {
+  const cur = props.modelValue as IHomeCommonCategoryDynamicDataRequest | undefined;
+  if (!cur || cur.type !== 'request') return;
+  let querys: Record<string, any> | undefined;
+  try {
+    querys = str?.trim() ? JSON.parse(str) : undefined;
+  } catch {
+    return;
+  }
+  emit('update:modelValue', { ...cur, querys });
+}
+
+function setRequestParams(str: string) {
+  const cur = props.modelValue as IHomeCommonCategoryDynamicDataRequest | undefined;
+  if (!cur || cur.type !== 'request') return;
+  let params: Record<string, any> | undefined;
+  try {
+    params = str?.trim() ? JSON.parse(str) : undefined;
+  } catch {
+    return;
+  }
+  emit('update:modelValue', { ...cur, params });
+}
+</script>
+
+<style scoped>
+.dynamic-data-editor {
+  font-size: 12px;
+  margin-left: 14px;
+}
+</style>

+ 83 - 0
src/pages/editor/components/IconEditor.vue

@@ -0,0 +1,83 @@
+<template>
+  <div class="icon-editor">
+    <div class="icon-preview">
+      <a-image v-if="modelValue" :src="modelValue" alt="Icon Preview" class="preview-img" />
+      <div v-else class="preview-placeholder">无图标</div>
+    </div>
+    <div class="icon-input">
+      <a-input v-model:value="localValue" placeholder="图标URL" />
+    </div>
+    <div class="icon-upload">
+      <a-button @click="handleUpload">上传</a-button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import CommonContent from '@/api/CommonContent';
+import { ref, watch } from 'vue';
+
+const props = defineProps<{
+  modelValue: string;
+}>();
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: string): void;
+}>();
+
+const localValue = ref(props.modelValue);
+
+watch(localValue, (newValue) => {
+  emit('update:modelValue', newValue);
+});
+watch(() => props.modelValue, (newValue) => {
+  localValue.value = newValue;
+});
+
+async function handleUpload() {
+  const res = await uni.chooseImage({
+    count: 1,
+  });
+  if (res.tempFilePaths.length > 0) {
+    const url = await CommonContent.uploadFile(res.tempFilePaths[0]);
+    localValue.value = url.fullurl;
+  }
+}
+</script>
+
+<style scoped>
+.icon-editor {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.icon-preview {
+  width: 40px;
+  height: 40px;
+  border-radius: 4px;
+  overflow: hidden;
+  background: #f0f0f0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.preview-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.preview-placeholder {
+  font-size: 12px;
+  color: #999;
+}
+
+.icon-input {
+  flex: 1;
+}
+
+.icon-upload {
+  white-space: nowrap;
+}
+</style>

+ 186 - 0
src/pages/editor/components/KeyValueEditor.vue

@@ -0,0 +1,186 @@
+<template>
+  <div class="key-value-editor">
+    <div class="key-value-container">
+      <div 
+        v-for="item in localItems" 
+        :key="item.key"
+        class="key-value-item"
+      >
+        <a-input
+          v-model:value="item.key"
+          placeholder="键"
+          class="key-input"
+          @blur="updateKey(item)"
+        />
+        <value-editor
+          v-model:modelValue="item.value"
+          @update:modelValue="updateValue"
+          :forceOneLevel="forceOneLevel"
+          class="value-input"
+        />
+        <a-popconfirm
+          title="确定要删除这个项吗?"
+          ok-text="确认"
+          cancel-text="取消"
+          @confirm="removeItem(item.key)"
+        >
+          <a-button 
+            type="text" 
+            danger 
+            class="item-remove"
+          >
+            删除
+          </a-button>
+        </a-popconfirm>
+      </div>
+      <a-button type="dashed" block @click="addItem">
+        <plus-outlined /> 添加项
+      </a-button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch } from 'vue';
+import { PlusOutlined } from '@ant-design/icons-vue';
+import ValueEditor from './ValueEditor.vue';
+
+const props = defineProps<{
+  modelValue?: Record<string, any>;
+  /**
+   * 这会限制用户只能创建简单的值,而不能嵌套对象或数组。
+   * @default false
+   */
+  forceOneLevel?: boolean;
+  /**
+   * 默认创建的项的模板。当用户点击添加项时,会根据这个模板创建新的项。
+   * @default { key: '', value: '', type: 'string' }
+   */
+  defaultCreateTemplate?: LocalItem;
+}>();
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: Record<string, any>): void;
+}>();
+
+type LocalItem = {
+  key: string;
+  value: any;
+  type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null';
+};
+
+const localItems = ref<LocalItem[]>([]);
+
+function getType(value: any): LocalItem['type'] {
+  if (value === null) {
+    return 'null';
+  } else if (typeof value === 'string') {
+    return 'string';
+  } else if (typeof value === 'number') {
+    return 'number';
+  } else if (typeof value === 'boolean') {
+    return 'boolean';
+  } else if (Array.isArray(value)) {
+    return 'array';
+  } else if (typeof value === 'object') {
+    return 'object';
+  } else {
+    return 'string';
+  }
+}
+
+// 监听props变化
+watch(
+  () => props.modelValue,
+  (newValue) => {
+    if (newValue) {
+      localItems.value = Object.entries(newValue).map(([key, value]) => ({ 
+        key, 
+        value,
+        type: getType(value),
+      }));
+    } else {
+      localItems.value = [];
+    }
+  },
+  { deep: true, immediate: true }
+);
+
+// 更新值
+const updateValue = () => {
+  const newObj = localItems.value.reduce((acc, cur) => ({
+    ...acc,
+    [cur.key]: cur.value,
+  }), {} as Record<string, any>);
+  emit('update:modelValue', newObj);
+};
+
+// 获取一个可用的键
+const getUseableKey = (key: string) => {
+  if (!key) {
+    key = 'key';
+  }
+  
+  let useableKey = key;
+  let suffix = 1;
+  
+  // 检查键是否已存在
+  while (localItems.value.some((item) => item.key === useableKey)) {
+    useableKey = `${key}${suffix}`;
+    suffix++;
+  }
+  
+  return useableKey;
+};
+
+// 更新键
+const updateKey = (item: LocalItem) => {
+  // 检查是否有重复的key
+  if (localItems.value.some((i) => i.key === item.key)) {
+    item.key = getUseableKey(item.key);
+  }
+};
+
+// 添加项
+const addItem = () => {
+  const template = props.defaultCreateTemplate || {
+    key: `key${localItems.value.length + 1}`,
+    value: '',
+    type: 'string',
+  };
+  template.key = getUseableKey(template.key);
+  localItems.value.push(template);
+  updateValue();
+};
+
+// 删除项
+const removeItem = (key: string) => {
+  localItems.value = localItems.value.filter((item) => item.key !== key);
+  updateValue();
+};
+</script>
+
+<style scoped>
+.key-value-editor {
+  width: 100%;
+}
+
+.key-value-container {
+  width: 100%;
+}
+
+.key-value-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 8px;
+}
+
+.key-input {
+  width: 120px;
+  margin-right: 8px;
+}
+
+.value-input {
+  flex: 1;
+  margin-right: 8px;
+}
+</style>

+ 168 - 0
src/pages/editor/components/LinkPathEditor.vue

@@ -0,0 +1,168 @@
+<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="path-selector">
+            <a-select 
+              v-model:value="pathType" 
+              style="width: 150px; margin-right: 8px" 
+              @change="handlePathTypeChange"
+            >
+              <a-select-option value="dynamic-list">动态页面列表</a-select-option>
+              <a-select-option value="internal">程序内置页面</a-select-option>
+              <a-select-option value="custom">自定义输入</a-select-option>
+            </a-select>
+            
+            <!-- 动态页面地址 -->
+            <a-select 
+              v-if="pathType === 'dynamic-list'"
+              v-model:value="localPageConfigName"
+              style="flex: 1"
+              @change="updateLink"
+              placeholder="请选择动态页面"
+            >
+              <a-select-option 
+                v-for="page in pageList" 
+                :key="page.name" 
+                :value="page.name"
+              >
+                {{ page.title }} ({{ page.name }})
+              </a-select-option>
+            </a-select>
+            
+            <!-- 程序内置页面 -->
+            <a-select 
+              v-else-if="pathType === 'internal'"
+              v-model:value="linkPath"
+              style="flex: 1"
+              @change="updateLink"
+              placeholder="请选择内置页面"
+            >
+              <a-select-option 
+                v-for="page in internalPages" 
+                :key="page.path" 
+                :value="page.path"
+              >
+                {{ page.path }}
+              </a-select-option>
+            </a-select>
+            
+            <!-- 自定义输入 -->
+            <a-input
+              v-else-if="pathType === 'custom'"
+              v-model:value="linkPath"
+              placeholder="请输入跳转路径"
+              @change="updateLink"
+              style="flex: 1"
+            />
+          </div>
+        </a-form-item>    
+        <a-form-item v-if="pathType !== 'dynamic-list'" label="参数设置">
+          <KeyValueEditor
+            v-model="localParams"
+            :forceOneLevel="true"
+            :defaultCreateTemplate="{ key: '', value: '', type: 'string' }"
+          />
+        </a-form-item>
+      </a-collapse-panel>
+    </a-collapse>
+  </a-form-item>
+</template>
+
+<script setup lang="ts">
+import { inject, ref, watch, computed } from 'vue';
+import KeyValueEditor from './KeyValueEditor.vue';
+import type { IHomeCommonCategoryDefine } from '@/pages/article/data/CommonCategoryDefine';
+import PagesJson from '@/pages.json';
+import { CommonCategoryListPath } from '@/pages/article/data/CommonCategoryPathDefine';
+
+// 定义props
+const props = defineProps<{
+  modelValue?: string|[string, object]|undefined;
+}>();
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: [string, object]): void;
+}>();
+
+const pageList = inject<(IHomeCommonCategoryDefine['page'][0])[]>('pageList', []);
+const activeKey = ref('');
+
+// 跳转路径类型
+const pathType = ref<'dynamic-list' | 'internal' | 'custom'>('custom');
+// 跳转路径
+const linkPath = ref('');
+const localPageConfigName = ref('');
+const localParams = ref<Record<string, string>>({});
+
+// 从PagesJson中提取内置页面
+const internalPages = computed(() => {
+  if (!PagesJson || !PagesJson.pages) {
+    return [];
+  }
+  return PagesJson.pages.map((page: any) => ({
+    path: "/" + page.path
+  }));
+});
+
+// 监听参数变化
+watch(
+  () => props.modelValue,
+  (newValue) => {
+    if (Array.isArray(newValue)) {
+      linkPath.value = newValue[0] || '';
+      localParams.value = newValue[1] as Record<string, string> || {};
+    } else {
+      linkPath.value = newValue || '';
+      localParams.value = {};
+    }
+    if (linkPath.value == CommonCategoryListPath) {
+      pathType.value = 'dynamic-list';
+      localPageConfigName.value = localParams.value.pageConfigName || '';
+    } else if (linkPath.value.startsWith('/pages/')) {
+      pathType.value = 'internal';
+    } else {
+      pathType.value = 'custom';
+    }
+  },
+  { deep: true, immediate: true }
+);
+watch(
+  localParams,
+  () => {
+    updateLink();
+  },
+  { deep: true }
+);
+
+// 处理路径类型变化
+const handlePathTypeChange = () => {
+  updateLink();
+};
+
+// 更新链接
+const updateLink = () => {
+  if (pathType.value === 'dynamic-list') {
+    localParams.value = {
+      pageConfigName: localPageConfigName.value || '',
+    };
+    linkPath.value = CommonCategoryListPath;
+  }
+  emit('update:modelValue', [
+    linkPath.value,
+    localParams.value,
+  ]);
+};
+</script>
+
+<style lang="scss" scoped>
+.link-path-editor {
+  ::v-deep .ant-collapse-header {
+    padding: 0 !important;
+  }
+  .path-selector {
+    display: flex;
+    align-items: center;
+  }
+}
+</style>

+ 240 - 0
src/pages/editor/components/ValueEditor.vue

@@ -0,0 +1,240 @@
+<template>
+  <div class="value-editor">
+    <div class="value-editor-content">
+      <!-- 字符串编辑器 -->
+      <a-input
+        v-if="type === 'string'"
+        v-model:value="localValue"
+        placeholder="请输入字符串"
+        @change="updateValue"
+        class="value-input"
+      />
+      
+      <!-- 数字编辑器 -->
+      <a-input-number
+        v-else-if="type === 'number'"
+        v-model:value="localNumberValue"
+        placeholder="请输入数字"
+        @change="updateValue"
+        class="value-input"
+      />
+      
+      <!-- 布尔编辑器 -->
+      <a-switch
+        v-else-if="type === 'boolean'"
+        v-model:checked="localBoolValue"
+        @change="updateValue"
+        class="boolean-input"
+      />
+      
+      <!-- 对象编辑器 -->
+      <key-value-editor
+        v-else-if="type === 'object'"
+        v-model:modelValue="localObjectValue"
+        @update:modelValue="updateValue"
+        :forceOneLevel="forceOneLevel"
+        class="object-input"
+      />
+      
+      <!-- 数组编辑器 -->
+      <array-editor
+        v-else-if="type === 'array'"
+        v-model:modelValue="localArrayValue"
+        @update:modelValue="updateValue"
+        :forceOneLevel="forceOneLevel"
+        class="array-input"
+      />
+      
+      <!-- null编辑器 -->
+      <div v-else-if="type === 'null'" class="null-editor">
+        <span class="null-value">null</span>
+      </div>
+    </div>
+    <div class="value-editor-type-selector">
+      <a-dropdown @select="changeType">
+        <a-button type="text" class="type-select-button">
+          {{ type }}
+          <down-outlined />
+        </a-button>
+        <template #overlay>
+          <a-menu>
+            <a-menu-item key="string">字符串</a-menu-item>
+            <a-menu-item key="number">数字</a-menu-item>
+            <a-menu-item key="boolean">布尔</a-menu-item>
+            <a-menu-item key="object" v-if="!forceOneLevel">对象</a-menu-item>
+            <a-menu-item key="array" v-if="!forceOneLevel">数组</a-menu-item>
+            <a-menu-item key="null">null</a-menu-item>
+          </a-menu>
+        </template>
+      </a-dropdown>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, computed } from 'vue';
+import { DownOutlined } from '@ant-design/icons-vue';
+import KeyValueEditor from './KeyValueEditor.vue';
+import ArrayEditor from './ArrayEditor.vue';
+
+const props = defineProps<{
+  modelValue?: any;
+  /**
+   * 这会限制用户只能创建简单的值,而不能嵌套对象或数组。
+   * @default false
+   */
+  forceOneLevel?: boolean;
+}>();
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: any): void;
+}>();
+
+export type LocalItemType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null';
+
+// 计算当前值的类型
+const type = computed<LocalItemType>(() => {
+  const value = props.modelValue;
+  if (value === null) {
+    return 'null';
+  } else if (typeof value === 'string') {
+    return 'string';
+  } else if (typeof value === 'number') {
+    return 'number';
+  } else if (typeof value === 'boolean') {
+    return 'boolean';
+  } else if (Array.isArray(value)) {
+    return 'array';
+  } else if (typeof value === 'object') {
+    return 'object';
+  } else {
+    return 'string';
+  }
+});
+
+// 本地值
+const localValue = ref<string>(props.modelValue as string || '');
+const localNumberValue = ref<number>(typeof props.modelValue === 'number' ? props.modelValue : 0);
+const localBoolValue = ref<boolean>(!!props.modelValue);
+const localObjectValue = ref<Record<string, any>>(props.modelValue as Record<string, any> || {});
+const localArrayValue = ref<any[]>(props.modelValue as any[] || []);
+
+// 监听props变化
+watch(
+  () => props.modelValue,
+  (newValue) => {
+    if (type.value === 'string') {
+      localValue.value = newValue as string || '';
+    } else if (type.value === 'number') {
+      localNumberValue.value = typeof newValue === 'number' ? newValue : 0;
+    } else if (type.value === 'boolean') {
+      localBoolValue.value = !!newValue;
+    } else if (type.value === 'object') {
+      localObjectValue.value = newValue as Record<string, any> || {};
+    } else if (type.value === 'array') {
+      localArrayValue.value = newValue as any[] || [];
+    }
+  },
+  { deep: true, immediate: true }
+);
+
+// 更新值
+const updateValue = () => {
+  let value: any;
+  
+  if (type.value === 'string') {
+    value = localValue.value;
+  } else if (type.value === 'number') {
+    value = localNumberValue.value;
+  } else if (type.value === 'boolean') {
+    value = localBoolValue.value;
+  } else if (type.value === 'object') {
+    value = localObjectValue.value;
+  } else if (type.value === 'array') {
+    value = localArrayValue.value;
+  } else if (type.value === 'null') {
+    value = null;
+  } else {
+    value = localValue.value;
+  }
+  
+  emit('update:modelValue', value);
+};
+
+// 更改类型
+const changeType = (newType: LocalItemType) => {
+  let newValue: any;
+  
+  switch (newType) {
+    case 'string':
+      newValue = '';
+      break;
+    case 'number':
+      newValue = 0;
+      break;
+    case 'boolean':
+      newValue = false;
+      break;
+    case 'object':
+      newValue = {};
+      break;
+    case 'array':
+      newValue = [];
+      break;
+    case 'null':
+      newValue = null;
+      break;
+    default:
+      newValue = '';
+  }
+  
+  emit('update:modelValue', newValue);
+};
+</script>
+
+<style scoped>
+.value-editor {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.value-editor-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.value-input {
+  width: 100%;
+}
+
+.boolean-input {
+  margin: 4px 0;
+}
+
+.object-input,
+.array-input {
+  width: 100%;
+}
+
+.null-editor {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 4px 0;
+}
+
+.null-value {
+  color: #999;
+  font-style: italic;
+}
+
+.value-editor-type-selector {
+  white-space: nowrap;
+}
+
+.type-select-button {
+  min-width: 80px;
+  text-align: center;
+}
+</style>

+ 150 - 0
src/pages/editor/editors/CommonListPropsEditor.vue

@@ -0,0 +1,150 @@
+<template>
+  <div class="common-list-props-editor">
+    <a-form :labelCol="{ span: 6 }" size="small">
+      <a-form-item label="页面标题 (title)">
+        <a-input v-model:value="props.props.title" />
+      </a-form-item>
+      <a-form-item label="显示 Tab">
+        <a-switch v-model:checked="props.props.showTab" :checked-value="true" :un-checked-value="false" />
+      </a-form-item>
+      <a-form-item label="显示搜索">
+        <a-switch v-model:checked="props.props.showSearch" :checked-value="true" :un-checked-value="false" />
+      </a-form-item>
+      <a-form-item label="显示总数">
+        <a-switch v-model:checked="props.props.showTotal" :checked-value="true" :un-checked-value="false" />
+      </a-form-item>
+      <a-form-item label="列表项类型 (itemType)">
+        <a-input v-model:value="props.props.itemType" placeholder="如 image-large-2" />
+      </a-form-item>
+      <a-form-item label="详情页 (detailsPage)">
+        <a-input v-model:value="props.props.detailsPage" />
+      </a-form-item>
+    </a-form>
+
+    <a-collapse v-model:activeKey="activeKeys" class="props-collapse">
+      <a-collapse-panel key="dataSolve" header="数据解决方法 (dataSolve)">
+        <a-select
+          v-model:value="props.props.dataSolve"
+          mode="multiple"
+          style="width: 100%"
+          :options="dataSolveOptions"
+          placeholder="选择"
+         
+        />
+      </a-collapse-panel>
+
+      <a-collapse-panel key="data" header="数据 (data)">
+        <DynamicDataEditor v-model="props.props.data" />
+      </a-collapse-panel>
+
+      <a-collapse-panel key="tabs" header="选项卡 (tabs) 树型结构">
+        <div v-for="(tab, i) in tabItems" :key="tabKey(tab, i)" class="nested-item tab-item">
+          <a-collapse>
+            <a-collapse-panel :key="i" :header="tabHeader(tab)">
+              <a-form size="small">
+                <a-form-item label="文本">
+                  <a-input v-model:value="tab.text" />
+                </a-form-item>
+                <a-form-item label="类型">
+                  <a-select
+                    v-model:value="tab.type"
+                    style="width: 100%"
+                    :options="tabTypeOptions"
+                  />
+                </a-form-item>
+                <a-form-item label="显示(仅在选项卡可见时)">
+                  <a-switch 
+                    :checked="tab.visible !== false" 
+                    @change="tab.visible = $event"
+                  />
+                </a-form-item>
+                <template v-if="tab.type === 'nestCategory'">
+                  <a-form-item label="子分类 (categorys)">
+                    
+                    <!--TODO: 使用子分类组件-->
+                  </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>
+        </div>
+        <a-button type="dashed" block size="small" @click="addTab">+ 添加 Tab</a-button>
+      </a-collapse-panel>
+    </a-collapse>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from 'vue';
+import type { IHomeCommonCategoryListDefine, IHomeCommonCategoryListTabItemDefine } from '../../article/data/CommonCategoryDefine';
+import DynamicDataEditor from '../components/DynamicDataEditor.vue';
+
+type TabItem = IHomeCommonCategoryListTabItemDefine;
+
+const props = defineProps<{
+  props: IHomeCommonCategoryListDefine['props'];
+}>();
+const emit = defineEmits<{
+  (e: 'update', p: IHomeCommonCategoryListDefine['props']): void;
+}>();
+
+const activeKeys = ref<string[]>(['tabs']);
+const tabTypeOptions = [
+  { value: 'list', label: '列表' },
+  { value: 'jump', label: '跳转' },
+  { value: 'nestCategory', label: '子分类' },
+];
+const tabItems = computed(() => (props.props?.tabs || []) as TabItem[]);
+const dataSolveOptions = [
+  { value: 'none', label: '无' },
+  { value: 'ich', label: '显示传承人相关数据' },
+  { value: 'common', label: '显示通用数据(来源,栏目名称)' },
+  { value: 'date', label: '显示日期' },
+];
+
+function tabKey(tab: TabItem, i: number) {
+  return `${i}-${tab.text}-${tab.type}`;
+}
+function tabHeader(tab: TabItem) {
+  const t = tab.type || '?';
+  const text = tab.text || '';
+  return `${text || 'Tab'} (${t})`;
+}
+
+
+function addTab() {
+  props.props!.tabs = props.props!.tabs || [];
+  (props.props!.tabs as TabItem[]).push({ text: '新 Tab', type: 'nestCategory', categorys: [] });
+}
+function removeTab(i: number) {
+  props.props!.tabs?.splice(i, 1);
+}
+</script>
+
+<style scoped>
+.common-list-props-editor {
+  font-size: 12px;
+}
+.props-collapse {
+  margin-top: 8px;
+}
+.nested-item {
+  margin-bottom: 12px;
+  padding: 8px;
+  background: #fafafa;
+  border-radius: 4px;
+}
+.tab-item :deep(.ant-collapse) {
+  border: none;
+  background: transparent;
+}
+.sub-nested {
+  margin-bottom: 6px;
+}
+</style>

+ 125 - 0
src/pages/editor/editors/HomePropsEditor.vue

@@ -0,0 +1,125 @@
+<template>
+  <div class="home-props-editor">
+    <a-form :labelCol="{ span: 6 }" size="small">
+      <a-form-item label="首页标题">
+        <a-input v-model:value="props.props.title" />
+      </a-form-item>
+      <a-form-item label="首页副标题">
+        <a-input v-model:value="props.props.subTitle" />
+      </a-form-item>
+      <a-form-item label="Banner 图 URL">
+        <IconEditor v-model="props.props.homeBanner" />
+      </a-form-item>
+    </a-form>
+
+    <a-collapse v-model:activeKey="activeKeys" class="props-collapse">
+      <a-collapse-panel key="homeButtons" header="首页按钮 (homeButtons)">
+        <template #extra>
+          <PlusOutlined @click="addHomeButton" />
+        </template>
+        <div v-for="(btn, i) in props.props.homeButtons" :key="i" class="nested-item">
+          <a-form :labelCol="{ span: 6 }" size="small">
+            <a-form-item label="标题">
+              <a-input v-model:value="btn.title" />
+            </a-form-item>
+            <a-form-item label="图标 URL">
+              <IconEditor v-model="btn.icon" />
+            </a-form-item>
+            <a-form-item label="按钮尺寸%">
+              <a-input-number v-model:value="btn.size" :min="1" style="width: 100%" />
+            </a-form-item>
+            <LinkPathEditor v-model="btn.link" />
+            <a-popconfirm title="确定要删除这个按钮吗?" @confirm="removeHomeButton(i)">
+              <a-button type="link" danger size="small">删除</a-button>
+            </a-popconfirm>
+          </a-form>
+        </div>
+      </a-collapse-panel>
+
+      <a-collapse-panel key="categorys" header="首页分类 (categorys)">
+        <div v-for="(cat, i) in props.props.categorys" :key="i" class="nested-item category-item">
+          <a-collapse>
+            <a-collapse-panel :key="i" :header="cat.text || `分类 ${i + 1}`">
+              <a-form layout="vertical" size="small">
+                <a-form-item label="文本">
+                  <a-input v-model:value="cat.text" />
+                </a-form-item>
+                <a-form-item label="显示标题">
+                  <a-switch v-model:checked="cat.showTitle" :checked-value="true" :un-checked-value="false" />
+                </a-form-item>
+                <a-form-item label="类型 (type)">
+                  <a-input v-model:value="cat.type" />
+                </a-form-item>
+                <a-form-item label="详情页">
+                  <a-input v-model:value="cat.detailsPage" />
+                </a-form-item>
+                <a-form-item label="更多页">
+                  <LinkPathEditor v-model="cat.morePage"/>
+                </a-form-item>
+                <a-form-item label="数据 (data)">
+                  <DynamicDataEditor v-model="cat.data" />
+                </a-form-item>
+                <a-popconfirm title="确定要删除这个分类吗?" @confirm="removeCategory(i)">
+                  <a-button type="link" danger size="small">删除分类</a-button>
+                </a-popconfirm>
+              </a-form>
+            </a-collapse-panel>
+          </a-collapse>
+        </div>
+        <a-button type="dashed" block size="small" @click="addCategory">+ 添加分类</a-button>
+      </a-collapse-panel>
+    </a-collapse>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import { PlusOutlined } from '@ant-design/icons-vue';
+import type { IHomeCommonCategoryHomeDefine, IHomeCommonCategoryListTabNestCategoryItemDefine } from '../../article/data/CommonCategoryDefine';
+import LinkPathEditor from '../components/LinkPathEditor.vue';
+import IconEditor from '../components/IconEditor.vue';
+import DynamicDataEditor from '../components/DynamicDataEditor.vue';
+
+const props = defineProps<{
+  props: IHomeCommonCategoryHomeDefine['props'];
+}>();
+const activeKeys = ref<string[]>(['homeButtons', 'categorys']);
+
+function addHomeButton(event: MouseEvent) {
+  event.stopPropagation();
+  props.props.homeButtons = props.props.homeButtons || [];
+  props.props.homeButtons.push({ title: '新按钮', icon: '', link: ['', {}], size: 50 });
+}
+function removeHomeButton(i: number) {
+  props.props.homeButtons.splice(i, 1);
+}
+function addCategory() {
+  props.props.categorys = props.props.categorys || [];
+  props.props.categorys.push({ text: '新分类', type: '', data: undefined } as unknown as IHomeCommonCategoryListTabNestCategoryItemDefine);
+}
+function removeCategory(i: number) {
+  props.props.categorys.splice(i, 1);
+}
+</script>
+
+<style scoped>
+.home-props-editor {
+  font-size: 12px;
+}
+.props-collapse {
+  margin-top: 8px;
+}
+.nested-item {
+  margin-bottom: 12px;
+  padding: 8px;
+  background: #fafafa;
+  border-radius: 4px;
+}
+.category-item :deep(.ant-collapse) {
+  border: none;
+  background: transparent;
+}
+.category-item :deep(.ant-collapse-item) {
+  border: none;
+}
+</style>

+ 76 - 0
src/pages/editor/subpart/EditorPreview.vue

@@ -0,0 +1,76 @@
+<template>
+  <div class="editor-preview">
+    <div v-if="!selectedPage" class="preview-empty">请选择页面进行预览</div>
+    <template v-else>
+      <component
+        :is="previewComponent"
+        v-bind="previewProps"
+      />
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { provide, computed, toRef, markRaw, type Component } from 'vue';
+import { COMMON_CATEGORY_KEY } from '@/pages/article/data/CommonCategoryGlobalLoader';
+import type { IHomeCommonCategoryDefine } from '@/pages/article/data/CommonCategoryDefine';
+import HomePage from '@/pages/home/index.vue';
+import CommonCategoryList from '@/pages/article/data/CommonCategoryList.vue';
+
+const props = defineProps<{
+  editorJson: IHomeCommonCategoryDefine;
+  selectedPage: IHomeCommonCategoryDefine['page'][0] | null;
+}>();
+
+// 注入当前编辑的 JSON,供预览中的 home/list 通过 injectCommonCategory() 读取
+const editorJsonRef = toRef(props, 'editorJson');
+provide(COMMON_CATEGORY_KEY, editorJsonRef);
+
+const previewComponent = computed<Component | null>(() => {
+  const page = props.selectedPage;
+  if (!page?.content) 
+    return null;
+  const type = page.content.type;
+  switch (type) {
+    case 'Home':
+      return markRaw(HomePage);
+    case 'CommonList':
+      return markRaw(CommonCategoryList);
+    default:
+      console.error('未知页面类型:', type);
+      return null;
+  }
+});
+
+const previewProps = computed(() => {
+  const page = props.selectedPage;
+  if (!page) return {};
+  if (page.content.type === 'CommonList') {
+    return {
+      pageConfigName: page.name,
+      pageStartTab: 0,
+      pageQuerys: {},
+    };
+  }
+  return {};
+});
+</script>
+
+<style scoped>
+.editor-preview {
+  position: relative  ;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  overflow-y: scroll;
+}
+.preview-empty {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  min-height: 200px;
+  color: #999;
+  font-size: 14px;
+}
+</style>

+ 65 - 0
src/pages/editor/subpart/PropsEditorTree.vue

@@ -0,0 +1,65 @@
+<template>
+  <div class="props-editor-tree">
+    <a-form :labelCol="{ span: 6 }" size="small" class="props-form">
+      <a-form-item label="页面 key">
+        <a-input v-model:value="props.page.name" disabled />
+      </a-form-item>
+      <a-form-item label="页面标题">
+        <a-input v-model:value="props.page.title" />
+      </a-form-item>
+    </a-form>
+    <a-divider />
+    <div class="content-props-editor">
+      <HomePropsEditor
+        v-if="contentType === 'Home'"
+        :props="(props.page?.content?.props as IHomeCommonCategoryHomeDefine['props'])"
+      />
+      <CommonListPropsEditor
+        v-else-if="contentType === 'CommonList'"
+        :props="(props.page?.content?.props as IHomeCommonCategoryListDefine['props'])"
+      />
+      <div v-else class="unknown-type">未知模板类型: {{ contentType }}</div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import type {
+  IHomeCommonCategoryDefine,
+  IHomeCommonCategoryHomeDefine,
+  IHomeCommonCategoryListDefine,
+} from '@/pages/article/data/CommonCategoryDefine';
+import HomePropsEditor from '../editors/HomePropsEditor.vue';
+import CommonListPropsEditor from '../editors/CommonListPropsEditor.vue';
+
+const props = defineProps<{
+  page: IHomeCommonCategoryDefine['page'][0];
+}>();
+const emit = defineEmits<{
+  (e: 'update:page', page: IHomeCommonCategoryDefine['page'][0]): void;
+}>();
+
+const contentType = computed(() => props.page?.content?.type);
+</script>
+
+<style>
+.props-editor-tree {
+  flex: 1;
+  overflow: auto;
+  padding: 8px;
+}
+.props-editor-tree .ant-form-item{
+  margin-bottom: 14px !important;
+}
+.props-form {
+  margin-bottom: 0;
+}
+.content-props-editor {
+  font-size: 12px;
+}
+.unknown-type {
+  color: #999;
+  padding: 8px 0;
+}
+</style>

+ 1 - 1
src/pages/home/index.vue

@@ -167,7 +167,7 @@ onShareAppMessage(() => {
       }
     }
   }
-  .grid4-item {
+  ::v-deep .grid4-item {
     width: 320rpx;
 
     .tag {