浏览代码

init: 初始化项目

liaojiaxing 11 月之前
当前提交
232f819d07
共有 49 个文件被更改,包括 4029 次插入0 次删除
  1. 24 0
      .gitignore
  2. 3 0
      .vscode/extensions.json
  3. 29 0
      README.md
  4. 7 0
      env.d.ts
  5. 13 0
      index.html
  6. 31 0
      package.json
  7. 1221 0
      pnpm-lock.yaml
  8. 1 0
      public/vite.svg
  9. 18 0
      src/App.vue
  10. 二进制
      src/assets/comp-icon/icon-1.png
  11. 二进制
      src/assets/comp-icon/icon-2.png
  12. 9 0
      src/assets/comp-icon/index.ts
  13. 4 0
      src/components/Text/Title/index.ts
  14. 20 0
      src/components/Text/Title/src/index.vue
  15. 12 0
      src/components/Text/Title/src/props.ts
  16. 7 0
      src/components/index.ts
  17. 108 0
      src/config/compSetting.ts
  18. 9 0
      src/enum/alignEnum.ts
  19. 7 0
      src/enum/compTypeEnum.ts
  20. 13 0
      src/enum/screenFillEnum.ts
  21. 13 0
      src/main.ts
  22. 23 0
      src/router/index.ts
  23. 10 0
      src/store/index.ts
  24. 147 0
      src/store/modules/project.ts
  25. 113 0
      src/store/modules/stage.ts
  26. 11 0
      src/style/index.css
  27. 2 0
      src/style/var.less
  28. 173 0
      src/utils/index.ts
  29. 255 0
      src/views/designer/component/ComponentLibary.vue
  30. 160 0
      src/views/designer/component/ComponentWrapper.vue
  31. 20 0
      src/views/designer/component/Configurator.vue
  32. 143 0
      src/views/designer/component/LayerItem.vue
  33. 78 0
      src/views/designer/component/LayerManagement.vue
  34. 159 0
      src/views/designer/component/MenuBar.vue
  35. 289 0
      src/views/designer/component/Scaleplate.vue
  36. 218 0
      src/views/designer/component/Stage.vue
  37. 96 0
      src/views/designer/component/Workspace.vue
  38. 108 0
      src/views/designer/index.vue
  39. 144 0
      src/views/home/component/AddModal.vue
  40. 91 0
      src/views/home/component/BigscreenManagement.vue
  41. 13 0
      src/views/home/component/DataSourceManagement.vue
  42. 49 0
      src/views/home/index.vue
  43. 15 0
      src/views/system/404.vue
  44. 7 0
      src/vite-env.d.ts
  45. 40 0
      tsconfig.json
  46. 11 0
      tsconfig.node.json
  47. 1 0
      types/module.d.ts
  48. 82 0
      types/project.d.ts
  49. 22 0
      vite.config.ts

+ 24 - 0
.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 3 - 0
.vscode/extensions.json

@@ -0,0 +1,3 @@
+{
+  "recommendations": ["Vue.volar"]
+}

+ 29 - 0
README.md

@@ -0,0 +1,29 @@
+# 沙鲁大屏设计器
+技术方案:Vue 3 + TypeScript + Vite + ant-design-vue
+包管理器:pnpm
+
+安装:pnpm install
+运行:pnpm dev
+
+### 任务列表:
+1. 大屏管理页--完成
+2. 页面布局
+  a. 操作栏 -- 完成
+  b. 图层管理 -- 完成
+  c. 组件管理 -- 完成
+  d. 画布 -- 完成
+  e. 配置页
+3. 通用协议
+4. 画布
+  a. 缩放 -- 完成
+  b. 标尺 -- 完成
+  c. 拖拽
+  d. 吸附
+  e. 辅助线 -- 完成
+  f. 控制容器组件
+5. 渲染器
+6. 属性面板
+7. 组件属性渲染表单
+8. 预览页
+9. 快捷键
+10. 操作记录

+ 7 - 0
env.d.ts

@@ -0,0 +1,7 @@
+/// <reference types="vite/client" />
+
+declare module '*.vue' {
+  import { defineComponent } from 'vue';
+  const component: ReturnType<typeof defineComponent>;
+  export default component;
+}

+ 13 - 0
index.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>沙鲁大屏设计器</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 31 - 0
package.json

@@ -0,0 +1,31 @@
+{
+  "name": "shalu-bigscreen-designer",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@ant-design/icons-vue": "^7.0.1",
+    "@vueuse/components": "^10.11.0",
+    "@vueuse/core": "^10.10.1",
+    "ant-design-vue": "4.x",
+    "dayjs": "^1.11.11",
+    "less": "^4.2.0",
+    "less-loader": "^12.2.0",
+    "pinia": "^2.1.7",
+    "vue": "^3.4.21",
+    "vue-router": "^4.3.3",
+    "vuedraggable": "^4.1.0"
+  },
+  "devDependencies": {
+    "@types/node": "^20.14.2",
+    "@vitejs/plugin-vue": "^5.0.4",
+    "typescript": "^5.2.2",
+    "vite": "^5.2.0",
+    "vue-tsc": "^2.0.6"
+  }
+}

文件差异内容过多而无法显示
+ 1221 - 0
pnpm-lock.yaml


文件差异内容过多而无法显示
+ 1 - 0
public/vite.svg


+ 18 - 0
src/App.vue

@@ -0,0 +1,18 @@
+<script setup lang="ts">
+import { ConfigProvider } from 'ant-design-vue'
+import zhCN from 'ant-design-vue/es/locale/zh_CN'
+import dayjs from 'dayjs'
+import 'dayjs/locale/zh-cn'
+
+dayjs.locale('zh-cn')
+
+</script>
+
+<template>
+  <ConfigProvider :locale="zhCN">
+    <router-view />
+  </ConfigProvider>
+</template>
+
+<style lang="less" scoped>
+</style>

二进制
src/assets/comp-icon/icon-1.png


二进制
src/assets/comp-icon/icon-2.png


+ 9 - 0
src/assets/comp-icon/index.ts

@@ -0,0 +1,9 @@
+const list = import.meta.glob('./**/*.png', {eager: true});
+
+const compMap: Recordable = {};
+Object.keys(list).forEach((key) => {
+  const name = key.replace('./', '').replace('.png', '');
+  const mod = (list as Recordable)[key].default || {};
+  compMap[name] = mod;
+});
+export default compMap;

+ 4 - 0
src/components/Text/Title/index.ts

@@ -0,0 +1,4 @@
+import Title from './src/index.vue';
+export default Title;
+
+export { defaultPropsValue, titleProps } from './src/props';

+ 20 - 0
src/components/Text/Title/src/index.vue

@@ -0,0 +1,20 @@
+<template>
+  <div class="cus-title" v-bind="$attrs">
+    {{ title }}
+  </div>
+</template>
+
+<script setup lang="ts">
+import { defineProps } from 'vue';
+import { titleProps } from './props';
+
+defineProps(titleProps);
+</script>
+
+<style lang="less" scoped>
+.cus-title {
+  font-size: 24px;
+  color: #fff;
+  font-weight: bold;
+}
+</style>

+ 12 - 0
src/components/Text/Title/src/props.ts

@@ -0,0 +1,12 @@
+export const titleProps = {
+  title: {
+    type: String,
+    required: true
+  }
+}
+
+export const defaultPropsValue = {
+  title: '标题',
+  width: 300,
+  height: 80
+}

+ 7 - 0
src/components/index.ts

@@ -0,0 +1,7 @@
+// 导出components文件夹下所有组件
+const allComponents = {
+  Title: () => import("@/components/Text/Title"),
+};
+
+export default allComponents;
+export type ComponentType = keyof typeof allComponents;

+ 108 - 0
src/config/compSetting.ts

@@ -0,0 +1,108 @@
+/* 
+ * 组件库配置文件
+ */
+import { h } from 'vue';
+import { CompTypeEnum } from "@/enum/compTypeEnum";
+import compIcon from "@/assets/comp-icon/index";
+import {
+  AppstoreAddOutlined,
+  FileTextOutlined,
+  PieChartOutlined,
+  PlaySquareOutlined,
+} from "@ant-design/icons-vue";
+
+export interface CompItem {
+  // 名称
+  name: string;
+  // 渲染组件
+  componetName: string;
+  // 图标
+  icon: string;
+}
+
+export interface CompGroup {
+  // 名称
+  name: string;
+  // 子组件
+  children: CompItem[];
+}
+
+export interface CompCategory {
+  // 类型
+  type: CompTypeEnum;
+  // 名称
+  name: string;
+  // 图标
+  icon: () => any;
+  // 是否是组
+  isGroup?: boolean;
+  // 组件
+  children?: CompGroup[] | CompItem[];
+}
+
+
+interface CompSetting {
+  // 组件列表
+  compList: CompCategory[];
+}
+
+/**
+ * 组件库配置文件
+ *  */
+export const compSetting: CompSetting = {
+  compList: [
+    {
+      type: CompTypeEnum.CHART,
+      name: '图表',
+      icon: () => h(PieChartOutlined),
+      isGroup: true,
+      children: [
+        {
+          name: '柱状图',
+          children: [
+            {
+              name: '柱状图',
+              componetName: 'BaseBar',
+              icon: compIcon['icon-1']
+            }
+          ]
+        },
+        {
+          name: '折线图',
+          children: [
+            {
+              name: '折线图',
+              componetName: 'BaseLine',
+              icon: compIcon['icon-2']
+            }
+          ]
+        }
+      ]
+    },
+    {
+      type: CompTypeEnum.TEXT,
+      name: '文本',
+      icon: () => h(FileTextOutlined),
+      isGroup: false,
+      children: [
+        {
+          name: '标题',
+          componetName: 'Title',
+          icon: compIcon['icon-2']
+        },
+      ]
+    },
+    {
+      type: CompTypeEnum.MEDIA,
+      name: '媒体',
+      icon: () => h(PlaySquareOutlined),
+      isGroup: false,
+    },
+    {
+      type: CompTypeEnum.INPUT_COMP,
+      name: '控件',
+      icon: () => h(AppstoreAddOutlined),
+      isGroup: false,
+    }
+  ]
+}

+ 9 - 0
src/enum/alignEnum.ts

@@ -0,0 +1,9 @@
+// 对齐方式枚举
+export enum AlignEnum {
+  Left = "left",
+  Right = "right",
+  VerticalCenter = "vertical_center",
+  Top = "top",
+  Bottom = "bottom",
+  HorizontalCenter = "horizontal_center",
+}

+ 7 - 0
src/enum/compTypeEnum.ts

@@ -0,0 +1,7 @@
+// 控件类型枚举
+export enum CompTypeEnum {
+  CHART = 'chart',
+  TEXT = 'text',
+  MEDIA = 'media',
+  INPUT_COMP = 'inputComp',
+}

+ 13 - 0
src/enum/screenFillEnum.ts

@@ -0,0 +1,13 @@
+// 大屏填充方式
+export enum ScreenFillEnum {
+  // 自动
+  Auto,
+  // 高度铺满
+  FillHeight,
+  // 宽度铺满
+  FillWidth,
+  // 双向铺满
+  FillBoth,
+  // 无
+  None,
+}

+ 13 - 0
src/main.ts

@@ -0,0 +1,13 @@
+import { createApp } from "vue";
+import App from "./App.vue";
+import router from "./router";
+import { setupStore } from "./store";
+import "ant-design-vue/dist/reset.css";
+import "./style/index.css";
+
+const app = createApp(App);
+
+setupStore(app);
+app.use(router);
+
+app.mount("#app");

+ 23 - 0
src/router/index.ts

@@ -0,0 +1,23 @@
+import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router';
+
+const routes: Array<RouteRecordRaw> = [
+  {
+    path: '/',
+    component: () => import('@/views/home/index.vue'),
+  },
+  {
+    path: '/designer',
+    component: () => import('@/views/designer/index.vue'),
+  },
+  {
+    path: '/:pathMatch(.*)*',
+    component: () => import('@/views/system/404.vue')
+  }
+];
+
+const router = createRouter({
+  history: createWebHashHistory(),
+  routes,
+});
+
+export default router;

+ 10 - 0
src/store/index.ts

@@ -0,0 +1,10 @@
+import type { App } from 'vue';
+import { createPinia } from 'pinia';
+
+const store = createPinia();
+
+export function setupStore(app: App<Element>) {
+  app.use(store);
+}
+
+export { store };

+ 147 - 0
src/store/modules/project.ts

@@ -0,0 +1,147 @@
+import { defineStore } from "pinia";
+import type { ProjectInfo, Page, ReferLine, CustomElement } from "#/project";
+import type { ComponentType } from "@/components";
+import componentAll from '@/components';
+import { ScreenFillEnum } from "@/enum/screenFillEnum";
+
+type ProjectState = {
+  projectInfo: ProjectInfo;
+  activePageIndex: number;
+  addCompData: {
+    key: number;
+    name: string;
+    componentType: ComponentType;
+    position: {
+      x: number;
+      y: number;
+    };
+  } | null;
+  mode: 'edit' | 'preview';
+  selectedElementKeys: number[];
+}
+const defaultPage: Page = {
+  name: '页面1',
+  background: {
+    type: 'color',
+    color: '#0b074b',
+    image: '',
+    fillType: ''
+  },
+  elements: [],
+  referLines: []
+}
+const CURRENT_PROJECT = 'currentProject';
+
+export const useProjectStore = defineStore({
+  id: 'project',
+  state: (): ProjectState => ({
+    // 项目信息
+    projectInfo: {
+      name: '',
+      description: '',
+      sizeType: '',
+      width: 0,
+      height: 0,
+      fillType: ScreenFillEnum.Auto,
+      pages: [{ ...defaultPage}]
+    },
+    // 当前编辑页面索引
+    activePageIndex: 0,
+    // 添加组件临时数据
+    addCompData: null,
+    // 视图模式
+    mode: 'edit',
+    // 选中的元素
+    selectedElementKeys: []
+  }),
+  getters: {
+    referLines(state) {
+      return state.projectInfo.pages[state.activePageIndex].referLines;
+    },
+    elements(state) {
+      return state.projectInfo.pages[state.activePageIndex].elements;
+    },
+    currentPage(state) {
+      return state.projectInfo.pages[state.activePageIndex];
+    }
+  },
+  actions: {
+    setProjectInfo(info: any) {
+      Object.assign(this.projectInfo, info);
+      localStorage.setItem(CURRENT_PROJECT, JSON.stringify(info));
+    },
+    getCurrentProjectInfo(): ProjectInfo | undefined {
+      const info = JSON.parse(localStorage.getItem(CURRENT_PROJECT) || '');
+      this.setProjectInfo(info as unknown as ProjectInfo);
+      return info;
+    },
+    addReferLine(line: ReferLine) {
+      this.projectInfo.pages[this.activePageIndex].referLines.push(line);
+    },
+    removeReferLine(key: number) {
+      const index = this.referLines.findIndex((line) => line.key === key);
+      index !== -1 && this.projectInfo.pages[this.activePageIndex].referLines.splice(index, 1);
+    },
+    updateReferLine(line: ReferLine) {
+      const index = this.referLines.findIndex((l) => l.key === line.key);
+      if (index !== -1) {
+        this.projectInfo.pages[this.activePageIndex].referLines[index] = line;
+      }
+    },
+    // 添加组件
+    async addElement(element: CustomElement) {
+      this.addCompData = null;
+      if(!element) return;
+
+      const elements = this.projectInfo.pages[this.activePageIndex].elements;
+      // 获取组件默认属性
+      const { defaultPropsValue } = await componentAll[element.componentType]?.() || {};
+      const index = elements.filter(item => item.componentType === element.componentType).length + 1;
+      const width = defaultPropsValue?.width || 400;
+      const height = defaultPropsValue?.height || 260;
+
+      this.projectInfo.pages[this.activePageIndex].elements.push({
+        ...element,
+        name: element.name + index,
+        zIndex: elements.length + 1,
+        props: defaultPropsValue,
+        position: {
+          x: element.position.x - (width / 2),
+          y: element.position.y - (height / 2)
+        }
+      });
+
+      this.selectedElementKeys.push(element.key);
+    },
+    // 更新组件
+    updateElement(key: number, payload: Record<string, any>) {
+      const element = this.projectInfo.pages[this.activePageIndex].elements.find((item) => item.key === key);
+      if (element) {
+        Object.assign(element, payload);
+      }
+    },
+    // 设置临时添加组件数据
+    setAddCompData(data: any) {
+      this.addCompData = data;
+    },
+    // 清除临时添加组件数据
+    clearAddCompData() {
+      this.addCompData = null;
+    },
+    setMode(mode: 'edit' | 'preview') {
+      this.mode = mode;
+    },
+    // 设置选中的元素
+    setSelectedElementKeys(keys: number[]) {
+      this.selectedElementKeys.push(...keys);
+    },
+    // 删除选中的元素
+    deleteSelectedElement(keys: number[]) {
+      this.selectedElementKeys = this.selectedElementKeys.filter((key) => !keys.includes(key));
+    },
+    // 删除所有选中的元素
+    clearAllSelectedElement() {
+      this.selectedElementKeys = [];
+    },
+  }
+});

+ 113 - 0
src/store/modules/stage.ts

@@ -0,0 +1,113 @@
+import { defineStore } from "pinia";
+import { useProjectStore } from "@/store/modules/project";
+
+import { ReferLine } from "#/project";
+
+interface StageState {
+  // 缩放比例
+  scale: number;
+  // 大屏宽度
+  width: number;
+  // 大屏高度
+  height: number;
+  // x坐标原点位置
+  originX: number;
+  // y坐标原点位置
+  originY: number;
+  // 视口宽度
+  viewportWidth: number;
+  // 视口高度
+  viewportHeight: number;
+  // 屏幕中心原点x
+  centerX: number;
+  // 屏幕中心原点y
+  centerY: number;
+  // x坐标滚动
+  scrollX: number;
+  // y坐标滚动
+  scrollY: number;
+  // 容器宽度
+  wrapperWidth: number;
+  // 容器高度
+  wrapperHeight: number;
+}
+
+export const useStageStore = defineStore({
+  id: 'stage',
+  state: (): StageState => ({
+    scale: 1,
+    width: 1280,
+    height: 720,
+    originX: 0,
+    originY: 0,
+    viewportWidth: 0,
+    viewportHeight: 0,
+    centerX: 0,
+    centerY: 0,
+    scrollX: 0,
+    scrollY: 0,
+    wrapperWidth: 0,
+    wrapperHeight: 0,
+  }),
+  getters: {
+    // 根据滚动和缩放,重新计算辅助线位置
+    getReferLines(state): ReferLine[] {
+      const { scale, scrollX, scrollY, originX, originY } = state;
+      const projectStore = useProjectStore();
+      return projectStore.referLines
+      .map((line) => {
+        
+        let x = line.x || 0;
+        let y = line.y || 0;
+        if(line.type === 'horizontal') {
+          x = originX + line.value * scale - scrollX + 20;
+        } else {
+          y = originY + line.value * scale - scrollY + 20;
+        }
+
+        return {
+          ...line,
+          x,
+          y
+        }
+      })
+      .filter((line) => {
+        // 过滤掉不在视口内的辅助线
+        if(line.type === 'horizontal') {
+          return line.x > 20 && line.x < state.viewportWidth;
+        } else {
+          return line.y > 20 && line.y < state.viewportHeight;
+        }
+      })
+    }
+  },
+  actions: {
+    setSize(width: number, height: number) {
+      this.width = width;
+      this.height = height;
+    },
+    setStageWaraaperSize(width: number, height: number) {
+      this.wrapperWidth = width;
+      this.wrapperHeight = height;
+    },
+    setViewportSize(width: number, height: number) {
+      this.viewportWidth = width;
+      this.viewportHeight = height;
+    },
+    setOriginPoint(originX: number, originY: number) {
+      this.originX = originX;
+      this.originY = originY;
+    },
+    setScale(scale: number) {
+      this.scale = scale;
+    },
+    setCenterPoint(x: number, y: number) {
+      this.centerX = x;
+      this.centerY = y;
+    },
+    setScroll(x: number, y: number) {
+      this.scrollX = x;
+      this.scrollY = y;
+    },
+  }
+})

+ 11 - 0
src/style/index.css

@@ -0,0 +1,11 @@
+/* Modify the scrollbar track */
+::-webkit-scrollbar {
+  width: 4px; /* Adjust the width as needed */
+  height: 4px;
+}
+
+/* Modify the scrollbar thumb */
+::-webkit-scrollbar-thumb {
+  background-color: #888; /* Set the background color of the thumb */
+  border-radius: 5px; /* Adjust the border radius as needed */
+}

+ 2 - 0
src/style/var.less

@@ -0,0 +1,2 @@
+@bg-color: #f7fafc;
+@primary-color: #1890ff;

+ 173 - 0
src/utils/index.ts

@@ -0,0 +1,173 @@
+/**
+ * 刻度尺绘制
+ *
+ * @param {*} canvas - canvas元素
+ * @param {*} canvasStyleWidth - canvasStyleWidth宽度
+ * @param {*} canvasStyleHeight - canvasStyleHeight高度
+ * @param {*} direcotion - 标尺方向
+ * @param {*} scale - 缩放比例
+ * @param {*} scrollX - 滚动x
+ * @param {*} scrollY - 滚动y
+ * @param {*} originX - 原点x
+ * @param {*} originY - 原点y
+ *  */
+export function drawScaleplate({
+  canvas,
+  canvasStyleWidth,
+  canvasStyleHeight,
+  direcotion,
+  scale,
+  scrollX,
+  scrollY,
+  originX,
+  originY,
+}: {
+  canvas: HTMLCanvasElement;
+  direcotion: "horizontal" | "vertical";
+  canvasStyleWidth: number;
+  canvasStyleHeight: number;
+  scale: number;
+  scrollX: number;
+  scrollY: number;
+  originX: number;
+  originY: number;
+}) {
+  const ctx = canvas.getContext("2d");
+  if (!ctx) return;
+
+  ctx.clearRect(0, 0, canvas.width, canvas.height);
+  // 计算出清晰canvas的原始宽度 = 样式宽度 * 屏幕倍率
+  const drp = window.devicePixelRatio || 1;
+  const width = (canvas.width = canvasStyleWidth * drp);
+  const height = (canvas.height = canvasStyleHeight * drp);
+
+  // 起始位置
+  const hStartNum = (scrollX - originX) / scale;
+  const vStartNum = (scrollY - originY) / scale;
+
+  // 计算大刻度, 判断跟20 50 100 250 500 1000谁更接近用谁
+  const tickSpacingOptions = [20, 50, 100, 250, 500, 1000];
+  const maxTickNum = tickSpacingOptions.reduce((prev, curr) => {
+    return Math.abs(curr - 100 / scale) < Math.abs(prev - 100 / scale)
+      ? curr
+      : prev;
+  });
+  // 小刻度数
+  const minTickNum = maxTickNum / 10;
+  // 计算最小刻度的距离
+  const minTickSpacing = (maxTickNum / 10) * scale * drp;
+
+  const maxLength = direcotion === "horizontal" ? width : height;
+  // 记录起始刻度值
+  let startNum = Math.round(
+    direcotion === "horizontal" ? hStartNum : vStartNum
+  );
+  // 计算起始刻度偏移量
+  let startTickOffset = 0;
+  if (startNum % minTickNum !== 0) {
+    startTickOffset += Math.abs((startNum % minTickNum)) * scale * drp;
+    startNum -= startNum % minTickNum;
+  }
+
+  // 每间隔小刻度数就绘制一个刻度
+  for (
+    let tickSpacing = startTickOffset;
+    tickSpacing < maxLength;
+    tickSpacing += minTickSpacing
+  ) {
+    // 如果当前为大刻度 需要展示数字
+    if(startNum % maxTickNum === 0) {
+      drawMaxTick(
+        ctx,
+        direcotion === "horizontal"
+          ? tickSpacing
+          : 0,
+        direcotion === "horizontal"
+          ? 0
+          : tickSpacing,
+        startNum,
+        direcotion
+      );
+    } else if(startNum % minTickNum === 0) {
+      // 如果当前为小刻度
+      drawMinTick(
+        ctx,
+        direcotion === "horizontal"
+          ? tickSpacing
+          : 0,
+        direcotion === "horizontal"
+          ? 0
+          : tickSpacing,
+        direcotion
+      );
+    }
+    startNum += minTickNum;
+  }
+}
+
+// 绘制大刻度
+function drawMaxTick(
+  ctx: CanvasRenderingContext2D,
+  x: number,
+  y: number,
+  num: number,
+  direcotion: "horizontal" | "vertical"
+) {
+  const drp = window.devicePixelRatio || 1;
+  ctx.beginPath();
+  if (direcotion === "horizontal") {
+    ctx.moveTo(x + 1, 2);
+    ctx.lineTo(x + 1, y + 20 * drp);
+  } else {
+    ctx.moveTo(x + 1, y);
+    ctx.lineTo(x + 20 * drp, y);
+  }
+  ctx.strokeStyle = "#666";
+  ctx.lineWidth = 1 * drp;
+  ctx.stroke();
+  ctx.fillStyle = "#666";
+  ctx.font = "16px Arial";
+  if (direcotion === "horizontal") {
+    ctx.fillText(num.toString(), x + 5, 10 * drp);
+  } else {
+    ctx.save();
+    if(num / 10 >= 100) {
+      // >=1000
+      ctx.translate(8, y + 25 * drp);
+    } else if(num / 10 >= 10) {
+      // >=100
+      ctx.translate(8, y + 20 * drp);
+    } else if(num / 10 >= 1) {
+      // >=10
+      ctx.translate(8, y + 20 * drp);
+    } else if(num / 10 < 0) {
+      ctx.translate(8, y + 25 * drp);
+    } else {
+      ctx.translate(8, y + 10 * drp);
+    }
+    ctx.rotate(-Math.PI / 2);
+    ctx.fillText(num.toString(), 0, 10);
+    ctx.restore();
+  }
+}
+
+// 绘制小刻度
+function drawMinTick(
+  ctx: CanvasRenderingContext2D,
+  x: number,
+  y: number,
+  direcotion: "horizontal" | "vertical"
+) {
+  const drp = window.devicePixelRatio || 1;
+  ctx.beginPath();
+  if (direcotion === "horizontal") {
+    ctx.moveTo(x + 1, 12 * drp);
+    ctx.lineTo(x + 1, y + 20 * drp);
+  } else {
+    ctx.moveTo(12 * drp, y);
+    ctx.lineTo(x + 20 * drp, y);
+  }
+  ctx.strokeStyle = "#666";
+  ctx.lineWidth = 1 * drp;
+  ctx.stroke();
+}

+ 255 - 0
src/views/designer/component/ComponentLibary.vue

@@ -0,0 +1,255 @@
+<template>
+  <div id="compBox">
+    <!-- 组件弹窗 -->
+    <div class="comp-list-drawer" :style="getDrawerStyle" ondragover="return false">
+      <div class="drawer-title">{{ selectedMenu?.name }}</div>
+      <Collapse :bordered="false" style="border-radius: 0" v-if="selectedMenu?.isGroup">
+        <CollapsePanel
+          class="custom-collapse-panel"
+          v-for="group in getList"
+          :key="group.name"
+          :header="group.name"
+        >
+          <Space :size="4" wrap>
+            <div class="comp-item"
+              v-for="item in group.children"
+              :key="item.name"
+              @click="handleAddComp(item)"
+            >
+              <img :src="item.icon" draggable="true" @dragstart="handleDragStart(item)" @dragend="handleDragEnd">
+              <div class="comp-name">{{ item.name }}</div>
+            </div>
+          </Space>
+        </CollapsePanel>
+      </Collapse>
+      <div class="comp-box" v-else>
+        <Space :size="4" wrap>
+          <div class="comp-item"
+            v-for="item in getList"
+            :key="item.name"
+            @click="handleAddComp(item)"
+          >
+            <img :src="item.icon" draggable="true" @dragstart="handleDragStart(item)" @dragend="handleDragEnd"/>
+            <div class="comp-name">{{ item.name }}</div>
+          </div>
+        </Space>
+      </div>
+      
+    </div>
+
+    <Layout style="height: 100%">
+      <LayoutSider
+        collapsible
+        v-model:collapsed="collapsed"
+        :collapsed-width="60"
+        style="background: #f7fafc"
+        width="100"
+      >
+        <Menu theme="light" @click="handleMenuClick" v-model:selected-keys="selectedKeys" class="comp-menu">
+          <MenuItem v-for="item in compSetting.compList" :key="item.type">
+            <component :is="item.icon" />
+            <span>{{ item.name }}</span>
+          </MenuItem>
+        </Menu>
+      </LayoutSider>
+    </Layout>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch } from "vue";
+import type { Ref } from "vue";
+import {
+  LayoutSider,
+  Layout,
+  Menu,
+  MenuItem,
+  Collapse,
+  CollapsePanel,
+  Space,
+} from "ant-design-vue";
+import { compSetting } from "@/config/compSetting";
+import type { CompCategory, CompItem } from "@/config/compSetting";
+import { useEventListener } from "@vueuse/core"
+import { useProjectStore } from "@/store/modules/project";
+import { useStageStore } from "@/store/modules/stage";
+
+const projectStore = useProjectStore();
+const stageStore = useStageStore();
+const openDrawer: Ref<boolean> = ref(false); // 抽屉显示
+const collapsed: Ref<boolean> = ref(false); // 菜单折叠
+const selectedMenu: Ref<CompCategory | undefined> = ref(); // 选中菜单
+const selectedKeys: Ref<string[]> = ref([]); // 选中菜单key
+// 抽屉样式
+const getDrawerStyle = computed(() => {
+  const openLeft = collapsed.value ? "64px" : "104px";
+  return {
+    left: openDrawer.value ? openLeft : "-500%",
+    Transition: "0.5s ease",
+  };
+});
+// 获取选中组件列表
+const getList = computed(() => {
+  return selectedMenu.value?.children || [];
+});
+
+let timer: number;
+watch(
+  () => openDrawer.value,
+  (val) => {
+    if (!val) {
+      timer = setTimeout(() => {
+        selectedMenu.value = undefined;
+      }, 500);
+    } else {
+      clearTimeout(timer);
+    }
+  }
+);
+
+useEventListener(document, "click", (e) => {
+  if (openDrawer.value && !e?.target?.closest(".comp-list-drawer") && !e?.target?.closest(".comp-menu")) {
+    openDrawer.value = false;
+    selectedKeys.value = [];
+  }
+});
+
+// 点击菜单
+const handleMenuClick = (e: any) => {
+  const menu = compSetting.compList.find((item) => item.type === e.key);
+  if (selectedMenu.value?.type === e.key) {
+    openDrawer.value = false;
+    selectedKeys.value = [];
+  } else {
+    openDrawer.value = true;
+  }
+  selectedMenu.value = menu;
+};
+// 点击组件时 直接添加组件到中心位置
+const handleAddComp = (item: CompItem) => {
+  selectedKeys.value = [];
+  openDrawer.value = false;
+  const compData = {
+    key: Date.now(),
+    name: item.name,
+    componentType: item.componetName,
+    position: {
+      x: stageStore.width / 2,
+      y: stageStore.height / 2,
+    },
+  };
+
+  projectStore.addElement(compData);
+};
+// 拖拽添加组件方式
+// 1、拖拽开始时记录要添加的对象
+// 2、拖拽到画布区域时通过drop事件完成添加组件
+// 3、清空临时数据
+const handleDragStart = (item: CompItem) => {
+  projectStore.setAddCompData({
+    key: Date.now(),
+    name: item.name,
+    componentType: item.componetName,
+    positon: {
+      x: 0,
+      y: 0,
+    }
+  })
+};
+// 拖拽结束 清空临时数据
+const handleDragEnd = () => {
+  projectStore.clearAddCompData();
+};
+</script>
+
+<style lang="less" scoped>
+#compBox {
+  height: 100%;
+}
+.comp-list-drawer {
+  transition: 0.5s ease;
+  position: absolute;
+  width: 200px;
+  height: calc(100% - 16px);
+  top: 8px;
+  left: -500%;
+  border-radius: 4px;
+  background-color: #fff;
+  box-shadow: -6px 0 16px 0 rgba(0, 0, 0, 0.08),
+    -3px 0 6px -4px rgba(0, 0, 0, 0.12), -9px 0 28px 8px rgba(0, 0, 0, 0.05);
+  .drawer-title {
+    background: @bg-color;
+    line-height: 32px;
+    font-size: 14px;
+    padding: 0 12px;
+  }
+
+  .custom-collapse-panel {
+    border-radius: 0;
+    background: #fff;
+    ::v-deep .ant-collapse-header {
+      padding: 4px 16px;
+      border-bottom: solid 1px #f0f0f0;
+      background: @bg-color;
+      font-size: 13px;
+    }
+    ::v-deep .ant-collapse-content-box {
+      padding: 16px;
+      padding-bottom: 24px;
+    }
+  }
+}
+.comp-box {
+  padding: 16px;
+}
+.comp-item {
+  text-align: center;
+  font-size: 12px;
+  cursor: pointer;
+  &:hover {
+    img {
+      border: solid 1px #1677ff;
+    }
+  }
+  img {
+    width: 80px;
+    height: 54px;
+    object-fit: contain;
+    border: solid 1px #fff;
+  }
+}
+
+.ant-menu-light {
+  background: none;
+  ::v-deep {
+    .ant-menu-item:not(.ant-menu-item-selected):hover {
+      background: #eff8ff;
+    }
+    .ant-menu-item {
+      width: 100%;
+      border-radius: 0;
+      margin-inline: 0;
+      &:not(.ant-menu-item-selected) {
+        color: #666;
+      }
+    }
+    .ant-menu-item-selected {
+      position: relative;
+    }
+    .ant-menu-item-selected::before {
+      content: "";
+      position: absolute;
+      left: 0;
+      top: 0;
+      height: 100%;
+      border-left: 3px solid #1890ff;
+    }
+  }
+}
+::v-deep {
+  .ant-layout-sider-trigger {
+    background: none;
+    color: #666;
+  }
+}
+</style>

+ 160 - 0
src/views/designer/component/ComponentWrapper.vue

@@ -0,0 +1,160 @@
+<template>
+  <div
+    class="component-wrapper"
+    ref="componentWrapperRef"
+    :style="warpperStyle"
+  >
+    <div class="component-content">
+      <component :is="component" v-bind="componentData.props" />
+    </div>
+    <div class="edit-box" :style="editWapperStyle" v-if="showEditBox">
+      <span class="edit-box-point top-left"></span>
+      <span class="edit-box-point top-center"></span>
+      <span class="edit-box-point top-right"></span>
+      <span class="edit-box-point left-center"></span>
+      <span class="edit-box-point right-center"></span>
+      <span class="edit-box-point bottom-left"></span>
+      <span class="edit-box-point bottom-center"></span>
+      <span class="edit-box-point bottom-right"></span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { defineProps, defineAsyncComponent, computed, ref } from "vue";
+import componentAll from "@/components";
+import type { CustomElement } from "#/project";
+
+import { useStageStore } from "@/store/modules/stage";
+import { useProjectStore } from "@/store/modules/project";
+import { useDraggable } from "@vueuse/core";
+
+const { componentData } = defineProps<{ componentData: CustomElement }>();
+// 动态引入组件
+const component = defineAsyncComponent(
+  componentAll[componentData.componentType]
+);
+const componentWrapperRef = ref<HTMLElement | null>(null);
+const stageStore = useStageStore();
+const projectStore = useProjectStore();
+const editWapperStyle = computed(() => {
+  const { width = 400, height = 260 } = componentData.props || {};
+  return {
+    transform: `scale(${1 / stageStore.scale})`,
+    transformOrigin: "50% 50%",
+    width: `${width * stageStore.scale}px`,
+    height: `${height * stageStore.scale}px`,
+    border: "1px solid #1890ff",
+    left:  (width / 2) * (1 - stageStore.scale) + "px",
+    top: (height / 2) * (1 - stageStore.scale) + "px",
+  };
+});
+const warpperStyle = computed(() => {
+  const { width = 400, height = 260 } = componentData.props || {};
+  const { position } = componentData || {};
+  return {
+    width: `${width}px`,
+    height: `${height}px`,
+    left:  position.x + "px",
+    top: position.y + "px",
+  };
+});
+// 是否显示编辑框
+const showEditBox = computed(() => {
+  return projectStore.mode === 'edit' && projectStore.selectedElementKeys.includes(componentData.key);
+});
+
+const handleDragPoint = (e: MouseEvent) => {
+  e.stopPropagation();
+  console.log(e)
+};
+
+// 拖拽移动组件
+useDraggable(componentWrapperRef, {
+  onMove: (position) => {
+    const originPosition = componentWrapperRef.value!.getBoundingClientRect();
+    // 计算移动的距离
+    const x = position.x - originPosition.left;
+    const y = position.y - originPosition.top;
+
+    projectStore.updateElement(componentData.key, {
+      position: {
+        x: componentData.position.x + x,
+        y: componentData.position.y + y,
+      },
+    })
+  },
+  capture: false,
+  preventDefault: false,
+  stopPropagation: false
+});
+</script>
+
+<style lang="less" scoped>
+.component-wrapper {
+  position: absolute;
+}
+.component-content {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  left: 0;
+  top: 0;
+}
+.edit-box {
+  position: absolute;
+  &-point {
+    position: absolute;
+    width: 6px;
+    height: 6px;
+    background: #fff;
+    border-radius: 50%;
+    border: solid 1px @primary-color;
+  }
+  .top-left {
+    top: -3px;
+    left: -3px;
+    cursor: nw-resize;
+  }
+  .top-center {
+    top: -3px;
+    left: 50%;
+    transform: translateX(-50%);
+    transform-origin: center;
+    cursor: n-resize;
+  }
+  .top-right {
+    top: -3px;
+    right: -3px;
+    cursor: ne-resize;
+  }
+  .left-center {
+    top: 50%;
+    left: -3px;
+    transform: translateY(-50%);
+    cursor: w-resize;
+  }
+  .right-center {
+    top: 50%;
+    right: -3px;
+    transform: translateY(-50%);
+    cursor: e-resize;
+  }
+  .bottom-left {
+    bottom: -3px;
+    left: -3px;
+    cursor: sw-resize;
+  }
+  .bottom-center {
+    bottom: -3px;
+    left: 50%;
+    transform: translateX(-50%);
+    cursor: s-resize;
+  }
+  .bottom-right {
+    bottom: -3px;
+    right: -3px;
+    cursor: se-resize;
+  }
+}
+</style>

+ 20 - 0
src/views/designer/component/Configurator.vue

@@ -0,0 +1,20 @@
+<template>
+  <div>
+    <Tabs type="card">
+      <TabPane key="1" tab="组件">
+        <div>基本配置</div>
+      </TabPane>
+      <TabPane key="2" tab="样式">
+        <div>样式配置</div>
+      </TabPane>
+    </Tabs>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Tabs, TabPane } from 'ant-design-vue';
+</script>
+
+<style scoped>
+
+</style>

+ 143 - 0
src/views/designer/component/LayerItem.vue

@@ -0,0 +1,143 @@
+<template>
+  <div class="list-item" 
+    :class="{ 'list-item-active': layerData.active }"
+    @mouseenter="isHover = true"
+    @mouseleave="isHover = false"
+    @click="handleActive"
+  >
+    <template v-if="!isEditing">
+      <span class="list-item-visible">
+        <span v-show="isHover">
+          <EyeOutlined v-if="true" />
+          <EyeInvisibleOutlined v-else />
+        </span>
+      </span>
+      <span class="layer-name">
+        <Tooltip :title="data.name">
+          {{ data.name }}
+        </Tooltip>
+      </span>
+      <span class="layer-action">
+        <span v-show="isHover">
+          <Dropdown :trigger="['click']">
+            <Tooltip title="更多">
+              <MoreOutlined />
+            </Tooltip>
+            <template #overlay>
+              <Menu @click="handleMenuClick">
+                <MenuItem key="rename"><EditOutlined />重命名</MenuItem>
+                <MenuItem key="del"><DeleteOutlined />删除</MenuItem>
+              </Menu>
+            </template>
+          </Dropdown>
+
+          <Tooltip title="锁定" v-if="true">
+            <LockOutlined />
+          </Tooltip>
+          <Tooltip title="解锁" v-else>
+            <UnlockOutlined />
+          </Tooltip>
+        </span>
+      </span>
+    </template>
+
+    <Input 
+      v-else
+      v-model:value="layerData.name"
+      placeholder="请输入图层名称"
+      size="small"
+      @blur="handleChangeName"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { defineProps, defineEmits, computed, ref } from "vue";
+import { Tooltip, Dropdown, Menu, MenuItem, Input } from "ant-design-vue";
+import {
+  EyeOutlined,
+  EyeInvisibleOutlined,
+  MoreOutlined,
+  LockOutlined,
+  UnlockOutlined,
+  EditOutlined,
+  DeleteOutlined,
+} from "@ant-design/icons-vue";
+
+interface LayerItemData {
+  // 图层名称
+  name: string;
+  // 图层id
+  id: number;
+  // 是否可见
+  visible: boolean;
+  // 是否锁定
+  lock: boolean;
+  // 是否激活
+  active: boolean;
+}
+
+const props = defineProps<{
+  data: LayerItemData;
+}>();
+
+const emit = defineEmits(["change", "delete"]);
+
+const layerData = computed({
+  get: () => props.data,
+  set: (value: LayerItemData) => emit("change", value),
+})
+
+const isEditing = ref(false);
+const isHover = ref(false);
+
+const handleMenuClick = ({key}: {key: 'rename' | 'del'}) => {
+  if(key === 'rename') {
+    isEditing.value = true;
+  } else {
+    emit("delete", props.data.id);
+  }
+};
+
+const handleChangeName = () => {
+  isEditing.value = false;
+};
+
+const handleActive = () => {
+  layerData.value.active = !layerData.value.active;
+};
+</script>
+
+<style lang="less" scoped>
+.list-item {
+  padding: 4px 8px;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: move;
+  color: #666;
+  font-size: 12px;
+  line-height: 24px;
+  &:hover {
+    background: #e6f4ff;
+  }
+  &-visible {
+    cursor: pointer;
+    width: 10px;
+  }
+  .layer-name {
+    width: 100px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+  &-active {
+    background: #e6f4ff;
+    color: #1677ff;
+  }
+  .layer-action {
+    width: 30px;
+  }
+}
+</style>

+ 78 - 0
src/views/designer/component/LayerManagement.vue

@@ -0,0 +1,78 @@
+<template>
+  <div class="layer">
+    <div class="layer-header">
+      <span>组件图层</span>
+      <Button type="text" shape="circle" size="small">
+        <CloseOutlined />
+      </Button>
+    </div>
+
+    <InputSearch allowClear size="small" placeholder="请输入图层名称" />
+
+    <div class="layer-list">
+      <VueDraggable
+        v-if="layerList.length"
+        :list="layerList"
+        ghost-class="item-ghost"
+        chosen-class="item-chosen"
+        animation="300"
+        itemKey="id"
+        @end="dragEnd"
+      >
+        <template #item="{ element }">
+          <LayerItem :data="element" />
+        </template>
+      </VueDraggable>
+
+      <Empty v-else description="暂无图层" />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from "vue";
+import { Button, InputSearch, Empty } from "ant-design-vue";
+import { CloseOutlined } from "@ant-design/icons-vue";
+import VueDraggable from "vuedraggable";
+import LayerItem from "./LayerItem.vue";
+
+const layerList = ref(
+  Array.from({ length: 10 }, (_, index) => ({
+    name: `图层${index + 1}`,
+    id: index,
+  }))
+);
+
+const dragEnd = (event: DragEvent) => {
+  console.log("dragEnd", event, layerList.value);
+};
+</script>
+
+<style lang="less" scoped>
+.layer {
+  position: absolute;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  padding: 8px;
+  &-header {
+    height: 24px;
+    display: flex;
+    justify-content: space-between;
+    font-size: 12px;
+    line-height: 24px;
+    color: #666;
+    margin-bottom: 8px;
+  }
+
+  &-list {
+    flex: 1;
+    overflow-y: auto;
+    margin-top: 8px;
+
+    .item-ghost {
+      border-bottom: dashed 1px #ccc;
+    }
+  }
+}
+</style>

+ 159 - 0
src/views/designer/component/MenuBar.vue

@@ -0,0 +1,159 @@
+<template>
+  <div>
+    <Tooltip>
+      <template #title>
+        <div>撤销</div>
+        <div>ctrl+z</div>
+      </template>
+      <Button type="text" size="small">
+        <UndoOutlined />
+      </Button>
+    </Tooltip>
+
+    <Tooltip>
+      <template #title>
+        <div>还原</div>
+        <div>ctrl+shift+z</div>
+      </template>
+      <Button type="text" size="small">
+        <RedoOutlined />
+      </Button>
+    </Tooltip>
+
+    <Divider type="vertical" />
+
+    <Tooltip>
+      <template #title>
+        <div>组合</div>
+        <div>ctrl+g</div>
+      </template>
+      <Button type="text" size="small">
+        <BlockOutlined />
+      </Button>
+    </Tooltip>
+
+    <Tooltip>
+      <template #title>
+        <div>取消组合</div>
+        <div>ctrl+shift+g</div>
+      </template>
+      <Button type="text" size="small">
+        <SplitCellsOutlined />
+      </Button>
+    </Tooltip>
+
+    <Tooltip>
+      <template #title>
+        <div>删除</div>
+        <div>del</div>
+      </template>
+      <Button type="text" size="small">
+        <DeleteOutlined />
+      </Button>
+    </Tooltip>
+
+    <Divider type="vertical" />
+
+    <Dropdown trigger="click">
+      <template #overlay>
+        <Menu>
+          <MenuItem :key="AlignEnum.Left">
+            <VerticalRightOutlined />
+            左对齐
+          </MenuItem>
+          <MenuItem :key="AlignEnum.Right">
+            <VerticalLeftOutlined />
+            右对齐
+          </MenuItem>
+          <MenuItem :key="AlignEnum.HorizontalCenter">
+            <VerticalAlignMiddleOutlined />
+            水平居中
+          </MenuItem>
+          <MenuDivider />
+          <MenuItem :key="AlignEnum.VerticalCenter">
+            <VerticalAlignTopOutlined />
+            顶部对齐
+          </MenuItem>
+          <MenuItem key="5">
+            <VerticalAlignBottomOutlined />
+            底部对齐
+          </MenuItem>
+          <MenuItem key="6">
+            <LineOutlined />
+            垂直居中
+          </MenuItem>
+        </Menu>
+      </template>
+      <Tooltip placement="left">
+        <template #title>
+          <div>对齐</div>
+        </template>
+        <Button type="text" size="small">
+          <VerticalAlignMiddleOutlined />
+          <CaretDownOutlined
+            style="font-size: 10px; vertical-align: baseline"
+          />
+        </Button>
+      </Tooltip>
+    </Dropdown>
+
+    <Dropdown trigger="click">
+      <template #overlay>
+        <Menu>
+          <MenuItem :key="AlignEnum.Left">
+            <ArrowUpOutlined />
+            上移一层
+          </MenuItem>
+          <MenuItem :key="AlignEnum.Right">
+            <ArrowDownOutlined />
+            下移一层
+          </MenuItem>
+        </Menu>
+      </template>
+      <Tooltip placement="right">
+        <template #title>
+          <div>层级</div>
+        </template>
+        <Button type="text" size="small">
+          <ColumnHeightOutlined />
+          <CaretDownOutlined style="font-size: 10px; vertical-align: baseline" />
+        </Button>
+      </Tooltip>
+    </Dropdown>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from "vue";
+import {
+  Button,
+  Divider,
+  Tooltip,
+  Dropdown,
+  Menu,
+  MenuItem,
+  MenuDivider,
+} from "ant-design-vue";
+import {
+  UndoOutlined,
+  RedoOutlined,
+  DeleteOutlined,
+  ColumnHeightOutlined,
+  VerticalAlignTopOutlined,
+  VerticalAlignMiddleOutlined,
+  BlockOutlined,
+  SplitCellsOutlined,
+  VerticalAlignBottomOutlined,
+  CaretDownOutlined,
+  VerticalRightOutlined,
+  VerticalLeftOutlined,
+  LineOutlined,
+  ArrowDownOutlined,
+  ArrowUpOutlined,
+} from "@ant-design/icons-vue";
+
+import { AlignEnum } from "@/enum/alignEnum";
+
+</script>
+
+<style scoped></style>

+ 289 - 0
src/views/designer/component/Scaleplate.vue

@@ -0,0 +1,289 @@
+<template>
+  <div class="scaleplate">
+    <!-- 显示/隐藏参考线 -->
+    <div class="refer-line-img" @click="showReferLine = !showReferLine">
+      <EyeFilled v-if="showReferLine" />
+      <EyeInvisibleFilled v-else />
+    </div>
+    <!-- 标尺 -->
+    <div
+      class="scaleplate-horizontal"
+      ref="horizontalRef"
+      @mousemove="handleMouseMoveHScaleplate($event, 'horizontal')"
+      @mouseenter="virtualReferLine.type = 'horizontal'"
+      @mouseleave="virtualReferLine.type = null"
+      @click="handleAddReferLine"
+    >
+      <canvas
+        id="scaleplateHorizontal"
+        :style="{ width: windowSize.width + 'px', height: '20px' }"
+      />
+    </div>
+    <div
+      class="scaleplate-vertical"
+      ref="verticalRef"
+      @mousemove="handleMouseMoveHScaleplate($event, 'vertical')"
+      @mouseenter="virtualReferLine.type = 'vertical'"
+      @mouseleave="virtualReferLine.type = null"
+      @click="handleAddReferLine"
+    >
+      <canvas
+        id="scaleplateVertical"
+        :style="{ width: '20px', height: windowSize.height + 'px' }"
+      />
+    </div>
+
+    <!-- 参考线 -->
+    <div
+      class="refer-line"
+      v-for="item in stageStore.getReferLines"
+      v-show="showReferLine"
+      :key="item.key"
+      :style="{left: item.x + 'px', top: item.y + 'px'}"
+      :class="item.type === 'horizontal' ? 'refer-line-h' : 'refer-line-v'"
+      @dblclick="projectStore.removeReferLine(item.key)"
+    >
+      <UseDraggable @move="(position, event) => handleDragReferLine(position, event, item.key)">
+        <span class="refer-line__txt">{{ item.value }}px</span>
+        <span class="refer-line__line"></span>
+      </UseDraggable>
+    </div>
+    <!-- 临时参考线 -->
+    <div
+      class="refer-line virtual-refer-line"
+      :class="
+        virtualReferLine.type === 'horizontal' ? 'refer-line-h' : 'refer-line-v'
+      "
+      :style="{left: virtualReferLine.x + 'px', top: virtualReferLine.y + 'px'}"
+      v-show="virtualReferLine.type"
+    >
+      <span class="refer-line__txt">{{ virtualReferLine.value }}px</span>
+      <span class="refer-line__line refer-line__dashed"></span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import {
+  onMounted,
+  ref,
+  onBeforeUnmount,
+  nextTick,
+  watch,
+} from "vue";
+import type { Ref } from "vue";
+import { EyeFilled, EyeInvisibleFilled } from "@ant-design/icons-vue";
+import { UseDraggable } from "@vueuse/components";
+import { drawScaleplate } from "@/utils";
+import { useStageStore } from "@/store/modules/stage";
+import { useProjectStore } from "@/store/modules/project";
+import type { ReferLine } from "#/project";
+
+const stageStore = useStageStore();
+const projectStore = useProjectStore();
+const horizontalRef = ref<HTMLElement | null>(null);
+const verticalRef = ref<HTMLElement | null>(null);
+const showReferLine: Ref<boolean> = ref(true);
+const windowSize: Ref<{ width: number; height: number }> = ref({
+  width: 0,
+  height: 0,
+});
+
+/* 绘制标尺刻度 */
+const handleDrawScaleplate = () => {
+  // 水平轴
+  const { scale, scrollX, scrollY, originX, originY } = stageStore;
+  drawScaleplate({
+    canvas: document.getElementById(
+      "scaleplateHorizontal"
+    ) as HTMLCanvasElement,
+    canvasStyleWidth: windowSize.value.width,
+    canvasStyleHeight: 20,
+    direcotion: "horizontal",
+    scale,
+    scrollX,
+    scrollY,
+    originX,
+    originY,
+  });
+  // 垂直轴
+  drawScaleplate({
+    canvas: document.getElementById("scaleplateVertical") as HTMLCanvasElement,
+    canvasStyleWidth: 20,
+    canvasStyleHeight: windowSize.value.height,
+    direcotion: "vertical",
+    scale,
+    scrollX,
+    scrollY,
+    originX,
+    originY,
+  });
+};
+
+/* =============================== 参考线 ================================= */
+const virtualReferLine: Ref<ReferLine> = ref({
+  key: 0,
+  x: 0,
+  y: 0,
+  value: 0,
+  type: null,
+});
+/* 临时参考线位置 */
+const handleMouseMoveHScaleplate = (e: MouseEvent, type: "horizontal" | "vertical") => {
+  const { offsetX, offsetY } = e;
+  const { scale, originX, originY, scrollX, scrollY } = stageStore;
+
+  virtualReferLine.value.x = type === "horizontal" ? offsetX + 20 : 0;
+  virtualReferLine.value.y = type === "vertical" ? offsetY + 20 : 0;
+  // 计算当前位置数值
+  if(type === "horizontal") {
+    const offset = scrollX - originX;
+    virtualReferLine.value.value = Math.round((offset + offsetX) / scale);
+  } else {
+    const offset = scrollY - originY;
+    virtualReferLine.value.value = Math.round((offset + offsetY) / scale);
+  }
+};
+/* 添加参考线 */
+const handleAddReferLine = () => {
+  if(!virtualReferLine.value.type) return;
+
+  projectStore.addReferLine({
+    ...virtualReferLine.value,
+    key: Date.now(),
+  });
+};
+/* 拖拽参考线 */
+const handleDragReferLine = ({x, y}: {x: number, y: number}, e: PointerEvent, key: number) => {
+  const referLine = projectStore.referLines.find((item) => item.key === key);
+  if(!referLine) return;
+
+  const { scale, originX, originY, scrollX, scrollY } = stageStore;
+  const {left, top} = horizontalRef.value!.getBoundingClientRect();
+
+  if(referLine.type === "horizontal") {
+    const lineX = x - left + 20;
+    const offsetX = scrollX - originX;
+
+    referLine.value = Math.round((lineX + offsetX - 20) / scale);
+    referLine.x = lineX;
+  } else {
+    const lineY = y - top;
+    const offsetY = scrollY - originY;
+
+    referLine.value = Math.round((lineY + offsetY - 20) / scale);
+    referLine.y = lineY;
+  }
+
+  projectStore.updateReferLine(referLine);
+};
+/* ===============================参考线结束================================= */
+
+
+/* 设置刻度宽高 */
+const setWindowSize = async () => {
+  windowSize.value = {
+    width: horizontalRef.value!.clientWidth,
+    height: verticalRef.value!.clientHeight,
+  };
+  await nextTick();
+  handleDrawScaleplate();
+};
+
+watch(
+  () => [stageStore.scrollX, stageStore.scrollY, stageStore.scale],
+  () => {
+    handleDrawScaleplate();
+  },
+  { immediate: false }
+);
+
+onMounted(() => {
+  setWindowSize();
+  addEventListener("resize", setWindowSize);
+});
+onBeforeUnmount(() => {
+  removeEventListener("resize", setWindowSize);
+});
+</script>
+
+<style lang="less" scoped>
+.refer-line-img {
+  width: 20px;
+  height: 20px;
+  background: #fff;
+  text-align: center;
+  font-size: 12px;
+  line-height: 20px;
+  border-bottom: solid 1px #eee;
+  border-right: solid 1px #eee;
+  cursor: pointer;
+}
+.scaleplate-horizontal {
+  position: absolute;
+  left: 20px;
+  top: 0;
+  width: calc(100% - 20px);
+  height: 20px;
+  background: #fff;
+  border-bottom: solid 1px #eee;
+}
+.scaleplate-vertical {
+  position: absolute;
+  top: 20px;
+  left: 0;
+  height: calc(100% - 20px);
+  width: 20px;
+  background: #fff;
+  border-right: solid 1px #eee;
+}
+
+.refer-line {
+  position: absolute;
+  font-size: 12px;
+  color: red;
+  &-h {
+    width: 5px;
+    height: 100%;
+    .refer-line__line {
+      border-left: solid 1px red;
+      cursor: e-resize;
+    }
+    .refer-line__dashed {
+      border-left: dashed 1px red;
+    }
+  }
+  &-v {
+    width: 100%;
+    height: 5px;
+    .refer-line__line {
+      border-top: solid 1px red;
+      cursor: n-resize;
+    }
+    .refer-line__dashed {
+      border-top: dashed 1px red;
+    }
+    .refer-line__txt {
+      transform: rotate(-90deg);
+      transform-origin: -4px 2px;
+    }
+  }
+  &__line {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+  }
+  &__txt {
+    font-size: 12px;
+    position: absolute;
+    top: 0px;
+    left: 4px;
+    pointer-events: none;
+  }
+}
+.virtual-refer-line {
+  pointer-events: none;
+}
+</style>

+ 218 - 0
src/views/designer/component/Stage.vue

@@ -0,0 +1,218 @@
+<template>
+  <div class="stage-wrapper" ref="stageWrapperRef">
+    <div
+      class="stage"
+      ref="stageRef"
+      :style="getStyles.stageStyle"
+    >
+      <div ref="tipRef" class="tip-txt" :style="getStyles.tipStyle">请在画布内放置组件,超出的内容无法显示</div>
+      <div
+        ref="canvasRef"
+        id="canvasContainer"
+        ondragover="return false"
+        :style="getStyles.canvasStyle"
+        @drop="handleDrop"
+      >
+        <ComponentWrapper 
+          v-for="item in projectStore.elements" 
+          :component-data="item"
+          :key="item.key"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onBeforeUnmount, computed, nextTick, watch } from "vue";
+import type { Ref } from "vue";
+import { useRouter } from "vue-router";
+import { message } from "ant-design-vue";
+import { useStageStore } from "@/store/modules/stage";
+import { useProjectStore } from "@/store/modules/project";
+import { useScroll } from "@vueuse/core";
+import ComponentWrapper from "./ComponentWrapper.vue";
+
+const stageWrapperRef: Ref<HTMLElement | null> = ref(null);
+const stageRef: Ref<HTMLElement | null> = ref(null);
+const canvasRef: Ref<HTMLElement | null> = ref(null);
+const router = useRouter();
+const stageStore = useStageStore();
+const projectStore = useProjectStore();
+
+const STAGE_SCALE = 3;
+const getStyles = computed(() => {
+  const { width = 1280, height = 720 } = projectStore.projectInfo || {};
+  // 视窗宽高
+  const clientWidth = stageWrapperRef.value!?.clientWidth || 0;
+  const clientHeight = stageWrapperRef.value?.clientHeight || 0;
+  // 可滚动距离 至少2倍窗口大小
+  const scrollWidth = width * STAGE_SCALE > clientWidth *2 ? width * STAGE_SCALE : clientWidth *2;
+  const scrollHeight = height * STAGE_SCALE > clientHeight *2 ? height * STAGE_SCALE : clientHeight *2;
+
+  // 计算居中偏移量
+  const canvasOffsetX = -(width - (width * stageStore.scale)) / 2 + (clientWidth - width * stageStore.scale) / 2;
+  const canvasOffsetY = -(height - (height * stageStore.scale)) / 2 + (clientHeight - height * stageStore.scale) / 2;
+  // 距离左边 = (可滚动距离 - 视窗宽度) / 2 + 缩放实际偏移量
+  const canvasLeft = (scrollWidth - clientWidth) / 2 + canvasOffsetX;
+  // 距离顶边 = (可滚动距离 - 视窗高度) / 2 + 缩放实际偏移量
+  const canvasTop = (scrollHeight - clientHeight) / 2 + canvasOffsetY;
+
+  const tipOffsetX = (clientWidth - (width * stageStore.scale)) / 2;
+  const tipOffsetY = (clientHeight - (height * stageStore.scale)) / 2 - 20;
+  const tipLeft = (scrollWidth - clientWidth) / 2 + tipOffsetX;
+  const tipTop = (scrollHeight - clientHeight) / 2 + tipOffsetY;
+
+  stageStore.setSize(width, height);
+  stageStore.setOriginPoint(tipLeft, tipTop + 20);
+  stageStore.setViewportSize(clientWidth, clientHeight);
+  stageStore.setStageWaraaperSize(scrollWidth, scrollHeight);
+
+  const { background } = projectStore.currentPage || {};
+  const canvasBackground: Record<string, string | undefined> = {};
+  if(background?.type === 'color') {
+    canvasBackground['background-color'] = background.color;
+  } else if(background?.type === 'image') {
+    canvasBackground['background-image'] = `url(${background.image})`;
+    // todo 背景填充方式
+  }
+
+  return {
+    // 舞台样式
+    stageStyle: {
+      width: `${width * STAGE_SCALE}px`,
+      height: `${height * STAGE_SCALE}px`,
+    },
+    // 画布样式
+    canvasStyle: {
+      width: `${width}px`,
+      height: `${height}px`,
+      'transform-origin': '50% 50%',
+      transform: `scale(${stageStore.scale})`,
+      left: `${canvasLeft}px`,
+      top: `${canvasTop}px`,
+      ...canvasBackground
+    },
+    // 提示样式
+    tipStyle: {
+      left: `${tipLeft}px`,
+      top: `${tipTop}px`
+    }
+  }
+});
+
+useScroll(stageWrapperRef, {
+  throttle: 10,
+  onScroll: () => {
+    const scrollTop = stageWrapperRef.value!.scrollTop;
+    const scrollLeft = stageWrapperRef.value!.scrollLeft;
+    stageStore.setScroll(scrollLeft, scrollTop);
+  }
+});
+
+/* 设置缩放倍数 */
+const initScale = () => {
+  // 4为滚动条宽度 40为左右上下间隔20px
+  const windowWidth = stageWrapperRef.value!.clientWidth - 4 - 40;
+  const windowHeight = stageWrapperRef.value!.clientHeight - 4 - 40;
+
+  const { width = 1280, height = 720 } = projectStore.projectInfo || {};
+  let scale;
+  let maxScale;
+  if (windowHeight > windowWidth) {
+    scale = (windowWidth / width).toFixed(2) as unknown as number;
+    maxScale = (windowHeight / height).toFixed(2) as unknown as number;
+  } else {
+    scale = (windowHeight / height).toFixed(2) as unknown as number;
+    maxScale = (windowWidth / width).toFixed(2) as unknown as number;
+  }
+
+  stageStore.setScale(scale > maxScale ? maxScale : scale);
+};
+
+/* 设置舞台位置-默认居中 */
+const initStagePosition = async () => {
+  await nextTick();
+  const scrollWidth = stageWrapperRef.value!.scrollWidth;
+  const scrollHeight = stageWrapperRef.value!.scrollHeight;
+  const clientWidth = stageWrapperRef.value!.clientWidth;
+  const clientHeight = stageWrapperRef.value!.clientHeight;
+
+  const centerX = (scrollWidth - clientWidth) / 2;
+  const centerY = (scrollHeight - clientHeight) / 2;
+  stageStore.setCenterPoint(centerX, centerY);
+  stageWrapperRef.value!.scrollTo(centerX, centerY);
+};
+// 拖拽组件结束 添加组件到指定位置
+const handleDrop = (e: DragEvent) => {
+  e.preventDefault();
+  const { offsetX, offsetY } = e;
+
+  if(projectStore.addCompData) {
+    const compData = {
+      key: Date.now(),
+      name: projectStore.addCompData.name,
+      componentType: projectStore.addCompData.componentType,
+      position: {
+        x: offsetX,
+        y: offsetY,
+      },
+    };
+    projectStore.addElement(compData); 
+  }
+};
+
+/* 适应大小设置 */
+watch(
+  () => stageStore.scale,
+  (val) => {
+    if(!val) {
+      initScale();
+      initStagePosition();
+    }
+  }
+);
+
+onMounted(() => {
+  /* 获取项目信息 */
+  const obj = projectStore.getCurrentProjectInfo();
+  if (!obj) {
+    message.error("项目不存在");
+    setTimeout(() => {
+      router.push("/");
+    }, 500);
+  }
+
+  initStagePosition();
+  initScale();
+  window.addEventListener("resize", initScale);
+  window.addEventListener("resize", initStagePosition);
+});
+onBeforeUnmount(() => {
+  window.removeEventListener("resize", initScale);
+  window.removeEventListener("resize", initStagePosition);
+});
+</script>
+
+<style lang="less" scoped>
+.stage-wrapper {
+  height: calc(100% - 20px);
+  width: calc(100% - 20px);
+  position: absolute;
+  top: 20px;
+  left: 20px;
+  overflow: auto;
+}
+.stage {
+  position: relative;
+}
+#canvasContainer {
+  position: absolute;
+}
+.tip-txt {
+  position: absolute;
+  font-size: 12px;
+  line-height: 20px;
+  color: #999;
+}
+</style>

+ 96 - 0
src/views/designer/component/Workspace.vue

@@ -0,0 +1,96 @@
+<template>
+  <Flex class="workspace" vertical>
+    <div class="workspace-top">
+      <Stage />
+      <Scaleplate />
+    </div>
+    <Flex class="workspace-bottom" justify="space-between" align="center">
+      <div class="bottom-left">
+        <span style="margin-right: 12px"
+          >画布尺寸:{{ projectStore.width }}*{{ projectStore.height }}px</span
+        >
+        <span>画布自适应:<Select size="small" style="width: 120px" /></span>
+      </div>
+      <div class="bottom-right">
+        <Button 
+          size="small"
+          type="text"
+          :disabled="stageStore.scale <= 0.1"
+          @click="handleSizeChange(Number(stageStore.scale) - 0.1)"
+        ><MinusCircleOutlined /></Button>
+        <AutoComplete
+          size="small"
+          style="width: 120px"
+          :options="sizeOptions"
+          :value="(stageStore.scale * 100).toFixed(0) + '%'"
+          @change="handleSizeChange"
+        />
+        <Button
+          size="small"
+          type="text"
+          :disabled="stageStore.scale >= 4"
+          @click="handleSizeChange(Number(stageStore.scale) + 0.1)"
+        ><PlusCircleOutlined /></Button>
+      </div>
+    </Flex>
+  </Flex>
+</template>
+
+<script setup lang="ts">
+import { Button, Select, AutoComplete, Flex } from "ant-design-vue";
+import { MinusCircleOutlined, PlusCircleOutlined } from "@ant-design/icons-vue";
+import Scaleplate from "./Scaleplate.vue";
+import Stage from "./Stage.vue";
+import { useProjectStore } from "@/store/modules/project";
+import { useStageStore } from "@/store/modules/stage";
+
+const projectStore = useProjectStore();
+const stageStore = useStageStore();
+
+const sizeOptions = [
+  { value: 0.1, label: "10%" },
+  { value: 0.25, label: "25%" },
+  { value: 0.5, label: "50%" },
+  { value: 0.75, label: "75%" },
+  { value: 1, label: "100%" },
+  { value: 1.25, label: "125%" },
+  { value: 1.5, label: "150%" },
+  { value: 2, label: "200%" },
+  { value: 3, label: "300%" },
+  { value: 4, label: "400%" },
+  { value: 0, label: "适应大小" }
+];
+
+const handleSizeChange = (val: string | number) => {
+  if(Number.isFinite(val)) {
+    stageStore.setScale(val as number);
+  } 
+  if(typeof val === "string") {
+    const n = +((val + '').replace("%", ""));
+    if(Number.isNaN(n) && n >= 10 && n <= 400) {
+      stageStore.setScale((n / 100).toFixed(2) as unknown as number);
+    } else {
+      stageStore.setScale(0.1);
+    }
+  }
+};
+</script>
+
+<style lang="less" scoped>
+.workspace {
+  height: 100%;
+  user-select: none;
+  &-top {
+    flex: 1;
+    position: relative;
+  }
+  &-bottom {
+    padding: 0 20px;
+    height: 40px;
+    background: #fff;
+    border-top: solid 1px #eee;
+    font-size: 12px;
+    color: #666;
+  }
+}
+</style>

+ 108 - 0
src/views/designer/index.vue

@@ -0,0 +1,108 @@
+<template>
+  <Layout style="height: 100vh;">
+    <LayoutHeader style="background: #fff;">
+      <div class="header-left">
+        <h1>{{ projectStore.projectInfo.name || '大屏标题'}}</h1>
+      </div>
+      <div class="header-middle">
+        <MenuBar />
+      </div>
+      <div class="header-right">
+        <Button size="small" style="margin-right: 8px;"><DesktopOutlined/>预览</Button>
+        <Button size="small" type="primary"><SaveOutlined/>保存</Button>
+      </div>
+    </LayoutHeader>
+
+    <LayoutContent>
+      <div class="layer-wrapper">
+        <!-- 图层管理 -->
+        <LayerManagement />
+      </div>
+      <div class="component-wrapper">
+        <!-- 组件库 -->
+        <ComponentLibary />
+      </div>
+      <div class="workspace-wrapper">
+        <!-- 舞台 -->
+        <Workspace />
+      </div>
+      <div class="config-wrapper">
+        <!-- 属性配置 -->
+        <Configurator />
+      </div>
+    </LayoutContent>
+  </Layout>
+</template>
+
+<script lang="ts" setup>
+import {
+  Layout,
+  LayoutHeader,
+  LayoutContent,
+  Button
+} from 'ant-design-vue';
+import { DesktopOutlined, SaveOutlined } from '@ant-design/icons-vue';
+import { useProjectStore } from '@/store/modules/project';
+
+import LayerManagement from './component/LayerManagement.vue';
+import ComponentLibary from './component/ComponentLibary.vue';
+import Workspace from './component/Workspace.vue';
+import Configurator from './component/Configurator.vue';
+import MenuBar from './component/MenuBar.vue';
+
+const projectStore = useProjectStore();
+
+</script>
+
+<style lang="less" scoped>
+.ant-layout-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 0 20px;
+  height: 60px;
+  border-bottom: solid 1px #eee;
+  z-index: 2;
+  .ant-btn {
+    font-size: 12px
+  }
+  .header-left h1{
+    width: 300px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+  .header-right {
+    width: 300px;
+    text-align: right;
+  }
+}
+
+.ant-layout-content {
+  display: flex;
+}
+
+.layer-wrapper {
+  position: relative;
+  width: 200px;
+  background: #fff;
+  z-index: 2;
+}
+.component-wrapper {
+  position: relative;
+  background: @bg-color;
+  z-index: 1;
+  border-right: solid 1px #f0f0f0;
+}
+.workspace-wrapper {
+  position: relative;
+  flex: 1;
+  z-index: 0;
+}
+.config-wrapper {
+  position: relative;
+  width: 200px;
+  background: #fff;
+  border-left: solid 1px #eee;
+}
+</style>

+ 144 - 0
src/views/home/component/AddModal.vue

@@ -0,0 +1,144 @@
+<template>
+  <Modal v-bind="$attrs" title="新增大屏" width="800px" @ok="handleOk">
+    <Form ref="formRef" :model="formData" :label-col="{ span: 4 }">
+      <FormItem label="大屏名称" name="name">
+        <Input v-model:value="formData.name" placeholder="请输入大屏名称" />
+      </FormItem>
+      <FormItem label="画布大小" name="sizeType">
+        <RadioGroup button-style="solid" v-model:value="formData.sizeType">
+          <RadioButton value="0">常用尺寸</RadioButton>
+          <RadioButton value="1">自定义尺寸</RadioButton>
+        </RadioGroup>
+      </FormItem>
+      <FormItem v-if="formData.sizeType === '0'" :colon="false">
+        <template #label>
+          <span></span>
+        </template>
+        <div class="common-size-wrapper">
+          <div
+            class="common-size-item"
+            v-for="item in commonSize"
+            :key="item.value"
+            :class="{ 'common-size-item-active': activeSize === item.value }"
+            @click="handleClickCommonSize(item.value)"
+          >
+            <p>{{ item.label }}</p>
+            <p>{{ item.value }}</p>
+          </div>
+        </div>
+      </FormItem>
+      <FormItem v-if="formData.sizeType === '1'" label="宽" name="width">
+        <InputNumber
+          :min="1"
+          :max="50000"
+          :step="1"
+          addonAfter="px"
+          v-model:value="formData.width"
+        />
+      </FormItem>
+      <FormItem v-if="formData.sizeType === '1'" label="高" name="height">
+        <InputNumber
+          :min="1"
+          :max="50000"
+          :step="1"
+          addonAfter="px"
+          v-model:value="formData.height"
+        />
+      </FormItem>
+      <!-- <FormItem label="大屏描述" name="description">
+        <Input />
+      </FormItem> -->
+    </Form>
+  </Modal>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, watch, defineEmits } from "vue";
+import type { Ref } from "vue";
+import {
+  Modal,
+  Form,
+  FormItem,
+  Input,
+  RadioGroup,
+  RadioButton,
+  InputNumber,
+  message,
+  FormInstance,
+} from "ant-design-vue";
+import { useProjectStore } from "@/store/modules/project";
+
+const activeSize: Ref<string> = ref("1280*720px");
+const formRef: Ref<FormInstance | null> = ref(null);
+// 表单数据
+const formData = reactive({
+  name: "新建大屏",
+  sizeType: "0",
+  width: 1280,
+  height: 720,
+  description: "",
+});
+const projectStore = useProjectStore();
+
+const emit = defineEmits(["change"]);
+
+watch(
+  () => formData.sizeType,
+  () => {
+    if (formData.sizeType === "0") {
+      handleClickCommonSize(activeSize.value);
+    }
+  }
+);
+
+// 常用尺寸
+const commonSize = [
+  { label: "高清屏", value: "1280*720px" },
+  { label: "超清屏", value: "1920*1080px" },
+  { label: "2K屏", value: "2560*1440px" },
+  { label: "超宽屏", value: "4864*1294px" },
+];
+
+const handleClickCommonSize = (size: string) => {
+  activeSize.value = size;
+  const [width, height] = size.split("*");
+  formData.width = parseInt(width);
+  formData.height = parseInt(height.replace("px", "")); // 去掉px
+};
+
+const handleOk = async () => {
+  await formRef.value?.validate();
+
+  projectStore.setProjectInfo(formData);
+  
+  window.open(`/#/designer`);
+  emit("change");
+  message.success("新增成功");
+};
+</script>
+
+<style lang="less" scoped>
+.common-size {
+  &-wrapper {
+    display: flex;
+  }
+  &-item {
+    width: 120px;
+    height: 80px;
+    border: 1px solid #f0f0f0;
+    margin-right: 10px;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    cursor: pointer;
+    p {
+      margin: 0;
+    }
+  }
+  &-item-active {
+    border-color: #1890ff;
+    color: #1890ff;
+  }
+}
+</style>

+ 91 - 0
src/views/home/component/BigscreenManagement.vue

@@ -0,0 +1,91 @@
+<template>
+  <Layout>
+    <LayoutHeader style="background: #fff; padding: 0 16px">
+      <Menu mode="horizontal" :selectedKeys="menuSelectedKeys">
+        <MenuItem key="1" :icon="() => h(CodepenCircleOutlined)"
+          >我的大屏</MenuItem
+        >
+      </Menu>
+    </LayoutHeader>
+    <layoutContent style="padding: 16px">
+      <Flex wrap="wrap" gap="large">
+        <Card hoverable style="width: 258px" @click="handleOpenAddModal">
+          <template #cover>
+            <div class="add-btn">
+              <PlusOutlined style="font-size: 38px; margin-bottom: 12px;" />
+              <div class="add-btn-text">新建大屏</div>
+            </div>
+          </template>
+        </Card>
+        <Card hoverable style="width: 258px" v-for="i in 10" :key="i">
+          <template #cover>
+            <img class="cover-img" alt="大屏封面" src="https://picsum.photos/258/200" />
+          </template>
+          <template #actions>
+            <Tooltip title="删除"><DeleteOutlined /></Tooltip>
+            <Tooltip title="编辑"><EditOutlined /></Tooltip>
+            <Tooltip title="预览"><EyeOutlined /></Tooltip>
+            <Tooltip title="发布"><SendOutlined /></Tooltip>
+          </template>
+          <CardMeta title="大屏名称XX" />
+        </Card>
+      </Flex>
+
+      <!-- 新增大屏弹窗 -->
+      <AddModal v-model:open="addOpen"></AddModal>
+    </layoutContent>
+  </Layout>
+</template>
+
+<script setup lang="ts">
+import { h, ref } from "vue";
+import type { Ref } from "vue";
+import {
+  Layout,
+  LayoutHeader,
+  LayoutContent,
+  Menu,
+  MenuItem,
+  Card,
+  CardMeta,
+  Flex,
+  Tooltip
+} from "ant-design-vue";
+import {
+  CodepenCircleOutlined,
+  EditOutlined,
+  DeleteOutlined,
+  EyeOutlined,
+  SendOutlined,
+  PlusOutlined
+} from "@ant-design/icons-vue";
+import AddModal from "./AddModal.vue";
+
+const menuSelectedKeys: Ref<string[]> = ref(["1"]);
+const addOpen: Ref<boolean> = ref(false);
+
+const handleOpenAddModal = () => {
+  addOpen.value = true;
+};
+</script>
+
+<style lang="less" scoped>
+.cover-img {
+  width: 100%;
+  height: 148px;
+  object-fit: cover;
+}
+.add-btn {
+  width: 258px;
+  height: 260px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  &-text {
+    font-size: 16px;
+    color: #333;
+    font-weight: bold;
+  }
+}
+</style>

+ 13 - 0
src/views/home/component/DataSourceManagement.vue

@@ -0,0 +1,13 @@
+<template>
+  <div>
+    数据源管理
+  </div>
+</template>
+
+<script setup lang="ts">
+
+</script>
+
+<style scoped>
+
+</style>

+ 49 - 0
src/views/home/index.vue

@@ -0,0 +1,49 @@
+<template>
+  <Layout style="min-height: 100vh;">
+    <LayoutSider 
+      :style="{ overflow: 'auto', height: '100vh', background: '#fff'}"
+    >
+      <div class="logo">沙鲁大屏设计器</div>
+      <Menu theme="light" mode="inline" v-model:selected-keys="menuSelectedKeys">
+        <MenuItem key="1" :icon="() => h(FundProjectionScreenOutlined)">大屏管理</MenuItem>
+        <MenuItem key="2" :icon="() => h(DatabaseOutlined)">数据源管理</MenuItem>
+      </Menu>
+    </LayoutSider>
+    
+    <LayoutContent style="max-height: 100vh; overflow: auto;">
+      <BigscreenManagement v-if="menuSelectedKeys.includes('1')"/>
+      <DataSourceManagement v-if="menuSelectedKeys.includes('2')"/>
+    </LayoutContent>
+  </Layout>
+</template>
+
+<script lang="ts" setup>
+import { ref, h } from 'vue'
+import type { Ref } from 'vue'
+import { 
+  FundProjectionScreenOutlined,
+  DatabaseOutlined,
+} from '@ant-design/icons-vue'
+import { 
+  Layout,
+  LayoutContent,
+  LayoutSider,
+  Menu,
+  MenuItem,
+} from 'ant-design-vue'
+import BigscreenManagement from './component/BigscreenManagement.vue'
+import DataSourceManagement from './component/DataSourceManagement.vue'
+
+// 菜单选中项
+const menuSelectedKeys: Ref<string[]> = ref(['1'])
+</script>
+
+<style lang="less" scoped>
+.logo {
+  text-align: center;
+  color: #333;
+  font-size: 20px;
+  font-weight: bold;
+  line-height: 62px;
+}
+</style>

+ 15 - 0
src/views/system/404.vue

@@ -0,0 +1,15 @@
+<template>
+  <Result status="404" title="404" sub-title="抱歉,您访问的页面不存在!">
+    <template #extra>
+      <Button type="primary" @click="$router.push('/')">返回首页</Button>
+    </template>
+  </Result>
+</template>
+
+<script setup lang="ts">
+import { Result, Button } from 'ant-design-vue'
+</script>
+
+<style scoped>
+
+</style>

+ 7 - 0
src/vite-env.d.ts

@@ -0,0 +1,7 @@
+/// <reference types="vite/client" />
+
+declare module '*.vue' {
+  import { ComponentOptions } from 'vue'
+  const componentOptions: ComponentOptions
+  export default componentOptions
+}

+ 40 - 0
tsconfig.json

@@ -0,0 +1,40 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "lib": ["ES2020", "DOM", "DOM.Iterable"],
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "preserve",
+
+    /* Linting */
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "baseUrl": "./",
+    "declaration": false,
+    "types": ["vite/client"],
+    "paths": {
+      "@/*": ["src/*"],
+      "#/*": ["types/*"]
+    },
+  },
+  "include": [
+    "src/**/*.ts", 
+    "src/**/*.tsx", 
+    "src/**/*.vue",
+    "types/**/*.d.ts",
+    "types/**/*.ts",
+    "env.d.ts" 
+  ],
+  "exclude": ["node_modules", "dist", "**/*.js"],
+  "references": [{ "path": "./tsconfig.node.json" }]
+}

+ 11 - 0
tsconfig.node.json

@@ -0,0 +1,11 @@
+{
+  "compilerOptions": {
+    "composite": true,
+    "skipLibCheck": true,
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "allowSyntheticDefaultImports": true,
+    "strict": true
+  },
+  "include": ["vite.config.ts"]
+}

+ 1 - 0
types/module.d.ts

@@ -0,0 +1 @@
+declare type Recordable<T = any> = Record<string, T>;

+ 82 - 0
types/project.d.ts

@@ -0,0 +1,82 @@
+import type { ComponentType } from "@/components";
+interface BackgroundOptions {
+  // 背景类型
+  type: 'color' | 'image';
+  // 背景颜色
+  color?: string;
+  // 背景图片
+  image?: string;
+  // 背景图片填充方式
+  fillType?: string;
+}
+
+interface CustomElement {
+  // 元素唯一标识
+  key: number;
+  // 元素名称
+  name: string;
+  // 组件类型
+  componentType: ComponentType;
+  // 元素位置
+  position: {
+    x: number;
+    y: number;
+  };
+  // 元素尺寸
+  size: {
+    width: number;
+    height: number;
+  };
+  // 元素层级
+  zIndex: number;
+  // 元素属性 -- 包含样式,组件属性
+  props: Record<string, any>;
+  // 元素交互
+  events: Record<string, any>;
+  // 元素动画
+  animations: Record<string, any>;
+  // 元素内容 -- 数据源
+  content: Record<string, any>;
+}
+
+export interface ReferLine {
+  // 辅助线唯一标识
+  key: number;
+  // 辅助线类型
+  type: 'horizontal' | 'vertical' | null;
+  // 辅助线位置
+  value: number;
+  // x坐标
+  x?: number;
+  // y坐标
+  y?: number;
+}
+
+interface Page {
+  // 页面名称
+  name: string;
+  // 页面背景
+  background: BackgroundOptions;
+  // 页面元素
+  elements: CustomElement[];
+  // 辅助线
+  referLines: ReferLine[];
+}
+
+// 项目基本信息
+export interface ProjectInfo {
+  // 项目名称
+  name: string;
+  // 项目描述
+  description: string;
+  // 尺寸类型
+  sizeType: string;
+  // 屏幕宽度
+  width: number;
+  // 屏幕高度
+  height: number;
+  // 填充方式
+  fillType?: ScreenFillEnum;
+  // 页面内容
+  pages: Page[];
+}

+ 22 - 0
vite.config.ts

@@ -0,0 +1,22 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import path from 'path';
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  plugins: [vue()],
+  resolve: {
+    alias: {
+      "@": path.resolve(__dirname, "src"),
+      "~@": path.resolve(__dirname, "src"),
+      "#": path.resolve(__dirname, "types"),
+    }
+  },
+  css: {
+    preprocessorOptions: {
+      less: {
+        additionalData: `@import "~@/style/var.less";`
+      }
+    }
+  }
+})