Browse Source

refactor: 重构项目结构

liaojiaxing 1 year ago
commit
96a773d8a3
100 changed files with 9606 additions and 0 deletions
  1. 24 0
      .gitignore
  2. 1 0
      .npmrc
  3. 3 0
      .vscode/extensions.json
  4. 1 0
      apps/shalu-bigscreen-designer/.env
  5. 1 0
      apps/shalu-bigscreen-designer/.env.production
  6. 1 0
      apps/shalu-bigscreen-designer/.npmrc
  7. 49 0
      apps/shalu-bigscreen-designer/README.md
  8. 7 0
      apps/shalu-bigscreen-designer/env.d.ts
  9. 13 0
      apps/shalu-bigscreen-designer/index.html
  10. 47 0
      apps/shalu-bigscreen-designer/package.json
  11. 2784 0
      apps/shalu-bigscreen-designer/pnpm-lock.yaml
  12. 1 0
      apps/shalu-bigscreen-designer/public/vite.svg
  13. 28 0
      apps/shalu-bigscreen-designer/src/App.vue
  14. 8 0
      apps/shalu-bigscreen-designer/src/api/index.ts
  15. 22 0
      apps/shalu-bigscreen-designer/src/api/model.ts
  16. BIN
      apps/shalu-bigscreen-designer/src/assets/comp-icon/icon-1.png
  17. BIN
      apps/shalu-bigscreen-designer/src/assets/comp-icon/icon-2.png
  18. BIN
      apps/shalu-bigscreen-designer/src/assets/comp-icon/icon-3.png
  19. BIN
      apps/shalu-bigscreen-designer/src/assets/comp-icon/icon-4.png
  20. 9 0
      apps/shalu-bigscreen-designer/src/assets/comp-icon/index.ts
  21. 11 0
      apps/shalu-bigscreen-designer/src/components/CodeEditor/index.ts
  22. 58 0
      apps/shalu-bigscreen-designer/src/components/CodeEditor/src/Editor.vue
  23. 48 0
      apps/shalu-bigscreen-designer/src/components/CodeEditor/src/index.vue
  24. 86 0
      apps/shalu-bigscreen-designer/src/components/Container/index.vue
  25. 118 0
      apps/shalu-bigscreen-designer/src/config/compSetting.ts
  26. 42 0
      apps/shalu-bigscreen-designer/src/config/containerDefaultConfig.ts
  27. 9 0
      apps/shalu-bigscreen-designer/src/enum/alignEnum.ts
  28. 7 0
      apps/shalu-bigscreen-designer/src/enum/compTypeEnum.ts
  29. 7 0
      apps/shalu-bigscreen-designer/src/enum/index.ts
  30. 6 0
      apps/shalu-bigscreen-designer/src/enum/layerEnum.ts
  31. 13 0
      apps/shalu-bigscreen-designer/src/enum/screenFillEnum.ts
  32. 14 0
      apps/shalu-bigscreen-designer/src/main.ts
  33. 45 0
      apps/shalu-bigscreen-designer/src/mock/index.ts
  34. 27 0
      apps/shalu-bigscreen-designer/src/router/index.ts
  35. 10 0
      apps/shalu-bigscreen-designer/src/store/index.ts
  36. 343 0
      apps/shalu-bigscreen-designer/src/store/modules/action.ts
  37. 15 0
      apps/shalu-bigscreen-designer/src/store/modules/app.ts
  38. 257 0
      apps/shalu-bigscreen-designer/src/store/modules/project.ts
  39. 119 0
      apps/shalu-bigscreen-designer/src/store/modules/stage.ts
  40. 11 0
      apps/shalu-bigscreen-designer/src/style/index.css
  41. 2 0
      apps/shalu-bigscreen-designer/src/style/var.less
  42. 29 0
      apps/shalu-bigscreen-designer/src/utils/calljs.ts
  43. 29 0
      apps/shalu-bigscreen-designer/src/utils/common.ts
  44. 73 0
      apps/shalu-bigscreen-designer/src/utils/http/axios.ts
  45. 26 0
      apps/shalu-bigscreen-designer/src/utils/http/index.ts
  46. 183 0
      apps/shalu-bigscreen-designer/src/utils/index.ts
  47. 65 0
      apps/shalu-bigscreen-designer/src/utils/recover.ts
  48. 123 0
      apps/shalu-bigscreen-designer/src/utils/scale.ts
  49. 39 0
      apps/shalu-bigscreen-designer/src/utils/style.ts
  50. 308 0
      apps/shalu-bigscreen-designer/src/views/designer/component/ComponentLibary.vue
  51. 292 0
      apps/shalu-bigscreen-designer/src/views/designer/component/ComponentWrapper.vue
  52. 241 0
      apps/shalu-bigscreen-designer/src/views/designer/component/Configurator.vue
  53. 189 0
      apps/shalu-bigscreen-designer/src/views/designer/component/LayerItem.vue
  54. 143 0
      apps/shalu-bigscreen-designer/src/views/designer/component/LayerManagement.vue
  55. 250 0
      apps/shalu-bigscreen-designer/src/views/designer/component/MenuBar.vue
  56. 61 0
      apps/shalu-bigscreen-designer/src/views/designer/component/PageConfig.vue
  57. 309 0
      apps/shalu-bigscreen-designer/src/views/designer/component/Scaleplate.vue
  58. 230 0
      apps/shalu-bigscreen-designer/src/views/designer/component/Stage.vue
  59. 231 0
      apps/shalu-bigscreen-designer/src/views/designer/component/Workspace.vue
  60. 296 0
      apps/shalu-bigscreen-designer/src/views/designer/component/useComponentConfig.ts
  61. 249 0
      apps/shalu-bigscreen-designer/src/views/designer/index.vue
  62. 145 0
      apps/shalu-bigscreen-designer/src/views/home/component/AddModal.vue
  63. 91 0
      apps/shalu-bigscreen-designer/src/views/home/component/BigscreenManagement.vue
  64. 13 0
      apps/shalu-bigscreen-designer/src/views/home/component/DataSourceManagement.vue
  65. 49 0
      apps/shalu-bigscreen-designer/src/views/home/index.vue
  66. 15 0
      apps/shalu-bigscreen-designer/src/views/system/404.vue
  67. 31 0
      apps/shalu-bigscreen-designer/src/views/view/component/RenderComponent.vue
  68. 115 0
      apps/shalu-bigscreen-designer/src/views/view/index.vue
  69. 7 0
      apps/shalu-bigscreen-designer/src/vite-env.d.ts
  70. 40 0
      apps/shalu-bigscreen-designer/tsconfig.json
  71. 11 0
      apps/shalu-bigscreen-designer/tsconfig.node.json
  72. 25 0
      apps/shalu-bigscreen-designer/types/echart.d.ts
  73. 6 0
      apps/shalu-bigscreen-designer/types/index.d.ts
  74. 1 0
      apps/shalu-bigscreen-designer/types/module.d.ts
  75. 90 0
      apps/shalu-bigscreen-designer/types/project.d.ts
  76. 35 0
      apps/shalu-bigscreen-designer/vite.config.ts
  77. 22 0
      package.json
  78. 24 0
      packages/shalu-dashboard-ui/.gitignore
  79. 12 0
      packages/shalu-dashboard-ui/components/charts/Bar/BasicBar/index.ts
  80. 17 0
      packages/shalu-dashboard-ui/components/charts/Bar/BasicBar/src/BasicBar.vue
  81. 2 0
      packages/shalu-dashboard-ui/components/charts/Bar/BasicBar/src/BasicBar.vue.d.ts
  82. 182 0
      packages/shalu-dashboard-ui/components/charts/Bar/BasicBar/src/Config.vue
  83. 43 0
      packages/shalu-dashboard-ui/components/charts/Bar/BasicBar/src/props.d.ts
  84. 133 0
      packages/shalu-dashboard-ui/components/charts/Bar/BasicBar/src/props.ts
  85. 53 0
      packages/shalu-dashboard-ui/components/charts/Charts.vue
  86. 2 0
      packages/shalu-dashboard-ui/components/charts/Charts.vue.d.ts
  87. 161 0
      packages/shalu-dashboard-ui/components/charts/DataConfig.vue
  88. 11 0
      packages/shalu-dashboard-ui/components/charts/Line/BasicLine/index.ts
  89. 17 0
      packages/shalu-dashboard-ui/components/charts/Line/BasicLine/src/BasicLine.vue
  90. 183 0
      packages/shalu-dashboard-ui/components/charts/Line/BasicLine/src/Config.vue
  91. 132 0
      packages/shalu-dashboard-ui/components/charts/Line/BasicLine/src/props.ts
  92. 12 0
      packages/shalu-dashboard-ui/components/charts/Pie/BasicPie/index.ts
  93. 17 0
      packages/shalu-dashboard-ui/components/charts/Pie/BasicPie/src/BasicPie.vue
  94. 2 0
      packages/shalu-dashboard-ui/components/charts/Pie/BasicPie/src/BasicPie.vue.d.ts
  95. 124 0
      packages/shalu-dashboard-ui/components/charts/Pie/BasicPie/src/Config.vue
  96. 37 0
      packages/shalu-dashboard-ui/components/charts/Pie/BasicPie/src/props.d.ts
  97. 122 0
      packages/shalu-dashboard-ui/components/charts/Pie/BasicPie/src/props.ts
  98. 4 0
      packages/shalu-dashboard-ui/components/charts/chartEnum.d.ts
  99. 7 0
      packages/shalu-dashboard-ui/components/charts/chartEnum.ts
  100. 0 0
      packages/shalu-dashboard-ui/components/charts/config/chartFormItemsMap.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?

+ 1 - 0
.npmrc

@@ -0,0 +1 @@
+shamefully-hoist=true

+ 3 - 0
.vscode/extensions.json

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

+ 1 - 0
apps/shalu-bigscreen-designer/.env

@@ -0,0 +1 @@
+VITE_APP_BASE_URL=http://edesign.shalu.com

+ 1 - 0
apps/shalu-bigscreen-designer/.env.production

@@ -0,0 +1 @@
+VITE_APP_BASE_URL=''

+ 1 - 0
apps/shalu-bigscreen-designer/.npmrc

@@ -0,0 +1 @@
+shamefully-hoist=true

+ 49 - 0
apps/shalu-bigscreen-designer/README.md

@@ -0,0 +1,49 @@
+# 沙鲁大屏设计器
+技术方案:Vue 3 + TypeScript + Vite + ant-design-vue
+包管理器:pnpm
+
+安装:pnpm install
+运行:pnpm dev
+
+1、运行前需要先拷贝组件库项目: shalu-dashboard-ui
+2、需要添加本地link
+```
+cd shalu-dashboard-ui
+pnpm link ./
+pnpm link --global
+cd shalu-dashboard-designer
+pnpm link --global shalu-dashboard-ui
+```
+
+
+### 图表组件开发思路
+- 图表的option参数归一,提供统一的配置,便于后期做主题配置
+- 不同的图表提供可以配置的参数,后期通过归一化处理
+- 组件配置:组件内容、组件样式
+-- 组件样式为容器样式,包括:组件属性(如宽高、xy坐标、整体透明度等)、样式属性(如背景、边框、阴影等)
+-- 组件内容为每个组件单独提供的配置组件,通常为数据源,数据样式等
+
+
+### 图表文件夹结构目录如下:
+```
+components/Charts/
+├── BarChart/ // 柱状图
+│   └── BasicBar // 基础柱状图
+|         ├── index.ts // 暴露组件相关信息
+|         └── src
+|             ├── BasicBar.vue // 图表组件
+|             ├── Config.vue // 配置组件
+|             └── props.ts // 组件属性及组件初始数据
+...其他图表组件
+├── hooks
+│   └── useChartOptions.ts // 用于获取组件配置项options数据
+|—— Charts.vue // 封装Charts组件
+|—— DataConfig.vue // 数据配置组件
+```
+
+图表分为3层设计
+1、实现层:通过useEchars封装,实现echarts的初始化加载,渲染,设置配置项目。
+2、渲染层:提供图表统一的渲染,通过给实现层提供配置项,实现图表的渲染。
+3、应用层:提供具体的业务组件。
+图表封装采用echarts  后期可替换为其他图表库
+图表配置项采用Echarts为通用配置项,可以通过切换“实现层”,满足不同图表库的适配器进行转换,满足不同图表库的配置项差异

+ 7 - 0
apps/shalu-bigscreen-designer/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
apps/shalu-bigscreen-designer/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>

+ 47 - 0
apps/shalu-bigscreen-designer/package.json

@@ -0,0 +1,47 @@
+{
+  "name": "shalu-bigscreen-designer",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@ant-design/icons-vue": "^7.0.1",
+    "@codemirror/lang-javascript": "^6.2.2",
+    "@codemirror/lang-json": "^6.0.1",
+    "@codemirror/language": "^6.10.2",
+    "@codemirror/lint": "^6.8.1",
+    "@codemirror/theme-one-dark": "^6.1.2",
+    "@vueuse/components": "^10.11.0",
+    "@vueuse/core": "^10.10.1",
+    "ant-design-vue": "4.x",
+    "axios": "^1.7.2",
+    "dayjs": "^1.11.11",
+    "echarts": "^5.5.0",
+    "element-plus": "^2.7.6",
+    "js-beautify": "^1.14.3",
+    "less": "^4.2.0",
+    "less-loader": "^12.2.0",
+    "lodash": "^4.17.21",
+    "mockjs": "^1.1.0",
+    "pinia": "^2.1.7",
+    "unplugin-element-plus": "^0.8.0",
+    "vue": "^3.4.21",
+    "vue-codemirror": "^6.1.1",
+    "vue-hooks-plus": "^2.2.0",
+    "vue-router": "^4.3.3",
+    "vuedraggable": "^4.1.0"
+  },
+  "devDependencies": {
+    "@types/node": "^20.14.2",
+    "@vitejs/plugin-vue": "^5.0.4",
+    "@vitejs/plugin-vue-jsx": "^4.0.0",
+    "typescript": "^5.2.2",
+    "vite": "^5.2.0",
+    "vite-plugin-mock": "^3.0.2",
+    "vue-tsc": "^2.0.6"
+  }
+}

File diff suppressed because it is too large
+ 2784 - 0
apps/shalu-bigscreen-designer/pnpm-lock.yaml


File diff suppressed because it is too large
+ 1 - 0
apps/shalu-bigscreen-designer/public/vite.svg


+ 28 - 0
apps/shalu-bigscreen-designer/src/App.vue

@@ -0,0 +1,28 @@
+<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'
+import { Spin } from 'ant-design-vue'
+import { useAppStore } from './store/modules/app'
+
+import { ElConfigProvider } from 'element-plus'
+import zhcn from 'element-plus/es/locale/lang/zh-cn'
+
+const appStore = useAppStore()
+dayjs.locale('zh-cn')
+
+</script>
+
+<template>
+  <ConfigProvider :locale="zhCN">
+    <ElConfigProvider :locale="zhcn">
+      <Spin :spinning="appStore.loading" tip="加载中...">
+        <router-view />
+      </Spin>
+    </ElConfigProvider>
+  </ConfigProvider>
+</template>
+
+<style lang="less" scoped>
+</style>

+ 8 - 0
apps/shalu-bigscreen-designer/src/api/index.ts

@@ -0,0 +1,8 @@
+import { http } from "@/utils/http";
+import type { PageParams, PageResponse } from "./model";
+
+export const editPageDesignApi = (data: PageParams) =>
+  http.post("/api/form/EditPageDesign", data);
+
+export const getPageDesignApi = (data: { id: string}) => 
+  http.post<PageResponse>(`/api/form/GetPageDesign`, data);

+ 22 - 0
apps/shalu-bigscreen-designer/src/api/model.ts

@@ -0,0 +1,22 @@
+/* 保存页面数据 */
+export type PageParams = {
+  appPageId: string;
+  json: string;
+  type: number;
+  html: string;
+  js: string;
+}
+
+/* 获取页面数据 */
+export type PageResponse = {
+  appPageId: string;
+  creationTime: string;
+  fileName: string;
+  id: string;
+  isDeleted: boolean;
+  mobileHtml: string;
+  mobileJson: string;
+  pageName: string;
+  pcHtml: string;
+  pcJson: string;
+}

BIN
apps/shalu-bigscreen-designer/src/assets/comp-icon/icon-1.png


BIN
apps/shalu-bigscreen-designer/src/assets/comp-icon/icon-2.png


BIN
apps/shalu-bigscreen-designer/src/assets/comp-icon/icon-3.png


BIN
apps/shalu-bigscreen-designer/src/assets/comp-icon/icon-4.png


+ 9 - 0
apps/shalu-bigscreen-designer/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;

+ 11 - 0
apps/shalu-bigscreen-designer/src/components/CodeEditor/index.ts

@@ -0,0 +1,11 @@
+import CodeEditorModal from './src/index.vue';
+
+export type CodeEditorModalInstance = {
+  open: (code: string) => void;
+  close: () => void;
+  getCode: () => string;
+};
+
+export {
+  CodeEditorModal,
+}

+ 58 - 0
apps/shalu-bigscreen-designer/src/components/CodeEditor/src/Editor.vue

@@ -0,0 +1,58 @@
+<template>
+  <Codemirror
+    ref="editorRef"
+    placeholder="请输入"
+    style="height: 500px;"
+    :model-value="modelValue"
+    :tab-size="2"
+    :auto-focus="true"
+    :indent-with-tabs="true"
+    :extensions="[
+      oneDark,
+      javascript(),
+      json()
+    ]"
+    @change="handleCodeChange"
+  />
+</template>
+
+<script setup lang="ts">
+import { ref, defineProps, watch, defineEmits } from 'vue';
+import { Codemirror } from 'vue-codemirror';
+import { oneDark } from '@codemirror/theme-one-dark';
+import { javascript } from '@codemirror/lang-javascript';
+import { json } from '@codemirror/lang-json';
+import jsBeautify from 'js-beautify';
+
+const props = defineProps({
+  code: {
+    type: String,
+    default: ''
+  }
+});
+const emit = defineEmits(['update:code', 'change']);
+const editorRef = ref(null);
+const modelValue = ref(props.code);
+
+watch(
+  () => props.code,
+  (val) => {
+    modelValue.value = jsBeautify.js(val, { indent_size: 2 });
+  },
+  { immediate: true }
+)
+
+const handleCodeChange = (val: string) => {
+  try {
+    emit('update:code', val);
+    emit('change', val);
+  } catch (error) {
+    console.error(error);
+    return;
+  }
+}
+</script>
+
+<style scoped>
+
+</style>

+ 48 - 0
apps/shalu-bigscreen-designer/src/components/CodeEditor/src/index.vue

@@ -0,0 +1,48 @@
+<template>
+  <Modal
+    v-model:open="open"
+    :title="title"
+    :width="width"
+    @ok="handleOk"
+  >
+    <Editor v-model:code="code"/>
+  </Modal>
+</template>
+
+<script setup lang="ts">
+import { Modal  } from 'ant-design-vue';
+import { ref, defineProps, withDefaults, defineExpose } from 'vue';
+import Editor from './Editor.vue';
+
+interface IProp {
+  title?: string;
+  width?: number;
+}
+withDefaults(defineProps<IProp>(), {
+  title: '编辑',
+  width: 800,
+});
+const emit = defineEmits(['ok']);
+const open = ref(false);
+const code = ref('');
+
+const handleOk = () => {
+  // TODO: 检验code
+  emit('ok', code.value);
+  open.value = false;
+};
+
+defineExpose({
+  open: (codeStr: string) => {
+    open.value = true;
+    code.value = codeStr;
+  },
+  close: () => {
+    open.value = false;
+  },
+});
+</script>
+
+<style scoped>
+
+</style>

+ 86 - 0
apps/shalu-bigscreen-designer/src/components/Container/index.vue

@@ -0,0 +1,86 @@
+<template>
+  <div class="component-contaier" :style="getContainetStyle" v-bind="$attrs">
+    <div class="component-background" :style="getBackgroundStyle"></div>
+    <div class="component-content" :style="getContentStyle">
+      <slot></slot>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from "vue";
+import { transformStyle } from "@/utils/style";
+import { pick } from "lodash";
+
+export default defineComponent({
+  name: "Container",
+  props: {
+    style: Object as PropType<Record<string, any>>,
+    props: Object as PropType<Record<string, any>>,
+  },
+  setup(props) {
+    const getContainetStyle = computed(() => {
+      return {
+        width: props.props?.width + "px",
+        height: props.props?.height + "px",
+        transform: `rotateX(${props.props?.rotateX || 0}deg) rotateY(${
+          props.props?.rotateY || 0
+        }deg) rotateZ(${props.props?.rotateZ || 0}deg)`,
+        opacity: props.props?.opacity / 100,
+        ...transformStyle(
+          pick(props.style, [
+            "boxShadow",
+            "webkitBoxReflect",
+          ])
+        ),
+      };
+    });
+    const getContentStyle = computed(() => {
+      return {
+        paddingLeft: props.props?.paddingLeft + "px",
+        paddingRight: props.props?.paddingRight + "px",
+        paddingTop: props.props?.paddingTop + "px",
+        paddingBottom: props.props?.paddingBottom + "px",
+        overflow: "hidden",
+      };
+    });
+
+    const getBackgroundStyle = computed(() => {
+      const { style = {} } = props;
+      const otherStyle = transformStyle(
+        pick(style, ["background", "backdropFilter", "borderRadius",
+            "borderStyle",
+            "borderColor",
+            "borderWidth",])
+      );
+      return {
+        position: "absolute",
+        boxSizing: "border-box",
+        left: 0,
+        top: 0,
+        opacity: style.opacity / 100,
+        width: props.props?.width + "px",
+        height: props.props?.height + "px",
+        ...otherStyle,
+      };
+    });
+
+    return {
+      getContentStyle,
+      getBackgroundStyle,
+      getContainetStyle,
+    };
+  },
+});
+</script>
+
+<style lang="less" scoped>
+.conponent-contaier {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  left: 0;
+  top: 0;
+  overflow: hidden;
+}
+</style>

+ 118 - 0
apps/shalu-bigscreen-designer/src/config/compSetting.ts

@@ -0,0 +1,118 @@
+/* 
+ * 组件库配置文件
+ */
+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: 'BasicBar',
+              icon: compIcon['icon-1']
+            }
+          ]
+        },
+        {
+          name: '折线图类',
+          children: [
+            {
+              name: '折线图',
+              componetName: 'BasicLine',
+              icon: compIcon['icon-2']
+            }
+          ]
+        },
+        {
+          name: '饼状图类',
+          children: [
+            {
+              name: '饼状图',
+              componetName: 'BasicPie',
+              icon: compIcon['icon-4']
+            }
+          ]
+        }
+      ]
+    },
+    {
+      type: CompTypeEnum.TEXT,
+      name: '文本',
+      icon: () => h(FileTextOutlined),
+      isGroup: false,
+      children: [
+        {
+          name: '标题',
+          componetName: 'Title',
+          icon: compIcon['icon-3']
+        },
+      ]
+    },
+    {
+      type: CompTypeEnum.MEDIA,
+      name: '媒体',
+      icon: () => h(PlaySquareOutlined),
+      isGroup: false,
+    },
+    {
+      type: CompTypeEnum.INPUT_COMP,
+      name: '控件',
+      icon: () => h(AppstoreAddOutlined),
+      isGroup: false,
+    }
+  ]
+}

+ 42 - 0
apps/shalu-bigscreen-designer/src/config/containerDefaultConfig.ts

@@ -0,0 +1,42 @@
+// 组件容器基本设置
+export const containerDefaultConfig = {
+  /* ===================================== 通用容器样式 ============================================ */
+  style: {
+    background: {
+      type: "color", // none, color, image
+      color: "rgba(0, 0, 0, 0.1)",
+      image: "",
+      fillType: "",
+    },
+    opacity: 100,
+    borderStyle: "none",
+    borderColor: "#EEEEEEFF",
+    borderWidth: 1,
+    borderRadius: {
+      type: "all", // all, custom
+      value: 2, // 整体圆角值 || {topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0}
+      unit: "px", // 单位
+    },
+    // 阴影
+    boxShadow: '',
+    // 毛玻璃
+    backdropFilter: '',
+    // 倒影
+    webkitBoxReflect: '',
+  },
+  /* ===================================== 通用容器属性 ============================================ */
+  props: {
+    width: 0,
+    height: 0,
+    x: 0,
+    y: 0,
+    paddingLeft: 0,
+    paddingRight: 0,
+    paddingTop: 0,
+    paddingBottom: 0,
+    rotateX: 0,
+    rotateY: 0,
+    rotateZ: 0,
+    opacity: 100,
+  },
+};

+ 9 - 0
apps/shalu-bigscreen-designer/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
apps/shalu-bigscreen-designer/src/enum/compTypeEnum.ts

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

+ 7 - 0
apps/shalu-bigscreen-designer/src/enum/index.ts

@@ -0,0 +1,7 @@
+/* 数据来源 */
+export enum DataSourceType {
+  /* 静态数据 */
+  STATIC,
+  /* 接口数据 */
+  API,
+}

+ 6 - 0
apps/shalu-bigscreen-designer/src/enum/layerEnum.ts

@@ -0,0 +1,6 @@
+export enum LayerEnum {
+  UP = 'up',
+  DOWN = 'down',
+  TOP = 'top',
+  BOTTOM = 'bottom',
+}

+ 13 - 0
apps/shalu-bigscreen-designer/src/enum/screenFillEnum.ts

@@ -0,0 +1,13 @@
+// 大屏填充方式
+export enum ScreenFillEnum {
+  // 自动
+  AUTO,
+  // 高度铺满
+  FILL_HEIGHT,
+  // 宽度铺满
+  FILL_WIDTH,
+  // 双向铺满
+  FILL_BOTH,
+  // 无
+  NONE,
+}

+ 14 - 0
apps/shalu-bigscreen-designer/src/main.ts

@@ -0,0 +1,14 @@
+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";
+import "@/mock";
+
+const app = createApp(App);
+
+setupStore(app);
+app.use(router);
+
+app.mount("#app");

+ 45 - 0
apps/shalu-bigscreen-designer/src/mock/index.ts

@@ -0,0 +1,45 @@
+// test.ts
+
+import { MockMethod } from 'vite-plugin-mock'
+export default [
+  {
+    url: '/mock/api/get/resource/image-list',
+    method: 'get',
+    response: ({ }) => {
+      return {
+        code: 0,
+        data: {
+          name: 'vben',
+        },
+      }
+    },
+  },
+  {
+    url: '/mock/api/get/example/bar',
+    method: 'post',
+    timeout: 2000,
+    response: {
+      code: 0,
+      data: [
+        { name: '苹果', price: 20, count: 10 },
+        { name: '香蕉', price: 10, count: 20},
+        { name: '西瓜', price: 25, count: 30 },
+        { name: '葡萄', price: 12, count: 40},
+      ],
+    },
+  },
+  {
+    url: '/mock/api/get/example/line',
+    method: 'post',
+    timeout: 2000,
+    response: {
+      code: 0,
+      data: [
+        { name: '一月', apple: 1654, vivo: 1234, mi: 3421 },
+        { name: '二月', apple: 4322, vivo: 4321, mi: 2343 },
+        { name: '三月', apple: 4345, vivo: 3221, mi: 3221  },
+        { name: '四月', apple: 3222, vivo: 1222, mi: 1222 },
+      ],
+    },
+  },
+] as MockMethod[]

+ 27 - 0
apps/shalu-bigscreen-designer/src/router/index.ts

@@ -0,0 +1,27 @@
+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: '/view',
+    component: () => import('@/views/view/index.vue'),
+  },
+  {
+    path: '/:pathMatch(.*)*',
+    component: () => import('@/views/system/404.vue')
+  }
+];
+
+const router = createRouter({
+  history: createWebHashHistory(),
+  routes,
+});
+
+export default router;

+ 10 - 0
apps/shalu-bigscreen-designer/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 };

+ 343 - 0
apps/shalu-bigscreen-designer/src/store/modules/action.ts

@@ -0,0 +1,343 @@
+import { defineStore } from "pinia";
+import { AlignEnum } from "@/enum/alignEnum";
+import { LayerEnum } from "@/enum/layerEnum";
+import { useProjectStore } from "@/store/modules/project";
+import { cloneDeep } from "lodash";
+import { CustomElement } from "#/project";
+import { uuid } from "@/utils";
+// import { recoverRecord } from "@/utils/recover";
+
+// type RecordItem = {
+//   info: Record<string, any>;
+//   type: "add" | "update" | "delete" | "init";
+//   snapshot: ProjectInfo;
+// };
+type ActionState = {
+  // 操作记录--最大记录10条
+  records: string[];
+  // 当前操作索引
+  activeIndex: number;
+  appKey: number;
+  copyCache: any;
+};
+export const useAcionStore = defineStore({
+  id: "action",
+  state(): ActionState {
+    return {
+      records: [],
+      activeIndex: -1,
+      appKey: 0,
+      copyCache: null,
+    };
+  },
+  getters: {
+    projectStore: () => useProjectStore(),
+    undoDisabled: (state) => state.activeIndex <= 0,
+    redoDisabled: (state) => state.activeIndex === state.records.length - 1,
+  },
+  actions: {
+    initRecord() {
+      this.records = [JSON.stringify(this.projectStore.projectInfo)];
+      this.activeIndex = 0;
+    },
+    // addRecord({type, info }: RecordItem & { snapshot?: ProjectInfo}) {
+    addRecord() {
+      // 新增如果当前索引不是最后一条, 覆盖后面的记录
+      if (this.activeIndex < this.records.length - 1) {
+        this.records.splice(this.activeIndex + 1, this.records.length);
+      }
+
+      this.records.push(JSON.stringify(this.projectStore.projectInfo));
+
+      // 新增如果超过10条记录,删除最早的一条
+      if (this.records.length > 10) {
+        this.records.shift();
+        this.activeIndex--;
+      }
+
+      this.activeIndex = this.records.length - 1;
+    },
+    /* 撤销 */
+    actionUndo() {
+      if (this.activeIndex <= 0) return;
+      --this.activeIndex;
+      const projectInfo = JSON.parse(this.records[this.activeIndex]);
+      this.projectStore.updateProjectInfo(projectInfo);
+      this.appKey++;
+    },
+    /* 重做 */
+    actionRedo() {
+      ++this.activeIndex;
+      const projectInfo = JSON.parse(this.records[this.activeIndex]);
+      this.projectStore.updateProjectInfo(projectInfo);
+      this.appKey++;
+    },
+    actionClear() {},
+    /* 对齐 */
+    actionAlign(type: AlignEnum) {
+      const activeElements = this.projectStore.currentSelectedElements;
+      switch (type) {
+        case AlignEnum.Bottom: {
+          const maxY = Math.max(
+            ...activeElements.map(
+              (item) => item.container.props.y + item.container.props.height
+            )
+          );
+          activeElements.forEach((item) => {
+            this.projectStore.updateElement(
+              item.key,
+              "container.props.y",
+              maxY - item.container.props.height
+            );
+          });
+          break;
+        }
+        case AlignEnum.HorizontalCenter: {
+          const maxX = Math.max(
+            ...activeElements.map(
+              (item) => item.container.props.x + item.container.props.width
+            )
+          );
+          const minX = Math.min(
+            ...activeElements.map((item) => item.container.props.x)
+          );
+          const centerX = minX + (maxX - minX) / 2;
+          activeElements.forEach((item) => {
+            this.projectStore.updateElement(
+              item.key,
+              "container.props.x",
+              centerX - item.container.props.width / 2
+            );
+          });
+          break;
+        }
+        case AlignEnum.VerticalCenter: {
+          const maxY = Math.max(
+            ...activeElements.map(
+              (item) => item.container.props.y + item.container.props.height
+            )
+          );
+          const minY = Math.min(
+            ...activeElements.map((item) => item.container.props.y)
+          );
+          const centerY = minY + (maxY - minY) / 2;
+          activeElements.forEach((item) => {
+            this.projectStore.updateElement(
+              item.key,
+              "container.props.y",
+              centerY - item.container.props.height / 2
+            );
+          });
+          break;
+        }
+        case AlignEnum.Left: {
+          const minX = Math.min(
+            ...activeElements.map((item) => item.container.props.x)
+          );
+          activeElements.forEach((item) => {
+            this.projectStore.updateElement(
+              item.key,
+              "container.props.x",
+              minX
+            );
+          });
+          break;
+        }
+        case AlignEnum.Right: {
+          const maxX = Math.max(
+            ...activeElements.map(
+              (item) => item.container.props.x + item.container.props.width
+            )
+          );
+          activeElements.forEach((item) => {
+            this.projectStore.updateElement(
+              item.key,
+              "container.props.x",
+              maxX - item.container.props.width
+            );
+          });
+          break;
+        }
+        case AlignEnum.Top: {
+          const minY = Math.min(
+            ...activeElements.map((item) => item.container.props.y)
+          );
+          activeElements.forEach((item) => {
+            this.projectStore.updateElement(
+              item.key,
+              "container.props.y",
+              minY
+            );
+          });
+          break;
+        }
+        default:
+      }
+      this.addRecord();
+    },
+    /* 图层调整 */
+    actionLayer(type: LayerEnum) {
+      const activeElements = this.projectStore.currentSelectedElements;
+      const elements = cloneDeep(
+        this.projectStore.elements.sort((a, b) => a.zIndex - b.zIndex)
+      ) as CustomElement[];
+
+      switch (type) {
+        case LayerEnum.UP: {
+          activeElements.forEach((item) => {
+            const index = elements.findIndex(
+              (element) => element.key === item.key
+            );
+            if (item.zIndex === elements.length) return;
+            elements.splice(index, 1);
+            elements.splice(index + 1, 0, { ...item });
+          });
+          elements.forEach((item, index) => {
+            item.zIndex = index + 1;
+          });
+          elements.forEach((item) => {
+            this.projectStore.updateElement(item.key, "zIndex", item.zIndex);
+          });
+          break;
+        }
+        case LayerEnum.DOWN: {
+          activeElements.forEach((item) => {
+            const index = elements.findIndex(
+              (element) => element.key === item.key
+            );
+            if (item.zIndex === 1) return;
+            elements.splice(index, 1);
+            elements.splice(index - 1, 0, { ...item });
+          });
+          elements.forEach((item, index) => {
+            item.zIndex = index + 1;
+          });
+          elements.forEach((item) => {
+            this.projectStore.updateElement(item.key, "zIndex", item.zIndex);
+          });
+          break;
+        }
+        case LayerEnum.TOP: {
+          activeElements.forEach((item) => {
+            const index = elements.findIndex(
+              (element) => element.key === item.key
+            );
+            if (item.zIndex === elements.length) return;
+            elements.splice(index, 1);
+            elements.push({ ...item });
+          });
+          elements.forEach((item, index) => {
+            item.zIndex = index + 1;
+          });
+          elements.forEach((item) => {
+            this.projectStore.updateElement(item.key, "zIndex", item.zIndex);
+          });
+          break;
+        }
+        case LayerEnum.BOTTOM: {
+          activeElements.forEach((item) => {
+            const index = elements.findIndex(
+              (element) => element.key === item.key
+            );
+            if (item.zIndex === 1) return;
+            elements.splice(index, 1);
+            elements.unshift({ ...item });
+          });
+          elements.forEach((item, index) => {
+            item.zIndex = index + 1;
+          });
+          elements.forEach((item) => {
+            this.projectStore.updateElement(item.key, "zIndex", item.zIndex);
+          });
+          break;
+        }
+      }
+      this.addRecord();
+    },
+    /* 添加组合 */
+    actionGroup() {
+      const elements = this.projectStore.currentSelectedElements;
+      const key = uuid();
+      // 1、移除元素
+      elements.forEach((element) => {
+        this.projectStore.removeElement(element.key);
+      });
+      const minX = Math.min(...elements.map((item) => item.container.props.x));
+      const minY = Math.min(...elements.map((item) => item.container.props.y));
+      const maxX = Math.max(...elements.map((item) => item.container.props.x + item.container.props.width));
+      const maxY = Math.max(...elements.map((item) => item.container.props.y + item.container.props.height));
+      const maxZIndex = Math.max(...elements.map((item) => item.zIndex));
+      const groupIndex = this.projectStore.elements.filter((item) => item.componentType === "group").length + 1;
+      // 重新计算子元素位置
+      elements.forEach((item) => {
+        item.container.props.x -= minX;
+        item.container.props.y -= minY;
+        item.parentKey = key;
+      });
+      const group: CustomElement = {
+        key,
+        name: "组合" + groupIndex,
+        componentType: "group",
+        visible: true,
+        locked: false,
+        zIndex: maxZIndex,
+        container: {
+          style: {},
+          props: {
+            width: maxX - minX,
+            height: maxY - minY,
+            x: minX,
+            y: minY,
+          },
+        },
+        children: elements,
+        collapsed: false,
+        events: [],
+        animations: [],
+        props: {}
+      }
+      // 2、添加组合元素
+      this.projectStore.addElement(group);
+    },
+   /* 拆分组合元素 */
+    actionUngroup() {
+      const group = this.projectStore.currentSelectedElements[0];
+      // 1、取出子元素
+      const elements = group.children?.map((item) => {
+        // 2、计算子元素位置
+        item.container.props.x += group.container.props.x;
+        item.container.props.y += group.container.props.y;
+        delete item.parentKey;
+        return item;
+      });
+    
+      // 3、移除组
+      this.projectStore.removeElement(group.key);
+      // 4、添加子元素
+      elements?.forEach((item) => {
+        this.projectStore.addElement(item, undefined, true);
+      });
+    },
+    /* 复制 */
+    actionCopy() {
+      const elements = this.projectStore.currentSelectedElements;
+      this.copyCache = JSON.stringify(elements);
+    },
+    /* 粘贴 */
+    actionPaste() {
+      try {
+        const elements = JSON.parse(this.copyCache);
+        const offsetX = 10;
+        const offsetY = 10;
+        elements.forEach((element: CustomElement) => {
+          element.key = uuid();
+          element.container.props.x += offsetX;
+          element.container.props.y += offsetY;
+          this.projectStore.addElement(element);
+        });
+      } catch (error) {
+        console.log(error);
+      }
+    }
+  },
+});

+ 15 - 0
apps/shalu-bigscreen-designer/src/store/modules/app.ts

@@ -0,0 +1,15 @@
+import { defineStore } from "pinia";
+
+export const useAppStore = defineStore({
+  id: 'app',
+  state() {
+    return {
+      loading: false
+    }
+  },
+  actions: {
+    setPageLoading(loading: boolean) {
+      this.loading = loading
+    }
+  }
+})

+ 257 - 0
apps/shalu-bigscreen-designer/src/store/modules/project.ts

@@ -0,0 +1,257 @@
+import type { ProjectInfo, Page, ReferLine, CustomElement } from "#/project";
+import { defineStore } from "pinia";
+import { asyncComponentAll } from "@shalu/dashboard-ui";
+import { ScreenFillEnum } from "@/enum/screenFillEnum";
+import { update, defaultsDeep } from "lodash";
+import { getNormalizedContainer } from "@/utils/common";
+import { editPageDesignApi } from "@/api/index";
+import { message } from "ant-design-vue";
+
+type ProjectState = {
+  projectInfo: ProjectInfo;
+  activePageIndex: number;
+  addCompData: {
+    key: string;
+    name: string;
+    componentType: string;
+    container: {
+      style: Record<string, any>;
+      props: Record<string, any>;
+    };
+  } | null;
+  mode: "edit" | "player";
+  selectedElementKeys: string[];
+};
+const defaultPage: Page = {
+  key: "1",
+  name: "页面1",
+  background: {
+    type: "color",
+    color: "#0B074BFF",
+    image: "",
+    fillType: "",
+  },
+  elements: [],
+  referLines: [],
+};
+const CURRENT_PROJECT = "currentProject";
+
+export const useProjectStore = defineStore({
+  id: "project",
+  state: (): ProjectState => ({
+    // 项目信息
+    projectInfo: {
+      pageId: "",
+      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];
+    },
+    currentSelectedElements(state) {
+      const list: CustomElement[] = [];
+      state.projectInfo.pages[state.activePageIndex].elements.forEach((item) => {
+        if (state.selectedElementKeys.includes(item.key)) {
+          list.push(item);
+        }
+        // 为组时,遍历组下面成员
+        if (item.children) {
+          item.children.forEach((child) => {
+            if (state.selectedElementKeys.includes(child.key)) {
+              list.push(child);
+            }
+          });
+        }
+      });
+      return list;
+    },
+  },
+  actions: {
+    setProjectInfo(info: any) {
+      Object.assign(this.projectInfo, info);
+      localStorage.setItem(CURRENT_PROJECT, JSON.stringify(info));
+    },
+    getCurrentProjectInfo(): ProjectInfo | undefined {
+      let info = JSON.parse(localStorage.getItem(CURRENT_PROJECT) || "null");
+      if (!info) {
+        info = {
+          name: "默认项目",
+          description: "这是一个默认项目",
+          sizeType: "custom",
+          width: 1280,
+          height: 720,
+          fillType: ScreenFillEnum.AUTO,
+          pages: [{ ...defaultPage }],
+        };
+      }
+      this.setProjectInfo(info as unknown as ProjectInfo);
+      return info;
+    },
+    updateProjectInfo(info: any) {
+      Object.assign(this.projectInfo, info);
+    },
+    updateProjectInfoByPath(path: string, payload: any) {
+      update(this.projectInfo, path, () => payload);
+    },
+    addReferLine(line: ReferLine) {
+      this.projectInfo.pages[this.activePageIndex].referLines.push(line);
+    },
+    removeReferLine(key: string) {
+      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: any, position?: 'center', cancelSelect?: boolean = false) {
+      this.addCompData = null;
+      if (!element) return;
+
+      // 组合
+      if(element.componentType === 'group') {
+        this.projectInfo.pages[this.activePageIndex].elements.push(element);
+        this.selectedElementKeys = [element.key];
+        return;
+      }
+
+      const elements = this.projectInfo.pages[this.activePageIndex].elements;
+      // 获取每个自定义组件暴露出来的默认属性
+      const { defaultPropsValue } =
+        (await asyncComponentAll[element.componentType]?.()) || {};
+
+      const { defaultWidth = 400, defaultHeight = 260 } =
+        defaultPropsValue?.container?.props || {};
+      const { props: containerDefaultProps = {}, style: containerDefaultStyle = {} } =
+        defaultPropsValue?.container || {};
+
+      const index =
+        elements.filter((item) => item.componentType === element.componentType)
+          .length + 1;
+
+      const { x, y, width, height } = element.container.props;
+      const container = getNormalizedContainer({
+        containerDefaultStyle,
+        props: {
+          ...containerDefaultProps,
+          width: width ?? defaultWidth,
+          height: height ?? defaultHeight,
+          // 判断是否需要居中,是的话需要减去宽高的一半
+          x: position === 'center' ? x - defaultWidth / 2 : x,
+          y: position === 'center' ? y - defaultHeight / 2 : y,
+        },
+      });
+      // 添加组件
+      this.projectInfo.pages[this.activePageIndex].elements.push({
+        ...defaultsDeep(element, defaultPropsValue),
+        name: element.name + index,
+        zIndex: elements.length + 1,
+        visible: true,
+        locked: false,
+        container,
+      });
+
+      if(!cancelSelect) this.selectedElementKeys = [element.key];
+    },
+    // 更新组件
+    updateElement(key: string, path: string, payload: any) {
+      const pageIndex = this.activePageIndex;
+
+      const element = this.projectInfo.pages[pageIndex].elements.find((item) => item.key === key);
+      const elementIndex = this.projectInfo.pages[pageIndex].elements.findIndex((item) => item.key === key);
+
+      // 如果是锁定状态不能修改宽高 位置
+      if (
+        element &&
+        element.locked &&
+        [
+          "container.props.width",
+          "container.props.height",
+          "container.props.x",
+          "container.props.y",
+        ].includes(path)
+      )
+        return;
+
+      if (element) {
+        update(this.projectInfo.pages[pageIndex].elements[elementIndex], path, () => payload);
+      }
+    },
+    // 删除组件
+    removeElement(key: string) {
+      const index = this.projectInfo.pages[
+        this.activePageIndex
+      ].elements.findIndex((item) => item.key === key);
+      if (index !== -1) {
+        this.projectInfo.pages[this.activePageIndex].elements.splice(index, 1);
+      }
+    },
+    // 设置临时添加组件数据
+    setAddCompData(data: any) {
+      this.addCompData = data;
+    },
+    // 清除临时添加组件数据
+    clearAddCompData() {
+      this.addCompData = null;
+    },
+    setMode(mode: "edit" | "player") {
+      this.mode = mode;
+    },
+    // 设置选中的元素
+    setSelectedElementKeys(keys: string[]) {
+      this.selectedElementKeys = keys;
+    },
+    // 删除所有选中的元素
+    clearAllSelectedElement() {
+      this.selectedElementKeys = [];
+    },
+    // 设置当前页面背景
+    setCurrentPageBackground(background: any) {
+      this.projectInfo.pages[this.activePageIndex].background = background;
+    },
+    setFillType(fillType: ScreenFillEnum) {
+      this.projectInfo.fillType = fillType;
+    },
+    // 保存当前项目到服务器
+    async handleSaveProject() {
+      const params = {
+        appPageId: this.projectInfo.pageId,
+        json: JSON.stringify(this.projectInfo),
+        html: "",
+        js: "",
+        type: 0,
+      };
+
+      editPageDesignApi(params);
+      message.success("保存成功");
+    },
+  },
+});

+ 119 - 0
apps/shalu-bigscreen-designer/src/store/modules/stage.ts

@@ -0,0 +1,119 @@
+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;
+  // 显示图层面板
+  showLayer: boolean;
+}
+
+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,
+    showLayer: false
+  }),
+  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;
+    },
+    toggleShowLayer() {
+      this.showLayer = !this.showLayer;
+    }
+  }
+})

+ 11 - 0
apps/shalu-bigscreen-designer/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
apps/shalu-bigscreen-designer/src/style/var.less

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

+ 29 - 0
apps/shalu-bigscreen-designer/src/utils/calljs.ts

@@ -0,0 +1,29 @@
+/**
+ * 执行动态js
+ * @param code 代码
+ * @param param 参数
+ * @returns promise
+ */
+export const cllJsCode = (code: string, param: string): Promise<any> => {
+  
+  return new Promise((resove, reject) => {
+    // 生成一份new webwork
+    const blob = new Blob([`
+      self.onmessage = function(e) {
+        self.postMessage((${code}).call(null, e.data));
+      }
+    `], { type: 'application/javascript' });
+
+    const worker = new Worker(URL.createObjectURL(blob));
+    // 向webwork发送消息
+    worker.postMessage(JSON.parse(param));
+    worker.onmessage = (e) => {
+      worker.terminate();
+      resove(e.data);
+    }
+    worker.onerror = (e) => {
+      worker.terminate();
+      reject(e);
+    }
+  });
+}

+ 29 - 0
apps/shalu-bigscreen-designer/src/utils/common.ts

@@ -0,0 +1,29 @@
+import type { DataSource } from '#/echart';
+import { defaultsDeep } from "lodash";
+import { containerDefaultConfig } from "@/config/containerDefaultConfig";
+import { PropType } from "vue";
+import { DataSourceType } from "@/enum/index";
+
+/**
+ * 获取容器组件属性
+ *  @param {Record<string, any>} config - 传入属性
+ *  @returns {Record<string, any>} - 返回与默认配置合并后的属性
+ */
+export function getNormalizedContainer(config: Record<string, any>) {
+  return defaultsDeep(config, containerDefaultConfig);
+}
+
+// 图表组件数据来源prop
+export const dataSource = {
+  type: Object as PropType<DataSource>,
+  default: () => ({
+    sourceType: DataSourceType.STATIC,
+    data: [],
+    url: "",
+    method: "GET",
+    params: {},
+    headers: {},
+    refreshTime: 0,
+    dataProcess: () => [],
+  }),
+}

+ 73 - 0
apps/shalu-bigscreen-designer/src/utils/http/axios.ts

@@ -0,0 +1,73 @@
+import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
+
+interface ApiService {
+  get<T>(url: string, config?: AxiosRequestConfig): Promise<T>;
+  post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>;
+  put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>;
+  delete<T>(url: string, config?: AxiosRequestConfig): Promise<T>;
+}
+
+type RequestOptions = {
+  transformRequest?: AxiosRequestConfig['transformRequest'];
+  transformResponse?: <T>(response: T, config: AxiosRequestConfig) => T;
+}
+
+class HttpService implements ApiService {
+  private axiosInstance: AxiosInstance;
+  private options?: RequestOptions;
+
+  constructor(baseURL: string, options?: RequestOptions) {
+    this.axiosInstance = axios.create({
+      baseURL,
+      timeout: 5000, // Set your desired timeout value
+    });
+    this.options = options;
+  }
+
+  get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
+    return this.request({ url, method: 'GET', ...config })
+  }
+
+  post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
+    return this.request({ url, method: 'POST', data, ...config })
+  }
+
+  put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
+    return this.request({ url, method: 'PUT', data, ...config })
+  }
+
+  delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
+    return this.request({ url, method: 'DELETE', ...config })
+  }
+
+  request<T = any>(config: AxiosRequestConfig): Promise<T> {
+    config.headers = {
+      ...config.headers,
+      Authorization: localStorage.getItem('token') || '',
+    }
+    
+   const { transformResponse } = this.options || {};
+    
+    return new Promise((resolve, reject) => {
+      this.axiosInstance
+      .request(config)
+      .then((response: AxiosResponse<T>) => {
+        if (transformResponse) {
+          try {
+            const res = transformResponse(response.data, config);
+            resolve(res);
+          }
+          catch (e) {
+            reject(e);
+          }
+        }
+        resolve(response.data);
+      })
+      .catch((e: Error | AxiosError) => {
+        reject(e);
+      })
+    })
+  }
+}
+
+export default HttpService;

+ 26 - 0
apps/shalu-bigscreen-designer/src/utils/http/index.ts

@@ -0,0 +1,26 @@
+import { message } from 'ant-design-vue';
+import HttpService from './axios';
+import { AxiosRequestConfig } from 'axios';
+
+const baseURL = import.meta.env.VITE_APP_BASE_URL as string;
+
+type ResponseResult<T> = {
+  code: number;
+  isAuthorized: boolean;
+  isSuccess: boolean;
+  result: T;
+  error?: string;
+}
+const transformResponse = <T>(response: ResponseResult<T>, config: AxiosRequestConfig) => {
+  if (config.responseType === 'blob') {
+    return response;
+  }
+  if(response.code === 1) {
+    return response.result;
+  } else {
+    message.warning(response.error as string);
+    throw new Error(response.error)
+  }
+};
+
+export const http = new HttpService(baseURL, {transformResponse});

+ 183 - 0
apps/shalu-bigscreen-designer/src/utils/index.ts

@@ -0,0 +1,183 @@
+/**
+ * 刻度尺绘制
+ *
+ * @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();
+}
+
+
+/* uuid */
+export function uuid() {
+  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
+    var r = (Math.random() * 16) | 0,
+      v = c === "x" ? r : (r & 0x3) | 0x8;
+    return v.toString(16);
+  });
+}

+ 65 - 0
apps/shalu-bigscreen-designer/src/utils/recover.ts

@@ -0,0 +1,65 @@
+import { ProjectInfo } from "#/project";
+import  { update, isArray, isPlainObject, get, isEqual } from 'lodash';
+/**
+ * 批量恢复记录
+ * 
+ * @param projectInfo 项目信息
+ * @param path 路径
+ * @param changeValue 修改值
+ * @returns 
+ */
+export const recoverRecord = (projectInfo: ProjectInfo, path: string = '', changeValue: any) => {
+  const target = get(projectInfo, path);
+  // 数组处理
+  if (isArray(changeValue)) {
+    // 判断两个数组是否一致
+    const originKeys = target.map((item: any) => item.key);
+    const changeKeys = changeValue.map((item: any) => item.key);
+    if(!isEqual(originKeys, changeKeys)) {
+      // 两个数组不一致时,移除原数组多余的数据
+      target.forEach((item: any) => {
+        if(!changeKeys.includes(item.key)) {
+          const index = target.findIndex((val: any) => val.key === item.key);
+          target.splice(index, 1);
+        }
+      });
+      // 添加新的数据
+      changeValue.forEach((item: any) => {
+        if(!originKeys.includes(item.key)) {
+          target.push(item);
+        }
+      });
+    }
+
+    // 遍历更新
+    changeValue.forEach((item: any) => {
+      const index = target.findIndex((val: any) => val.key === item.key);
+      recoverRecord(projectInfo, `${path}[${index}]`, item);
+    });
+
+    return;
+  }
+
+  // 对象处理
+  if (isPlainObject(changeValue)) {
+    // 删除多余的属性
+    if(path && Object.keys(changeValue).length < Object.keys(target || {}).length) {
+      Object.keys(target).forEach((key) => {
+        if(!Object.keys(changeValue).includes(key)) {
+          delete target[key];
+          update(projectInfo, path, () => target);
+        }
+      });
+    }
+    Object.keys(changeValue).forEach((key) => {
+      recoverRecord(projectInfo, `${path}${path && '.'}${key}`, changeValue[key]);
+    });
+    return;
+  }
+
+  // 其他
+  const originValue = get(projectInfo, path);
+  if(originValue !== changeValue) {
+    update(projectInfo, path, () => changeValue);
+  }
+}

+ 123 - 0
apps/shalu-bigscreen-designer/src/utils/scale.ts

@@ -0,0 +1,123 @@
+/**
+ * 对选中的组件进行缩放
+ * 如果操作的是group组件,递归缩放子元素
+ * 
+ * @param projectStore 项目store
+ * @param type 缩放类型
+ * @param moveX x轴移动距离
+ * @param moveY y轴移动距离
+ * @param pathPrefix 路径前缀
+ * @param elements 选中的组件
+ * @param parentKey 父组件key
+ * @returns void
+ *
+ */
+
+import { CustomElement } from "#/project";
+
+export const scaleAction = ({
+  projectStore,
+  type,
+  moveX,
+  moveY,
+  pathPrefix = "",
+  elements,
+  parentKey,
+}: {
+  projectStore: any;
+  type: string;
+  moveX: number;
+  moveY: number;
+  elements: CustomElement[];
+  pathPrefix?: string;
+  parentKey?: string;
+}) => {
+  // 对选中的组件进行缩放
+  elements.forEach((item, index) => {
+    let { x, y, width, height } = item.container.props || {};
+
+    switch (type) {
+      case "top-left":
+        width -= moveX;
+        height -= moveY;
+        if(!parentKey) {
+          x += moveX;
+          y += moveY;
+        }
+        break;
+      case "top-center":
+        height -= moveY;
+        if(!parentKey) {
+          y += moveY;
+        }
+        break;
+      case "top-right":
+        width += moveX;
+        height -= moveY;
+        if(!parentKey) {
+          y += moveY;
+        }
+        break;
+      case "left-center":
+        width -= moveX;
+        if(!parentKey) {
+          x += moveX;
+        }
+        break;
+      case "right-center":
+        width += moveX;
+        break;
+      case "bottom-left":
+        width -= moveX;
+        height += moveY;
+        if(!parentKey) {
+          x += moveX;
+        }
+        break;
+      case "bottom-center":
+        height += moveY;
+        break;
+      case "bottom-right":
+        width += moveX;
+        height += moveY;
+        break;
+    }
+
+    if (width < 10 || height < 10) return;
+
+    const prefix = pathPrefix ? `${pathPrefix}[${index}].` : "";
+
+    projectStore.updateElement(
+      parentKey || item.key,
+      prefix + "container.props.x",
+      Math.round(x)
+    );
+    projectStore.updateElement(
+      parentKey || item.key,
+      prefix + "container.props.y",
+      Math.round(y)
+    );
+    projectStore.updateElement(
+      parentKey || item.key,
+      prefix + "container.props.width",
+      Math.round(width)
+    );
+    projectStore.updateElement(
+      parentKey || item.key,
+      prefix + "container.props.height",
+      Math.round(height)
+    );
+    // 如果是group组件,递归缩放子元素
+    if (item.componentType === "group") {
+      scaleAction({
+        projectStore,
+        type,
+        moveX,
+        moveY,
+        pathPrefix: `children`,
+        elements: item.children || [],
+        parentKey: item.key,
+      });
+    }
+  });
+};

+ 39 - 0
apps/shalu-bigscreen-designer/src/utils/style.ts

@@ -0,0 +1,39 @@
+/**
+ * 转换样式对象
+ * @param style
+ */
+export function transformStyle(style: Record<string, any>) {
+  const styleObj: Record<string, any> = {};
+  for (const key in style) {
+    if (typeof style[key] === "object") {
+      switch (key) {
+        // 背景色对象
+        case "background":
+          if (style[key].type === "none") styleObj[key] = "none";
+          if (style[key].type === "color") styleObj[key] = style[key].color;
+          if (style[key].type === "image") {
+            styleObj[
+              key + "-image"
+            ] = `url(${style[key].image}) no-repeat center center`;
+            styleObj[key + "-size"] = style[key].fillType;
+          }
+          break;
+        // 边框对象
+        case "borderRadius":
+          if (style[key].type === "all")
+            styleObj[key] = `${style[key].value}${style[key].unit}`;
+          else
+            styleObj[
+              key
+            ] = `${style[key].value.topLeft}${style[key].unit} ${style[key].value.topRight}${style[key].unit} ${style[key].value.bottomLeft}${style[key].unit} ${style[key].value.bottomRight}${style[key].unit}`;
+          break;
+      }
+    } else if (typeof style[key] === "number") {
+      styleObj[key] = style[key] + "px";
+    } else if (typeof style[key] === "string") {
+      styleObj[key] = style[key];
+    }
+  }
+  
+  return styleObj;
+}

+ 308 - 0
apps/shalu-bigscreen-designer/src/views/designer/component/ComponentLibary.vue

@@ -0,0 +1,308 @@
+<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>
+        <div class="layer-btn">
+          <div class="cus-btn" @click="stageStore.toggleShowLayer">
+            <DiffOutlined/><span v-show="!collapsed">组件图层</span>
+          </div>
+        </div>
+      </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";
+import { DiffOutlined } from "@ant-design/icons-vue";
+import { uuid } from "@/utils";
+
+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: uuid(),
+    name: item.name,
+    componentType: item.componetName,
+    container: {
+      props: {
+        x: stageStore.width / 2,
+        y: stageStore.height / 2,
+      },
+    },
+  };
+
+  projectStore.addElement(compData, 'center');
+};
+// 拖拽添加组件方式
+// 1、拖拽开始时记录要添加的对象
+// 2、拖拽到画布区域时通过drop事件完成添加组件
+// 3、清空临时数据
+const handleDragStart = (item: CompItem) => {
+  openDrawer.value = false;
+  selectedKeys.value = [];
+
+  projectStore.setAddCompData({
+    key: uuid(),
+    name: item.name,
+    componentType: item.componetName,
+    container: {
+      props: {
+        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;
+    :deep(.ant-collapse-header) {
+      padding: 4px 16px;
+      border-bottom: solid 1px #f0f0f0;
+      background: @bg-color;
+      font-size: 13px;
+    }
+    :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;
+  }
+}
+
+:deep(.ant-menu-light) {
+  background: none;
+
+  .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;
+  }
+}
+:deep(.ant-layout-sider-trigger) {
+  background: none;
+  color: #666;
+}
+.layer-btn {
+  position: absolute;
+  bottom: 50px;
+  width: 100%;
+  text-align: center;
+  font-size: 12px;
+  color: #666;
+  border-top: solid 1px #f0f0f0;
+  border-bottom: solid 1px #f0f0f0;
+  padding: 12px 0;
+  .cus-btn {
+    cursor: pointer;
+  }
+}
+</style>

+ 292 - 0
apps/shalu-bigscreen-designer/src/views/designer/component/ComponentWrapper.vue

@@ -0,0 +1,292 @@
+<template>
+  <div
+    class="component-wrapper"
+    ref="componentWrapperRef"
+    :style="warpperStyle"
+  >
+    <div class="group-box" v-if="componentData.componentType === 'group'">
+      <ComponentWrapper
+        v-for="item in componentData.children"
+        v-show="item.visible"
+        :component-data="item"
+        :key="item.key"
+        :style="{ zIndex: item.zIndex }"
+      />
+    </div>
+    <Container v-bind="componentData.container" v-else>
+      <component
+        :is="component"
+        v-bind="componentData.props"
+        :width="getComponentWidth"
+        :height="getComponentHeight"
+      />
+    </Container>
+    <div v-if="showEditBox" class="edit-box" :style="editWapperStyle">
+      <span class="name-tip">{{ getTip }}</span>
+      <UseDraggable
+        v-for="item in dragPointList"
+        :key="item"
+        @move="(_, e) => handleDragPoint(item, e)"
+        @start="handleDragStart"
+        @end="handleDragEnd"
+      >
+        <span
+          v-if="!componentData.locked"
+          class="edit-box-point"
+          :class="item"
+        ></span>
+      </UseDraggable>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { CustomElement } from "#/project";
+import { defineProps, defineAsyncComponent, computed, ref } from "vue";
+import { useStageStore } from "@/store/modules/stage";
+import { useProjectStore } from "@/store/modules/project";
+import { useDraggable } from "@vueuse/core";
+import { UseDraggable } from "@vueuse/components";
+import { useAcionStore } from "@/store/modules/action";
+import { asyncComponentAll } from "@shalu/dashboard-ui";
+import Container from "@/components/Container/index.vue";
+import { scaleAction } from "@/utils/scale";
+
+const { componentData } = defineProps<{ componentData: CustomElement }>();
+// 动态引入组件
+const component =
+  componentData.componentType === "group"
+    ? ""
+    : defineAsyncComponent(asyncComponentAll[componentData.componentType]);
+
+const componentWrapperRef = ref<HTMLElement | null>(null);
+const stageStore = useStageStore();
+const projectStore = useProjectStore();
+const actionStore = useAcionStore();
+const editWapperStyle = computed(() => {
+  const { width = 400, height = 260 } = componentData.container.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 getComponentWidth = computed(() => {
+  const { width = 400 } = componentData.container.props || {};
+  const { paddingLeft = 0, paddingRight = 0 } =
+    componentData.container.props || {};
+  return width - paddingLeft - paddingRight;
+});
+
+// 组件高--根据边距计算
+const getComponentHeight = computed(() => {
+  const { height = 260 } = componentData.container.props || {};
+  const { paddingTop = 0, paddingBottom = 0 } =
+    componentData.container.props || {};
+  return height - paddingTop - paddingBottom;
+});
+
+const warpperStyle = computed(() => {
+  const {
+    width = 400,
+    height = 260,
+    x,
+    y,
+  } = componentData.container.props || {};
+  // const style = transformStyle(componentData.container?.style || {});
+
+  return {
+    width: `${width}px`,
+    height: `${height}px`,
+    left: x + "px",
+    top: y + "px",
+  };
+});
+// 是否显示编辑框
+const showEditBox = computed(() => {
+  return (
+    projectStore.mode === "edit" &&
+    projectStore.selectedElementKeys.includes(componentData.key)
+  );
+});
+// 获取提示信息
+const getTip = computed(() => {
+  const { x, y } = componentData.container.props || {};
+  return showNameTip.value
+    ? componentData.name
+    : `x: ${Math.round(x)} y: ${Math.round(y)}`;
+});
+
+let isPointDragFlag = false;
+const showNameTip = ref(true);
+let moveLeft: number;
+// 拖拽移动组件
+useDraggable(componentWrapperRef, {
+  onMove: (position) => {
+    if (isPointDragFlag) return;
+
+    const originPosition = componentWrapperRef.value!.getBoundingClientRect();
+    // 计算移动的距离
+    const xMoveLength = position.x - originPosition.left;
+    const yMoveLentgh = position.y - originPosition.top;
+
+    moveLeft = Math.max(Math.abs(xMoveLength), Math.abs(yMoveLentgh));
+    // 对每个选中的组件进行移动
+    projectStore.currentSelectedElements.forEach((item) => {
+      const { x, y } = item.container.props || {};
+      projectStore.updateElement(
+        item.key,
+        "container.props.x",
+        Math.round(x + xMoveLength)
+      );
+      projectStore.updateElement(
+        item.key,
+        "container.props.y",
+        Math.round(y + yMoveLentgh)
+      );
+    });
+  },
+  onStart: () => {
+    if (!projectStore.selectedElementKeys.includes(componentData.key)) {
+      projectStore.setSelectedElementKeys([componentData.key]);
+    }
+    showNameTip.value = false;
+    moveLeft = 0;
+  },
+  onEnd: () => {
+    showNameTip.value = true;
+    moveLeft && actionStore.addRecord(); // 记录操作
+  },
+});
+
+/* ===============================缩放组件==================================== */
+const dragPointList = [
+  "top-left",
+  "top-center",
+  "top-right",
+  "left-center",
+  "right-center",
+  "bottom-left",
+  "bottom-center",
+  "bottom-right",
+];
+
+const startPoint = {
+  x: 0,
+  y: 0,
+};
+// 拖拽点移动 => 缩放组件
+const handleDragPoint = (type: string, e: PointerEvent) => {
+  const moveX = (e.x - startPoint.x) / stageStore.scale;
+  const moveY = (e.y - startPoint.y) / stageStore.scale;
+
+  startPoint.x = e.x;
+  startPoint.y = e.y;
+
+  // 对选中的组件进行缩放
+  scaleAction({
+    projectStore,
+    type,
+    moveX,
+    moveY,
+    elements: projectStore.currentSelectedElements,
+  });
+};
+// 拖拽点开始
+const handleDragStart = (_: any, e: PointerEvent) => {
+  startPoint.x = e.x;
+  startPoint.y = e.y;
+  isPointDragFlag = true;
+  showNameTip.value = false;
+};
+// 拖拽点结束
+const handleDragEnd = () => {
+  isPointDragFlag = false;
+  showNameTip.value = true;
+  actionStore.addRecord(); // 记录操作
+};
+</script>
+
+<script lang="ts">
+export default {
+  name: "ComponentWrapper",
+};
+</script>
+
+<style lang="less" scoped>
+.component-wrapper {
+  position: absolute;
+}
+.edit-box {
+  position: absolute;
+  &-point {
+    position: absolute;
+    width: 8px;
+    height: 8px;
+    background: #fff;
+    border-radius: 50%;
+    border: solid 1px @primary-color;
+  }
+  .name-tip {
+    position: absolute;
+    top: -20px;
+    left: 4px;
+    font-size: 12px;
+    color: #fff;
+    background: @primary-color;
+    padding: 2px 4px;
+  }
+  .top-left {
+    top: -4px;
+    left: -4px;
+    cursor: nw-resize;
+  }
+  .top-center {
+    top: -4px;
+    left: 50%;
+    transform: translateX(-50%);
+    transform-origin: center;
+    cursor: n-resize;
+  }
+  .top-right {
+    top: -4px;
+    right: -4px;
+    cursor: ne-resize;
+  }
+  .left-center {
+    top: 50%;
+    left: -4px;
+    transform: translateY(-50%);
+    cursor: w-resize;
+  }
+  .right-center {
+    top: 50%;
+    right: -4px;
+    transform: translateY(-50%);
+    cursor: e-resize;
+  }
+  .bottom-left {
+    bottom: -4px;
+    left: -4px;
+    cursor: sw-resize;
+  }
+  .bottom-center {
+    bottom: -4px;
+    left: 50%;
+    transform: translateX(-50%);
+    cursor: s-resize;
+  }
+  .bottom-right {
+    bottom: -4px;
+    right: -4px;
+    cursor: se-resize;
+  }
+}
+</style>

+ 241 - 0
apps/shalu-bigscreen-designer/src/views/designer/component/Configurator.vue

@@ -0,0 +1,241 @@
+<template>
+  <div class="configurator">
+    <!-- 页面设置 -->
+    <Tabs v-if="projectStore.selectedElementKeys.length === 0" centered>
+      <TabPane key="1" tab="页面">
+        <div class="config-content">
+          <PageConfig />
+        </div>
+      </TabPane>
+    </Tabs>
+
+    <Tabs centered v-else-if="isGroup">
+      <TabPane key="1" tab="组合">
+        <div class="config-content">
+          <CusForm :columns="groupFormItems" @change="handleGroupChange" />
+        </div>
+      </TabPane>
+    </Tabs>
+
+    <!-- 组件设置 -->
+    <Tabs centered v-else-if="isComponent">
+      <TabPane key="1" tab="内容">
+        <div class="config-content">
+          <component
+            :is="configComponent"
+            @change="handleContentConfigChange"
+            v-bind="currentElementProps"
+          />
+        </div>
+      </TabPane>
+      <TabPane key="2" tab="事件">
+        <div class="config-content">事件处理</div>
+      </TabPane>
+      <TabPane key="3" tab="动画">
+        <div class="config-content">动画处理</div>
+      </TabPane>
+      <TabPane key="4" tab="组件">
+        <div class="config-content">
+          <CusForm :columns="formItems" @change="handleComponentConfigChange" />
+        </div>
+      </TabPane>
+    </Tabs>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, shallowRef, watch } from "vue";
+import { Tabs, TabPane } from "ant-design-vue";
+import { useProjectStore } from "@/store/modules/project";
+import PageConfig from "./PageConfig.vue";
+import { asyncComponentAll, CusForm } from "@shalu/dashboard-ui";
+import { set, defaultsDeep, cloneDeep } from "lodash";
+import { useComponentConfig } from "./useComponentConfig";
+
+const projectStore = useProjectStore();
+const configComponent = shallowRef<null | string>(null);
+const currentElementProps = shallowRef<any>({});
+const { formItems } = useComponentConfig();
+
+const groupFormItems = computed(() => {
+  const containerProps =
+    projectStore.currentSelectedElements?.[0]?.container?.props;
+  return containerProps ? [
+    {
+      label: "宽度",
+      prop: "props.width",
+      type: "inputNumber",
+      fieldProps: {
+        min: 0,
+        addonAfter: "px",
+      },
+      defaultValue: containerProps?.width ?? 400,
+    },
+    {
+      label: "高度",
+      prop: "props.height",
+      type: "inputNumber",
+      fieldProps: {
+        min: 0,
+        addonAfter: "px",
+      },
+      defaultValue: containerProps?.height ?? 260,
+    },
+    {
+      label: "X",
+      prop: "props.x",
+      type: "inputNumber",
+      fieldProps: {
+        min: 0,
+        addonAfter: "px",
+      },
+      defaultValue: containerProps?.x ?? 0,
+    },
+    {
+      label: "Y",
+      prop: "props.y",
+      type: "inputNumber",
+      fieldProps: {
+        min: 0,
+        addonAfter: "px",
+      },
+      defaultValue: containerProps?.y ?? 0,
+    },
+  ] : [];
+});
+
+watch(
+  () => projectStore.currentSelectedElements,
+  async (val) => {
+    // 组件类型
+    if (val.length === 1 && val[0].componentType !== "group") {
+      const { Config } = await asyncComponentAll[
+        val[0].componentType as keyof typeof asyncComponentAll
+      ]?.();
+      configComponent.value = Config;
+      currentElementProps.value = val[0].props;
+    } else {
+      // 多选或者组暂时为空
+      configComponent.value = null;
+      currentElementProps.value = {};
+    }
+  },
+  { immediate: true, deep: true }
+);
+
+const isComponent = computed(() => {
+  return (
+    projectStore.currentSelectedElements.length === 1 &&
+    projectStore.currentSelectedElements[0].componentType !== "group"
+  );
+});
+
+const isGroup = computed(() => {
+  return (
+    projectStore.currentSelectedElements.length === 1 &&
+    projectStore.currentSelectedElements[0].componentType === "group"
+  );
+});
+
+// 组件内容配置
+const handleContentConfigChange = (config: any) => {
+  const element = projectStore.currentSelectedElements[0];
+  let prefix: string = "";
+  if (element?.parentKey !== undefined) {
+    const parent = projectStore.elements.find(
+      (item) => item.key === element.parentKey
+    );
+    parent?.children?.findIndex((item, index) => {
+      if (item.key === element.key) {
+        prefix = `children[${index}].`;
+      }
+    });
+  }
+  projectStore.updateElement(
+    element?.parentKey ?? element.key,
+    prefix + "props",
+    config
+  );
+};
+
+// 组件配置
+const handleComponentConfigChange = (config: Record<string, any>) => {
+  const container: Record<string, any> = {};
+  const currentContainer = cloneDeep(
+    projectStore.currentSelectedElements[0].container
+  );
+  Object.entries(config).forEach(([key, value]) => {
+    set(container, key, value);
+  });
+  defaultsDeep(container, currentContainer);
+  const element = projectStore.currentSelectedElements[0];
+  let prefix: string = "";
+  if (element?.parentKey !== undefined) {
+    const parent = projectStore.elements.find(
+      (item) => item.key === element.parentKey
+    );
+    parent?.children?.findIndex((item, index) => {
+      if (item.key === element.key) {
+        prefix = `children[${index}].`;
+      }
+    });
+  }
+  // 判断是否存在父级情况
+  projectStore.updateElement(
+    element?.parentKey ?? element.key,
+    prefix + "container",
+    container
+  );
+  projectStore.updateElement(
+    element?.parentKey ?? element.key,
+    prefix + "name",
+    container?.name
+  );
+};
+
+const handleGroupChange = (config: Record<string, any>) => {
+  const element = projectStore.currentSelectedElements[0];
+  const container: Record<string, any> = {};
+  Object.entries(config).forEach(([key, value]) => {
+    set(container, key, value);
+  });
+  const changeWidth = container.props.width - element.container.props.width;
+  const changeHeight = container.props.height - element.container.props.height;
+
+  projectStore.updateElement(element.key, "container.props", container?.props);
+  // 更新子元素的宽高
+  element.children?.forEach((child, index) => {
+    child.container.props.width += changeWidth;
+    child.container.props.height += changeHeight;
+    projectStore.updateElement(element.key, `children[${index}].container.props.width`, child.container.props.width);
+    projectStore.updateElement(element.key, `children[${index}].container.props.height`, child.container.props.height);
+  });
+};
+</script>
+
+<style lang="less" scoped>
+.configurator {
+  width: 300px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  :deep(.ant-tabs) {
+    flex: 1;
+    overflow-y: scroll;
+  }
+  :deep(.ant-tabs-nav) {
+    margin-bottom: 0;
+  }
+  :deep(.ant-tabs-content-holder) {
+    background-color: @bg-color;
+    padding-top: 12px;
+  }
+  :deep(.ant-tabs-content) {
+    background-color: @bg-color;
+  }
+  .config-content {
+    padding: 12px;
+    padding-top: 0;
+  }
+}
+</style>

+ 189 - 0
apps/shalu-bigscreen-designer/src/views/designer/component/LayerItem.vue

@@ -0,0 +1,189 @@
+<template>
+  <div
+    class="list-item"
+    :class="{
+      'list-item-active': projectStore.selectedElementKeys.includes(data.key),
+    }"
+    @mouseenter="isHover = true"
+    @mouseleave="isHover = false"
+    @click="handleActive"
+  >
+    <template v-if="!isEditing">
+      <span class="list-item-visible">
+        <EyeOutlined v-if="data.visible && isHover" @click="handleVisible(false)" />
+        <EyeInvisibleOutlined v-if="!data.visible" @click="handleVisible(true)" />
+      </span>
+      <span class="layer-name">
+        <span v-if="type === 'group'" class="collapse-icon" @click="handleCollapse">
+          <CaretUpOutlined v-if="data.collapsed" />
+          <CaretDownOutlined v-else />
+        </span>
+        <span class="comp-icon" v-if="type === 'group'">
+          <FolderOutlined />
+        </span>
+        <span v-else class="comp-icon" :class="{'child-icon': type === 'child'}">
+          <PieChartOutlined />
+        </span>
+        <Tooltip :title="data.name">
+          <span @dblclick="isEditing = true">{{ data.name }}</span>
+        </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>
+        </span>
+
+        <Tooltip title="解锁" v-if="data.locked">
+          <LockOutlined @click="handleLock(false)"/>
+        </Tooltip>
+        <span v-else v-show="isHover">
+          <Tooltip title="锁定">
+            <UnlockOutlined @click="handleLock(true)"/>
+          </Tooltip>
+        </span>
+        
+      </span>
+    </template>
+
+    <Input
+      v-else
+      v-model:value="layerName"
+      placeholder="请输入图层名称"
+      size="small"
+      :status="!layerName ? 'error' : undefined"
+      @blur="handleChangeName"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { CustomElement } from "#/project";
+import { defineProps, ref } from "vue";
+import { Tooltip, Dropdown, Menu, MenuItem, Input } from "ant-design-vue";
+import {
+  EyeOutlined,
+  EyeInvisibleOutlined,
+  MoreOutlined,
+  LockOutlined,
+  UnlockOutlined,
+  EditOutlined,
+  DeleteOutlined,
+  PieChartOutlined,
+  FolderOutlined,
+  CaretUpOutlined,
+  CaretDownOutlined
+} from "@ant-design/icons-vue";
+import { useProjectStore } from "@/store/modules/project";
+import { useAcionStore } from "@/store/modules/action";
+
+const props = defineProps<{
+  data: CustomElement;
+  // 组件 | 组 | 子组件
+  type: "layer" | "group" | "child";
+  // 子组件所在的索引
+  index?: number;
+}>();
+
+const projectStore = useProjectStore();
+const actionStore = useAcionStore();
+
+const layerName = ref<string>(props.data.name);
+
+const isEditing = ref(false);
+const isHover = ref(false);
+
+const handleMenuClick = ({ key }: { key: "rename" | "del" }) => {
+  if (key === "rename") {
+    isEditing.value = true;
+  } else {
+    projectStore.removeElement(props.data.key);
+    actionStore.addRecord() // 添加记录
+  }
+};
+
+const handleChangeName = () => {
+  isEditing.value = false;
+  if (!layerName.value) {
+    layerName.value = props.data.name;
+    return;
+  };
+  const prefix = props.index !== undefined ? `children[${props.index}].` : '';
+  projectStore.updateElement(props.data?.parentKey ?? props.data.key, `${prefix}name`, layerName.value);
+  actionStore.addRecord() // 添加记录
+};
+
+const handleActive = () => {
+  projectStore.setSelectedElementKeys([props.data.key]);
+};
+
+const handleLock = (locked: boolean) => {
+  const prefix = props.index !== undefined ? `children[${props.index}].` : '';
+  projectStore.updateElement(props.data?.parentKey ?? props.data.key, `${prefix}locked`, locked);
+  actionStore.addRecord() // 添加记录
+};
+
+const handleVisible = (visible: boolean) => {
+  const prefix = props.index !== undefined ? `children[${props.index}].` : '';
+  projectStore.updateElement(props.data?.parentKey ?? props.data.key, `${prefix}visible`, visible);
+  actionStore.addRecord() // 添加记录
+};
+
+const handleCollapse = () => {
+  projectStore.updateElement(props.data.key, "collapsed", !props.data.collapsed);
+};
+</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: 0px;
+  }
+  .layer-name {
+    width: 100px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+  &-active {
+    background: #e6f4ff;
+    color: #1677ff;
+  }
+  .layer-action {
+    width: 30px;
+    text-align: right;
+  }
+  .comp-icon {
+    margin-right: 4px;
+  }
+  .collapse-icon {
+    margin-right: 4px;
+    cursor: pointer;
+  }
+  .child-icon {
+    margin-left: 20px;
+  }
+}
+</style>

+ 143 - 0
apps/shalu-bigscreen-designer/src/views/designer/component/LayerManagement.vue

@@ -0,0 +1,143 @@
+<template>
+  <div class="layer">
+    <div class="layer-header">
+      <span>组件图层</span>
+      <Button type="text" shape="circle" size="small">
+        <CloseOutlined @click="stageStore.toggleShowLayer"/>
+      </Button>
+    </div>
+    <div class="line"></div>
+    <InputSearch
+      allowClear
+      size="small"
+      placeholder="请输入图层名称"
+      @search="handleFilterLayer"
+    />
+
+    <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 }">
+          <div :class="{'group-bg': projectStore.selectedElementKeys.includes(element.key)}" v-if="element.componentType === 'group'">
+            <LayerItem :data="element" type="group"/>
+            <VueDraggable
+              :list="element.children"
+              ghost-class="item-ghost"
+              chosen-class="item-chosen"
+              animation="300"
+              itemKey="id"
+              @end="dragEnd"
+              v-show="!element.collapsed"
+            >
+              <template #item="{ element: item, index }">
+                <LayerItem :data="item" type="child" :index="index" />
+              </template>
+            </VueDraggable>
+          </div>
+          <LayerItem v-else :data="element" type="layer"/>
+        </template>
+      </VueDraggable>
+
+      <Empty
+        v-else
+        description="暂无图层"
+        :image="Empty.PRESENTED_IMAGE_SIMPLE"
+        :style="{ marginTop: '100px' }"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { CustomElement } from "#/project";
+import { ref, watch } 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";
+import { useProjectStore } from "@/store/modules/project";
+import { useStageStore } from "@/store/modules/stage";
+import { useAcionStore } from "@/store/modules/action";
+
+const stageStore = useStageStore();
+const projectStore = useProjectStore();
+const filter = ref<string>("");
+const layerList = ref<CustomElement[]>([]);
+const actionStore = useAcionStore();
+
+watch(
+  () => [
+    projectStore.elements,
+    filter.value,
+  ],
+  () => {
+    const list = projectStore.elements.filter((item) =>
+      item.name.includes(filter.value)
+    );
+    layerList.value = list.sort((a, b) => b.zIndex - a.zIndex);
+  },
+  {
+    immediate: true,
+    deep: true
+  }
+);
+
+const handleFilterLayer = (value: string) => {
+  filter.value = value;
+};
+
+const dragEnd = (event: CustomEvent & {newIndex: number}) => {
+  const length = layerList.value.length;
+  layerList.value.forEach((item, index) => {
+    projectStore.updateElement(item.key, "zIndex", length - index);
+  });
+  projectStore.setSelectedElementKeys([layerList.value[event.newIndex].key]);
+  actionStore.addRecord() // 添加记录
+};
+</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;
+    }
+  }
+
+  .line {
+    width: calc(100% + 16px);
+    border-top: solid 1px #eee;
+    margin-bottom: 8px;
+    margin-left: -8px;
+  }
+  .group-bg {
+    background: #f7f9ff;
+  }
+}
+</style>

+ 250 - 0
apps/shalu-bigscreen-designer/src/views/designer/component/MenuBar.vue

@@ -0,0 +1,250 @@
+<template>
+  <div>
+    <Tooltip>
+      <template #title>
+        <div>撤销</div>
+        <div>ctrl+z</div>
+      </template>
+      <Button
+        type="text"
+        size="small"
+        :disabled="actionStore.undoDisabled"
+        @click="actionStore.actionUndo"
+      >
+        <UndoOutlined />
+      </Button>
+    </Tooltip>
+
+    <Tooltip>
+      <template #title>
+        <div>还原</div>
+        <div>ctrl+shift+z</div>
+      </template>
+      <Button
+        type="text"
+        size="small"
+        :disabled="actionStore.redoDisabled"
+        @click="actionStore.actionRedo"
+      >
+        <RedoOutlined />
+      </Button>
+    </Tooltip>
+
+    <Divider type="vertical" />
+
+    <Tooltip>
+      <template #title>
+        <div>组合</div>
+        <div>ctrl+g</div>
+      </template>
+      <Button
+        type="text"
+        size="small"
+        :disabled="projectStore.selectedElementKeys.length <= 1"
+        @click="actionStore.actionGroup"
+      >
+        <BlockOutlined />
+      </Button>
+    </Tooltip>
+
+    <Tooltip>
+      <template #title>
+        <div>取消组合</div>
+        <div>ctrl+shift+g</div>
+      </template>
+
+      <Button type="text" size="small" :disabled="ungroupDisabled" @click="actionStore.actionUngroup">
+        <SplitCellsOutlined />
+      </Button>
+    </Tooltip>
+
+    <Tooltip>
+      <template #title>
+        <div>删除</div>
+        <div>del</div>
+      </template>
+      <Button
+        type="text"
+        size="small"
+        :disabled="!projectStore.selectedElementKeys.length"
+        @click="handleDeleteElements"
+      >
+        <DeleteOutlined />
+      </Button>
+    </Tooltip>
+
+    <Divider type="vertical" />
+
+    <Dropdown trigger="click">
+      <template #overlay>
+        <Menu
+          @click="handleAlignClick"
+          :disabled="projectStore.selectedElementKeys.length < 2"
+        >
+          <MenuItem :key="AlignEnum.Left">
+            <VerticalRightOutlined />
+            左对齐
+          </MenuItem>
+          <MenuItem :key="AlignEnum.Right">
+            <VerticalLeftOutlined />
+            右对齐
+          </MenuItem>
+          <MenuItem :key="AlignEnum.HorizontalCenter">
+            <VerticalAlignMiddleOutlined />
+            水平居中
+          </MenuItem>
+          <MenuDivider />
+          <MenuItem :key="AlignEnum.Top">
+            <VerticalAlignTopOutlined />
+            顶部对齐
+          </MenuItem>
+          <MenuItem :key="AlignEnum.Bottom">
+            <VerticalAlignBottomOutlined />
+            底部对齐
+          </MenuItem>
+          <MenuItem :key="AlignEnum.VerticalCenter">
+            <LineOutlined />
+            垂直居中
+          </MenuItem>
+        </Menu>
+      </template>
+      <Tooltip placement="left">
+        <template #title>
+          <div>对齐</div>
+        </template>
+        <Button
+          :disabled="projectStore.selectedElementKeys.length < 2"
+          type="text"
+          size="small"
+        >
+          <VerticalAlignMiddleOutlined />
+          <CaretDownOutlined
+            style="font-size: 10px; vertical-align: baseline"
+          />
+        </Button>
+      </Tooltip>
+    </Dropdown>
+
+    <Dropdown trigger="click">
+      <template #overlay>
+        <Menu
+          @click="handleLayerClick"
+          :disabled="projectStore.selectedElementKeys.length < 1"
+        >
+          <MenuItem
+            :key="LayerEnum.UP"
+            v-if="projectStore.selectedElementKeys.length === 1"
+          >
+            <ArrowUpOutlined />
+            上移一层
+          </MenuItem>
+          <MenuItem
+            :key="LayerEnum.DOWN"
+            v-if="projectStore.selectedElementKeys.length === 1"
+          >
+            <ArrowDownOutlined />
+            下移一层
+          </MenuItem>
+          <MenuItem :key="LayerEnum.TOP">
+            <VerticalAlignTopOutlined />
+            移至顶层
+          </MenuItem>
+          <MenuItem :key="LayerEnum.BOTTOM">
+            <VerticalAlignBottomOutlined />
+            移至底层
+          </MenuItem>
+        </Menu>
+      </template>
+      <Tooltip placement="right">
+        <template #title>
+          <div>层级</div>
+        </template>
+        <Button
+          type="text"
+          size="small"
+          :disabled="projectStore.selectedElementKeys.length < 1"
+        >
+          <ColumnHeightOutlined />
+          <CaretDownOutlined
+            style="font-size: 10px; vertical-align: baseline"
+          />
+        </Button>
+      </Tooltip>
+    </Dropdown>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } 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";
+import { LayerEnum } from "@/enum/layerEnum";
+import { useAcionStore } from "@/store/modules/action";
+import { useProjectStore } from "@/store/modules/project";
+import { useKeyPress } from 'vue-hooks-plus';
+
+const actionStore = useAcionStore();
+const projectStore = useProjectStore();
+const ungroupDisabled = computed(() => {
+  const length = projectStore.selectedElementKeys.length;
+  const current = projectStore.currentSelectedElements?.[0];
+  return !(length === 1 && current?.componentType === 'group');
+});
+const handleAlignClick = ({ key }: any) => {
+  actionStore.actionAlign(key);
+};
+const handleLayerClick = ({ key }: any) => {
+  actionStore.actionLayer(key);
+};
+const handleDeleteElements = () => {
+  projectStore.selectedElementKeys.forEach((key) => {
+    projectStore.removeElement(key);
+  });
+};
+
+/* =========================快捷键=========================== */
+// 撤销/重做
+useKeyPress('ctrl.z', () => !actionStore.undoDisabled && actionStore.actionUndo(), { exactMatch: true });
+useKeyPress('ctrl.shift.z', () => !actionStore.redoDisabled && actionStore.actionRedo(), { exactMatch: true });
+// 组合/取消组合
+useKeyPress('ctrl.g', () => projectStore.selectedElementKeys.length > 1 && actionStore.actionGroup(), { exactMatch: true });
+useKeyPress('ctrl.shift.g', () => !ungroupDisabled.value && actionStore.actionUngroup(), { exactMatch: true });
+// 删除
+useKeyPress('del', () => projectStore.selectedElementKeys.length && handleDeleteElements(), { exactMatch: true });
+// 调整层级
+useKeyPress('ctrl.up', () => projectStore.selectedElementKeys.length && actionStore.actionLayer(LayerEnum.UP), { exactMatch: true });
+useKeyPress('ctrl.down', () => projectStore.selectedElementKeys.length && actionStore.actionLayer(LayerEnum.DOWN), { exactMatch: true });
+useKeyPress('ctrl.shift.up', () => projectStore.selectedElementKeys.length && actionStore.actionLayer(LayerEnum.TOP), { exactMatch: true });
+useKeyPress('ctrl.shift.down', () => projectStore.selectedElementKeys.length && actionStore.actionLayer(LayerEnum.BOTTOM), { exactMatch: true });
+// 复制/粘贴
+useKeyPress('ctrl.c', () => projectStore.selectedElementKeys.length && actionStore.actionCopy(), { exactMatch: true });
+useKeyPress('ctrl.v', () => actionStore.actionPaste(), { exactMatch: true });
+</script>
+
+<style scoped></style>

+ 61 - 0
apps/shalu-bigscreen-designer/src/views/designer/component/PageConfig.vue

@@ -0,0 +1,61 @@
+<template>
+  <CusForm :columns="formItems" @change="handleChange"/>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { CusForm } from '@shalu/dashboard-ui';
+import { useProjectStore } from '@/store/modules/project';
+import { isEqual, omit } from 'lodash';
+import { useAcionStore } from '@/store/modules/action';
+
+const projectStore = useProjectStore();
+const actionStore = useAcionStore();
+const formItems = computed(() => [
+  {
+    label: '项目名称',
+    prop: 'name',
+    type: 'input',
+    defaultValue: projectStore.projectInfo.name
+  },
+  {
+    label: '宽度',
+    prop: 'width',
+    type: 'inputNumber',
+    fieldProps: {
+      addonAfter: 'px',
+      min: 100
+    },
+    defaultValue: projectStore.projectInfo.width
+  },
+  {
+    label: '高度',
+    prop: 'height',
+    type: 'inputNumber',
+    fieldProps: {
+      addonAfter: 'px',
+      min: 100
+    },
+    defaultValue: projectStore.projectInfo.height
+  },
+  {
+    label: '页面背景',
+    prop: 'background',
+    type: 'backgroundSelect',
+    defaultValue: projectStore.currentPage.background
+  }
+]);
+
+const handleChange = (value: Record<string, any>) => {
+  if(!isEqual(value.background, projectStore.currentPage.background)) {
+    projectStore.setCurrentPageBackground(value.background);
+  };
+  projectStore.updateProjectInfo(omit(value, ['background']));
+  actionStore.addRecord();
+};
+
+</script>
+
+<style scoped>
+
+</style>

+ 309 - 0
apps/shalu-bigscreen-designer/src/views/designer/component/Scaleplate.vue

@@ -0,0 +1,309 @@
+<template>
+  <div class="scaleplate" id="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,
+    stageStore.viewportHeight,
+    stageStore.viewportWidth,
+  ],
+  () => {
+    handleDrawScaleplate();
+  },
+  { immediate: false }
+);
+
+let observer: ResizeObserver;
+onMounted(() => {
+  setWindowSize();
+
+  observer = new ResizeObserver(setWindowSize);
+  /* 监听画布尺寸变化 */
+  const element = document.getElementsByClassName('workspace-wrapper')?.[0];
+  if(element) {
+    observer.observe(element);
+  }
+});
+onBeforeUnmount(() => {
+  observer.disconnect();
+});
+</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>

+ 230 - 0
apps/shalu-bigscreen-designer/src/views/designer/component/Stage.vue

@@ -0,0 +1,230 @@
+<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"
+          v-show="item.visible"
+          :component-data="item"
+          :key="item.key"
+          :style="{zIndex: item.zIndex}"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onBeforeUnmount, computed, nextTick, watch, defineExpose } from "vue";
+import type { Ref } from "vue";
+import { useStageStore } from "@/store/modules/stage";
+import { useProjectStore } from "@/store/modules/project";
+import { useScroll } from "@vueuse/core";
+import ComponentWrapper from "./ComponentWrapper.vue";
+import { useAcionStore } from "@/store/modules/action";
+import { uuid } from "@/utils";
+
+const stageWrapperRef: Ref<HTMLElement | null> = ref(null);
+const stageRef: Ref<HTMLElement | null> = ref(null);
+const canvasRef: Ref<HTMLElement | null> = ref(null);
+const stageStore = useStageStore();
+const projectStore = useProjectStore();
+const actionStore = useAcionStore();
+
+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`,
+      border: '1px solid #ddd',
+      ...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;
+  }
+
+  const result = scale > maxScale ? maxScale : scale;
+  stageStore.setScale(result > 0.1 ? result : 0.1);
+};
+
+/* 设置舞台位置-默认居中 */
+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: uuid(),
+      name: projectStore.addCompData.name,
+      componentType: projectStore.addCompData.componentType,
+      container: {
+        props: {
+          x: offsetX,
+          y: offsetY,
+        }
+      }
+    };
+    projectStore.addElement(compData);
+    actionStore.addRecord(); // 记录操作
+  }
+};
+
+/* 适应大小设置 */
+watch(
+  () => stageStore.scale,
+  (val) => {
+    if(!val) {
+      initScale();
+      initStagePosition();  
+    }
+  }
+);
+watch(
+  () => [
+    projectStore.projectInfo.width,
+    projectStore.projectInfo.height
+  ],
+  () => {
+    initScale();
+    initStagePosition();  
+  }
+);
+
+defineExpose({
+  getPosition: () => canvasRef.value?.getBoundingClientRect(),
+});
+
+onMounted(() => {
+  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>

+ 231 - 0
apps/shalu-bigscreen-designer/src/views/designer/component/Workspace.vue

@@ -0,0 +1,231 @@
+<template>
+  <div
+    ref="boxRef"
+    style="width: 100%; height: 100%; position: relative;"
+    @mousedown="handleMouseDown"
+    @mousemove="handleMouseMove"
+    @mouseup="handleMouseUp"
+  >
+    <Flex class="workspace" vertical>
+      <div class="workspace-top">
+        <Stage ref="stageRef" />
+        <Scaleplate />
+      </div>
+      <Flex class="workspace-bottom" justify="space-between" align="center">
+        <div class="bottom-left">
+          <span style="margin-right: 12px"
+            >画布尺寸:{{ projectStore.projectInfo.width }} *
+            {{ projectStore.projectInfo.height }}px</span
+          >
+          <span
+            >画布自适应:<Select
+              size="small"
+              style="width: 120px"
+              :value="projectStore.projectInfo.fillType"
+              :options="fillOptions"
+              @change="projectStore.setFillType"
+          /></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>
+    <div class="selectBox" ref="selectBoxRef"></div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from "vue";
+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";
+import { ScreenFillEnum } from "@/enum/screenFillEnum";
+import { throttle } from "lodash";
+
+const projectStore = useProjectStore();
+const stageStore = useStageStore();
+const stageRef = ref<{getPosition: HTMLElement['getBoundingClientRect']} | null>();
+
+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 fillOptions = [
+  { value: ScreenFillEnum.AUTO, label: "自动" },
+  { value: ScreenFillEnum.FILL_HEIGHT, label: "宽度填充" },
+  { value: ScreenFillEnum.FILL_WIDTH, label: "高度填充" },
+  { value: ScreenFillEnum.FILL_BOTH, label: "双向铺满" },
+  { value: ScreenFillEnum.NONE, label: "无" },
+];
+
+const handleSizeChange = (val: any) => {
+  if (Number.isFinite(val)) {
+    // 为0时为自动适应大小
+    if(val === 0) {
+      stageStore.setScale(0);
+      return;
+    }
+    stageStore.setScale((val as number) < 0.1 ? 0.1 : val);
+  }
+  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);
+    }
+  }
+};
+
+/* ====================处理框选多个组件======================== */
+const selectBoxRef = ref<HTMLElement | null>(null);
+const boxRef = ref<HTMLElement | null>(null);
+let isMouseDown = ref(false);
+let startX = 0; // 框选起始x坐标
+let startY = 0; // 框选起始y坐标
+let workspaceLeft = 0;
+let workspaceTop = 0;
+// 鼠标按下
+const handleMouseDown = (e: MouseEvent) => {
+  if (
+    e?.target?.closest(".edit-box") ||
+    e?.target?.closest(".component-content") ||
+    e?.target?.closest(".component-wrapper") ||
+    e?.target?.closest(".scaleplate-horizontal") ||
+    e?.target?.closest(".scaleplate-vertical") ||
+    e?.target?.closest(".refer-line-img") ||
+    e?.target?.closest(".workspace-bottom") ||
+    e?.target?.closest(".refer-line")
+  ) {
+    return;
+  }
+  
+  const { top, left } = boxRef.value!.getBoundingClientRect();
+  const { clientX, clientY } = e;
+  isMouseDown.value = true;
+  workspaceLeft = left;
+  workspaceTop = top;
+  startX = clientX - left;
+  startY = clientY - top;
+  selectBoxRef.value!.style.display = "block";
+  selectBoxRef.value!.style.left = `${startX}px`;
+  selectBoxRef.value!.style.top = `${startY}px`;
+};
+/* 鼠标移动 */
+const handleMouseMove = throttle((e: MouseEvent) => {
+  if (!isMouseDown.value) return;
+  const { clientX, clientY } = e;
+  const width = clientX - workspaceLeft - startX;
+  const height = clientY - workspaceTop - startY;
+
+  const left = width > 0 ? startX : clientX - workspaceLeft;
+  const top = height > 0 ? startY : clientY - workspaceTop;
+
+  selectBoxRef.value!.style.width = `${Math.abs(width)}px`;
+  selectBoxRef.value!.style.height = `${Math.abs(height)}px`;
+  selectBoxRef.value!.style.left = `${left}px`;
+  selectBoxRef.value!.style.top = `${top}px`;
+}, 50);
+/* 鼠标抬起 */
+const handleMouseUp = (e: MouseEvent) => {
+  if (!isMouseDown || (startX === 0 && startY === 0)) return;
+
+  isMouseDown.value = false;
+  selectBoxRef.value!.style.display = "none";
+  selectBoxRef.value!.style.width = "0";
+  selectBoxRef.value!.style.height = "0";
+
+  const { clientX, clientY } = e;
+  const { left: stageLeft = 0, top: stageTop = 0} = stageRef.value?.getPosition() || {};
+
+  // 框选的起始位置
+  const x1 = (Math.min(startX + workspaceLeft, clientX) - stageLeft) / stageStore.scale;
+  const y1 = (Math.min(startY + workspaceTop, clientY) - stageTop) / stageStore.scale;
+  // 框选的结束位置
+  const x2 = (Math.max(startX + workspaceLeft, clientX) - stageLeft) / stageStore.scale;
+  const y2 = (Math.max(startY + workspaceTop, clientY) - stageTop) / stageStore.scale;
+  handleSelectComponent(x1, y1, x2, y2);
+
+  // 清空临时数据
+  startX = startY = workspaceLeft = workspaceTop = 0;
+};
+/* 处理框选的组件 */
+const handleSelectComponent = (startX: number, startY: number, endX: number, endY: number) => {
+  const selectKeys = projectStore.currentPage.elements.filter((item) => {
+    const { x, y, width, height } = item.container.props;
+    const x1 = Math.min(startX, endX);
+    const x2 = Math.max(startX, endX);
+    const y1 = Math.min(startY, endY);
+    const y2 = Math.max(startY, endY);
+    // 返回判断完全包裹组件
+    return x >= x1 && y >= y1 && x + width <= x2 && y + height <= y2;
+  }).map((item) => item.key);
+  projectStore.setSelectedElementKeys(selectKeys);
+};
+/* ====================处理框选多个组件======================== */
+</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;
+  }
+}
+.selectBox {
+  display: none;
+  position: absolute;
+  left: 0;
+  top: 0;
+  z-index: 9999;
+  border: solid 1px #2c76bd;
+  box-sizing: border-box;
+  background-color: #2c76bd90;
+  width: 0;
+  height: 0;
+}
+</style>

+ 296 - 0
apps/shalu-bigscreen-designer/src/views/designer/component/useComponentConfig.ts

@@ -0,0 +1,296 @@
+import type { IFormItem } from "@shalu/dashboard-ui";
+import { computed } from "vue";
+import { cloneDeep } from "lodash";
+import { useProjectStore } from "@/store/modules/project";
+
+export const useComponentConfig = () => {
+
+  const projectStore = useProjectStore();
+
+  const formItems = computed((): IFormItem[] => {
+    const element = projectStore.currentSelectedElements[0];
+    const containerStyle = element?.container?.style || {};
+    const containerProps = element?.container?.props || {};
+    
+    return [
+      {
+        label: "组件样式",
+        prop: "style",
+        type: "group",
+        children: [
+          {
+            label: "边框背景",
+            prop: "style.background",
+            type: "backgroundSelect",
+            defaultValue: cloneDeep(containerStyle?.background) ?? {
+              type: "none",
+              color: "",
+              image: "",
+              fillType: "cover",
+            },
+          },
+          {
+            label: "不透明度",
+            prop: "style.opacity",
+            type: "slider",
+            defaultValue: containerStyle?.opacity ?? 1,
+          },
+          {
+            label: "边框线",
+            prop: "style.borderStyle",
+            type: "select",
+            fieldProps: {
+              options: [
+                { label: "无", value: "none" },
+                { label: "实线", value: "solid" },
+                { label: "虚线", value: "dashed" },
+                { label: "点线", value: "dotted" },
+              ],
+            },
+            defaultValue: containerStyle?.borderStyle || "none",
+          },
+          {
+            label: '',
+            prop: "",
+            type: 'dependency',
+            name: ['style.borderStyle'],
+            children: (model: any) => {
+              return model['style.borderStyle'] !== 'none' ? [
+                {
+                  label: "边框颜色",
+                  prop: "style.borderColor",
+                  type: "colorSelect",
+                  defaultValue: containerStyle?.borderColor ?? "#EEEEEEFF",
+                  fieldProps: {
+                    gradient: false
+                  }
+                },
+                {
+                  label: "边框宽度",
+                  prop: "style.borderWidth",
+                  type: "inputNumber",
+                  fieldProps: {
+                    min: 0,
+                    addonAfter: "px",
+                  },
+                  defaultValue: containerStyle?.borderWidth ?? 1,
+                },
+              ] : [];
+            }
+          },
+          {
+            label: "圆角",
+            prop: "style.borderRadius",
+            type: "boderRadiusSelect",
+            fieldProps: {
+              min: 0,
+              addonAfter: "px",
+            },
+            defaultValue: containerStyle?.borderRadius ?? 0,
+          },
+          {
+            label: "毛玻璃",
+            prop: "style.backdropFilter",
+            type: "radioGroup",
+            fieldProps: {
+              options: [
+                { label: "开启", value: "blur(10px)" },
+                { label: "关闭", value: "" },
+              ],
+            },
+            defaultValue: containerStyle?.backdropFilter || "",
+          },
+          {
+            label: "阴影",
+            prop: "style.boxShadow",
+            type: "radioGroup",
+            fieldProps: {
+              options: [
+                {
+                  label: "开启",
+                  value: "rgba(0,0,0,0.3) 15px 20px 20px",
+                },
+                { label: "关闭", value: "" },
+              ],
+            },
+            defaultValue: containerStyle?.boxShadowEnabled || "",
+          },
+          {
+            label: "倒影",
+            prop: "style.webkitBoxReflect",
+            type: "radioGroup",
+            fieldProps: {
+              options: [
+                {
+                  label: "开启",
+                  value:
+                    "below 2px linear-gradient(transparent, rgba(0,0,0,0.5))",
+                },
+                { label: "关闭", value: "" },
+              ],
+            },
+            defaultValue: containerStyle?.webkitBoxReflect || "",
+          },
+        ],
+      },
+      {
+        label: "组件属性",
+        prop: "props",
+        type: "group",
+        children: [
+          {
+            label: "名称",
+            prop: "name",
+            type: "input",
+            defaultValue: element?.name ?? "",
+          },
+          {
+            label: "宽高",
+            type: 'divider',
+            prop: "",
+          },
+          {
+            label: "宽度",
+            prop: "props.width",
+            type: "inputNumber",
+            fieldProps: {
+              min: 0,
+              addonAfter: "px",
+            },
+            defaultValue: containerProps?.width ?? 400,
+          },
+          {
+            label: "高度",
+            prop: "props.height",
+            type: "inputNumber",
+            fieldProps: {
+              min: 0,
+              addonAfter: "px",
+            },
+            defaultValue: containerProps?.height ?? 260,
+          },
+          {
+            label: "位置",
+            type: 'divider',
+            prop: "",
+          },
+          {
+            label: "X",
+            prop: "props.x",
+            type: "inputNumber",
+            fieldProps: {
+              min: 0,
+              addonAfter: "px",
+            },
+            defaultValue: containerProps?.x ?? 0,
+          },
+          {
+            label: "Y",
+            prop: "props.y",
+            type: "inputNumber",
+            fieldProps: {
+              min: 0,
+              addonAfter: "px",
+            },
+            defaultValue: containerProps?.y ?? 0,
+          },
+          {
+            label: "边距",
+            type: 'divider',
+            prop: "",
+          },
+          {
+            label: "顶边距",
+            prop: "props.paddingTop",
+            type: "inputNumber",
+            fieldProps: {
+              min: 0,
+              addonAfter: "px",
+            },
+            defaultValue: containerProps?.paddingTop ?? 0,
+          },
+          {
+            label: "右边距",
+            prop: "props.paddingRight",
+            type: "inputNumber",
+            fieldProps: {
+              min: 0,
+              addonAfter: "px",
+            },
+            defaultValue: containerProps?.paddingRight ?? 0,
+          },
+          {
+            label: "底边距",
+            prop: "props.paddingBottom",
+            type: "inputNumber",
+            fieldProps: {
+              min: 0,
+              addonAfter: "px",
+            },
+            defaultValue: containerProps?.paddingBottom ?? 0,
+          },
+          {
+            label: "左边距",
+            prop: "props.paddingLeft",
+            type: "inputNumber",
+            fieldProps: {
+              min: 0,
+              addonAfter: "px",
+            },
+            defaultValue: containerProps?.paddingLeft ?? 0,
+          },
+          {
+            label: "旋转",
+            type: 'divider',
+            prop: "",
+          },
+          {
+            label: "X轴旋转",
+            prop: "props.rotateX",
+            type: "inputNumber",
+            fieldProps: {
+              min: 0,
+              addonAfter: "deg",
+            },
+            defaultValue: containerProps?.rotateX ?? 0,
+          },
+          {
+            label: "Y轴旋转",
+            prop: "props.rotateY",
+            type: "inputNumber",
+            fieldProps: {
+              min: 0,
+              addonAfter: "deg",
+            },
+            defaultValue: containerProps?.rotateY ?? 0,
+          },
+          {
+            label: "Z轴旋转",
+            prop: "props.rotateZ",
+            type: "inputNumber",
+            fieldProps: {
+              min: 0,
+              addonAfter: "deg",
+            },
+            defaultValue: containerProps?.rotateZ ?? 0,
+          },
+          {
+            label: "整体",
+            type: 'divider',
+            prop: "",
+          },
+          {
+            label: "不透明度",
+            prop: "props.opacity",
+            type: "slider",
+            defaultValue: containerProps?.opacity ?? 100,
+          },
+        ],
+      },
+    ];
+  });
+
+  return {
+    formItems
+  }
+}; 

+ 249 - 0
apps/shalu-bigscreen-designer/src/views/designer/index.vue

@@ -0,0 +1,249 @@
+<template>
+  <Layout style="height: 100vh" :key="actionStore.appKey">
+    <LayoutHeader style="background: #fff">
+      <div class="header-left">
+        <h1>{{ projectStore.projectInfo.name || "大屏标题" }}</h1>
+      </div>
+      <div class="header-middle">
+        <MenuBar />
+      </div>
+      <div class="header-right">
+        <Upload :before-upload="handleUpload" :show-upload-list="false" accept=".json">
+          <Button size="small" style="margin-right: 8px"
+            ><ImportOutlined />导入json</Button
+          >
+        </Upload>
+        <Button size="small" style="margin-right: 8px" @click="handleJson"
+          ><ExportOutlined />导出json</Button
+        >
+        <Button size="small" style="margin-right: 8px" @click="handlePreview"
+          ><DesktopOutlined />预览</Button
+        >
+        <Button
+          size="small"
+          type="primary"
+          @click="handleSave"
+          :loading="loading"
+          ><SaveOutlined />保存</Button
+        >
+      </div>
+    </LayoutHeader>
+
+    <LayoutContent>
+      <div
+        class="layer-wrapper"
+        :style="{ width: stageStore.showLayer ? '200px' : '0px' }"
+      >
+        <!-- 图层管理 -->
+        <LayerManagement v-show="stageStore.showLayer" />
+      </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 { ref, watch } from "vue";
+import {
+  Layout,
+  LayoutHeader,
+  LayoutContent,
+  Button,
+  message,
+  Upload,
+} from "ant-design-vue";
+import { DesktopOutlined, SaveOutlined, ImportOutlined, ExportOutlined } from "@ant-design/icons-vue";
+import { useProjectStore } from "@/store/modules/project";
+import { useStageStore } from "@/store/modules/stage";
+import { useAcionStore } from "@/store/modules/action";
+import { useRoute } from "vue-router";
+import { useRequest } from "vue-hooks-plus";
+import { useAppStore } from "@/store/modules/app";
+import { getPageDesignApi } from "@/api";
+
+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 route = useRoute();
+
+const stageStore = useStageStore();
+const projectStore = useProjectStore();
+const appStore = useAppStore();
+const loading = ref(false);
+const actionStore = useAcionStore();
+
+const { run, loading: loadingPage } = useRequest(getPageDesignApi, {
+  manual: true,
+  onSuccess: (res) => {
+    const { appPageId, pcJson } = res;
+    if(pcJson) {
+      projectStore.setProjectInfo(JSON.parse(pcJson));
+      return;
+    }
+    if(appPageId) {
+      projectStore.updateProjectInfo({ pageId: appPageId });
+    }
+    actionStore.initRecord();
+  },
+  onError: (e) => {
+    console.error(e);
+    message.error("获取页面信息失败");
+  },
+});
+
+/* 获取项目信息 */
+projectStore.getCurrentProjectInfo();
+// 传入pageId和token,获取页面信息
+if(route.query?.pageId && route.query?.token) {
+  const { token, pageId } = route.query;
+  localStorage.setItem("token", token as string);
+  run({id: pageId as string});
+} else {
+  actionStore.initRecord();
+}
+
+watch(
+  () => loadingPage.value,
+  () => {
+    appStore.setPageLoading(loadingPage.value);
+  },
+)
+
+watch(
+  () => projectStore.projectInfo.name,
+  (val) => {
+
+    document.title = val || "大屏设计";
+  },
+  {
+    immediate: true
+  }
+)
+
+const handlePreview = () => {
+  localStorage.setItem(
+    "currentProject",
+    JSON.stringify(projectStore.projectInfo)
+  );
+  window.open("#/view?id=1");
+};
+
+const handleSave = async () => {
+  try {
+    loading.value = true;
+    localStorage.setItem(
+      "currentProject",
+      JSON.stringify(projectStore.projectInfo)
+    );
+
+    await projectStore.handleSaveProject();
+  } catch (e) {
+    loading.value = false;
+    message.error("保存失败");
+  } finally {
+    loading.value = false;
+  }
+};
+
+const handleJson = () => {
+  const json = JSON.stringify(projectStore.projectInfo);
+  const blob = new Blob([json], { type: "application/json" });
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement("a");
+  a.href = url;
+  a.download = projectStore.projectInfo.name + ".json";
+  a.click();
+  URL.revokeObjectURL(url);
+};
+
+const handleUpload = (file: File) => {
+  appStore.setPageLoading(true);
+  const reader = new FileReader();
+  reader.onload = (e) => {
+    const result = e.target?.result;
+    if (typeof result === "string") {
+      try {
+        const json = JSON.parse(result);
+        projectStore.setProjectInfo(json);
+        message.success("导入成功");
+      } catch (e) {
+        message.error("文件格式错误");
+      } finally {
+        appStore.setPageLoading(false);
+      }
+    }
+  };
+  reader.readAsText(file);
+  reader.onerror = () => {
+    appStore.setPageLoading(false);
+    message.error("文件读取失败");
+  };
+  return false;
+};
+</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: 500px;
+    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;
+  background: #fff;
+  border-left: solid 1px #eee;
+}
+</style>

+ 145 - 0
apps/shalu-bigscreen-designer/src/views/home/component/AddModal.vue

@@ -0,0 +1,145 @@
+<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", "update:open"]);
+
+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("新增成功");
+  emit("update:open", false);
+};
+</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
apps/shalu-bigscreen-designer/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
apps/shalu-bigscreen-designer/src/views/home/component/DataSourceManagement.vue

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

+ 49 - 0
apps/shalu-bigscreen-designer/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
apps/shalu-bigscreen-designer/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>

+ 31 - 0
apps/shalu-bigscreen-designer/src/views/view/component/RenderComponent.vue

@@ -0,0 +1,31 @@
+<template>
+  <div :style="containerStyle">
+    <Container v-bind="element.container">
+      <component :is="component" v-bind="element.props" :width="element.container.props.width" :height="element.container.props.height"/>
+    </Container>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { CustomElement } from '#/project';
+import { defineProps, ref, defineAsyncComponent } from 'vue';
+import { asyncComponentAll } from 'shalu-dashboard-ui';
+import Container from '@/components/Container/index.vue';
+
+const props = defineProps<{
+  element: CustomElement;
+}>();
+const component = defineAsyncComponent(asyncComponentAll[props.element.componentType]);
+const containerStyle = ref({
+  width: `${props.element.container.props.width}px`,
+  height: `${props.element.container.props.height}px`,
+  position: 'absolute',
+  left: `${props.element.container.props.x}px`,
+  top: `${props.element.container.props.y}px`,
+  zIndex: props.element.zIndex,
+});
+</script>
+
+<style scoped>
+
+</style>

+ 115 - 0
apps/shalu-bigscreen-designer/src/views/view/index.vue

@@ -0,0 +1,115 @@
+<template>
+  <div class="player-page" :style="bodyStyle">
+    <div class="page-wrapper" :style="pageWapperStyle">
+      <RenderComponent v-for="element in getElements" :key="element.key" :element="element"/>
+      <Result
+        v-if="!currentPage"
+        status="warning"
+        title="很抱歉!当前项目未知错误."
+        subTitle="请联系管理员"
+      >
+        <template #extra>
+          <Button key="console" type="primary" @click="$router.replace('/')">返回首页</Button>
+        </template>
+      </Result>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { CustomElement, ProjectInfo } from '#/project';
+import { ref, onMounted, onBeforeUnmount, StyleValue, computed } from "vue";
+import { Result, Button } from "ant-design-vue";
+import RenderComponent from "./component/RenderComponent.vue";
+import { ScreenFillEnum } from "@/enum/screenFillEnum";
+
+const projectInfo = JSON.parse(localStorage.getItem("currentProject") || "{}") as ProjectInfo;
+document.title = `${projectInfo?.name || '沙鲁大屏项目'}--沙鲁低码平台`;
+const currentPage = ref(projectInfo.pages?.[0]);
+const pageWapperStyle = ref<StyleValue>();
+const bodyStyle = ref<StyleValue>();
+
+const getElements = computed(() => {
+  const list: CustomElement[] = [];
+  (currentPage.value?.elements || []).forEach((item) => {
+    if(item.componentType === 'group') {
+      item.children?.forEach((child) => {
+        child.container.props.x += item.container.props.x;
+        child.container.props.y += item.container.props.y;
+        list.push(child);
+      });
+    } else {
+      list.push(item);
+    }
+  });
+
+  return list;
+});
+
+// 页面样式
+const getWapperStyle = () => {
+  if (!currentPage.value) return;
+
+  const { background } = currentPage.value;
+  const pageBackground =
+    background.type === "color"
+      ? { background: background.color }
+      : {
+          background: `url(${background.image}) no-repeat center center`,
+          backgroundSize: background.fillType,
+        };
+  const { width = 1280, height = 720 } = projectInfo;
+  const { clientWidth, clientHeight } = document.documentElement;
+
+  let scale: string | number = 1;
+  switch (projectInfo.fillType) {
+    case ScreenFillEnum.FILL_HEIGHT:
+      scale = clientHeight / height;
+      break;
+    case ScreenFillEnum.FILL_WIDTH:
+      scale = clientWidth / width;
+      break;
+    case ScreenFillEnum.FILL_BOTH:
+      const scaleX = clientWidth / width;
+      const scaleY = clientHeight / height;
+      scale = `${scaleX},${scaleY}`;
+      break;
+    default:
+      scale = Math.min(clientWidth / width, clientHeight / height);
+  }
+
+  bodyStyle.value = {
+    background: '#000',
+  } as unknown as StyleValue;
+  pageWapperStyle.value = {
+    position: 'absolute',
+    left: '50%',
+    top: '50%',
+    width: `${width}px`,
+    height: `${height}px`,
+    overflow: "hidden",
+    // border: "1px solid #f0f0f0",
+    boxShadow: "0 0 10px rgba(0, 0, 0, 0.1)",
+    transform: `scale(${scale}) translate(-50%, -50%)`,
+    transformOrigin: "0 0",
+    ...pageBackground,
+  } as unknown as StyleValue;
+};
+
+onMounted(() => {
+  getWapperStyle();
+  window.addEventListener("resize", getWapperStyle);
+});
+onBeforeUnmount(() => {
+  window.removeEventListener("resize", getWapperStyle);
+});
+</script>
+
+<style lang="less" scoped>
+.player-page {
+  height: 100%;
+  width: 100%;
+  position: fixed;
+  overflow: hidden;
+}
+</style>

+ 7 - 0
apps/shalu-bigscreen-designer/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
apps/shalu-bigscreen-designer/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
apps/shalu-bigscreen-designer/tsconfig.node.json

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

+ 25 - 0
apps/shalu-bigscreen-designer/types/echart.d.ts

@@ -0,0 +1,25 @@
+import { DataSourceType } from "@/enum/index";
+
+export interface ChartData {
+  xData: string[];
+  yData: number[] | string[];
+  series: any[];
+}
+// chart数据源
+export interface DataSource {
+  // 类型
+  sourceType: DataSourceType.STATIC,
+  // 数据
+  data?: ChartData,
+  // 接口相关
+  url?: string,
+  // 请求方式
+  method?: 'GET' | 'POST',
+  // 请求参数
+  params?: any,
+  // 请求头
+  headers?: Record<string, any>,
+  refreshTime?: number,
+  // 数据处理
+  dataProcess: () => any[],
+}

+ 6 - 0
apps/shalu-bigscreen-designer/types/index.d.ts

@@ -0,0 +1,6 @@
+declare interface Fn<T = any, R = T> {
+  (...arg: T[]): R;
+}
+
+declare module 'lodash';
+declare module 'js-beautify';

+ 1 - 0
apps/shalu-bigscreen-designer/types/module.d.ts

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

+ 90 - 0
apps/shalu-bigscreen-designer/types/project.d.ts

@@ -0,0 +1,90 @@
+declare interface BackgroundOptions {
+  // 背景类型
+  type: 'color' | 'image' | 'none';
+  // 背景颜色
+  color?: string;
+  // 背景图片
+  image?: string;
+  // 背景图片填充方式
+  fillType?: "cover" | "contain" | "fill" | "";
+}
+
+declare interface CustomElement {
+  // 元素唯一标识
+  key: string;
+  // 元素名称
+  name: string;
+  // 组件类型
+  componentType: string | 'group';
+  // 元素层级
+  zIndex: number;
+  // 是否可见
+  visible: boolean;
+  // 是否锁定
+  locked: boolean;
+  // 容器样式 -- 包含样式,组件属性
+  container: {
+    // 元素样式
+    style: Record<string, any>;
+    // 元素属性
+    props: Record<string, any>;
+  };
+  // 元素交互
+  events: Record<string, any>;
+  // 元素动画
+  animations: Record<string, any>;
+  // 组件内容 -- 数据源, 数据样式等
+  props: Record<string, any>;
+  // 子元素
+  children?: CustomElement[];
+  // group 折叠
+  collapsed?: boolean;
+  // 父级key
+  parentKey?: string;
+}
+
+declare export interface ReferLine {
+  // 辅助线唯一标识
+  key: number;
+  // 辅助线类型
+  type: 'horizontal' | 'vertical' | null;
+  // 辅助线位置
+  value: number;
+  // x坐标
+  x?: number;
+  // y坐标
+  y?: number;
+}
+
+declare interface Page {
+  // 页面id
+  key: string;
+  // 页面名称
+  name: string;
+  // 页面背景
+  background: BackgroundOptions;
+  // 页面元素
+  elements: CustomElement[];
+  // 辅助线
+  referLines: ReferLine[];
+}
+
+// 项目基本信息
+declare export interface ProjectInfo {
+  // 页面id
+  pageId: string;
+  // 项目名称
+  name: string;
+  // 项目描述
+  description: string;
+  // 尺寸类型
+  sizeType: string;
+  // 屏幕宽度
+  width: number;
+  // 屏幕高度
+  height: number;
+  // 填充方式
+  fillType?: ScreenFillEnum;
+  // 页面内容
+  pages: Page[];
+}

+ 35 - 0
apps/shalu-bigscreen-designer/vite.config.ts

@@ -0,0 +1,35 @@
+import { defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue";
+import path from "path";
+import ElementPlus from "unplugin-element-plus/vite";
+import { viteMockServe } from "vite-plugin-mock";
+import vueJsx from '@vitejs/plugin-vue-jsx';
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  base: './',
+  plugins: [
+    vue(),
+    ElementPlus({}),
+    viteMockServe({
+      mockPath: "./src/mock",
+      enable: true,
+      watchFiles: true
+    }),
+    vueJsx(),
+  ],
+  resolve: {
+    alias: {
+      "@": path.resolve(__dirname, "src"),
+      "~@": path.resolve(__dirname, "src"),
+      "#": path.resolve(__dirname, "types"),
+    },
+  },
+  css: {
+    preprocessorOptions: {
+      less: {
+        additionalData: `@import "~@/style/var.less";`,
+      },
+    },
+  },
+});

+ 22 - 0
package.json

@@ -0,0 +1,22 @@
+{
+  "name": "shalu-bigscreen-monorepo",
+  "version": "1.0.0",
+  "privite": true,
+  "scripts": {
+    "dev": "pnpm -C apps/shalu-bigscreen-designer run dev",
+    "build:ui": "pnpm -C packages/shalu-dashboard-ui run build"
+  },
+  "keywords": [],
+  "author": "",
+  "license": "ISC",
+  "devDependencies": {
+    "@types/gulp": "^4.0.17",
+    "gulp": "^5.0.0",
+    "sucrase": "^3.35.0",
+    "typescript": "^5.5.3",
+    "vue": "^3.4.31"
+  },
+  "dependencies": {
+    "@shalu/dashboard-ui": "workspace:^"
+  }
+}

+ 24 - 0
packages/shalu-dashboard-ui/.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?

+ 12 - 0
packages/shalu-dashboard-ui/components/charts/Bar/BasicBar/index.ts

@@ -0,0 +1,12 @@
+import BasicBar from './src/BasicBar.vue';
+import Config from './src/Config.vue';
+
+BasicBar.Config = Config;
+BasicBar.install = (app: any) => {
+  app.component('FmBasicBar', BasicBar);
+  return app;
+};
+
+export default BasicBar;
+export { Config };
+export { defaultPropsValue, basicBarProps } from './src/props';

+ 17 - 0
packages/shalu-dashboard-ui/components/charts/Bar/BasicBar/src/BasicBar.vue

@@ -0,0 +1,17 @@
+<template>
+  <Charts :width="width" :height="height" :echarts-options="options" :loading="loading"></Charts>
+</template>
+
+<script setup lang="ts" name="fmDashboardBasicBar">
+import { defineProps } from 'vue';
+import Charts from '../../../Charts.vue';
+import { basicBarProps } from "./props";
+import { useChartOptions } from "../../../hooks/useChartOptions";
+
+const props = defineProps(basicBarProps);
+
+const { options, loading } = useChartOptions(props);
+
+</script>
+
+<style scoped></style>

+ 2 - 0
packages/shalu-dashboard-ui/components/charts/Bar/BasicBar/src/BasicBar.vue.d.ts

@@ -0,0 +1,2 @@
+declare const _default: any;
+export default _default;

+ 182 - 0
packages/shalu-dashboard-ui/components/charts/Bar/BasicBar/src/Config.vue

@@ -0,0 +1,182 @@
+<template>
+  <div class="chart-config">
+    <div class="config-tab">
+      <Tabs v-model:activeKey="activeTab" size="small" centered>
+        <TabPane key="1">
+          <template #tab>
+            <DatabaseOutlined />
+            <span>数据设置</span>
+          </template>
+        </TabPane>
+        <TabPane key="2">
+          <template #tab>
+            <SkinOutlined />
+            <span>样式设置</span>
+          </template>
+        </TabPane>
+      </Tabs>
+    </div>
+
+    <DataConfig
+      v-if="activeTab === '1'"
+      :dataSource="dataSource"
+      @change="handleDataSourceChange"
+    />
+    <CusForm
+      v-if="activeTab === '2'"
+      :columns="formItems"
+      :formModel="props"
+      @change="handleFormChange"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, defineProps, defineEmits } from "vue";
+import { Tabs, TabPane } from "ant-design-vue";
+import { DatabaseOutlined, SkinOutlined } from "@ant-design/icons-vue";
+import DataConfig from "../../../DataConfig.vue";
+import { CusForm, IFormItem } from "../../../../cusForm";
+import { basicBarProps } from "./props";
+import { chartFormItemsMap } from "../../../config/chartFormItemsMap";
+import { set, cloneDeep } from "lodash-es";
+
+const props = defineProps(basicBarProps);
+const activeTab = ref("1");
+const emit = defineEmits(["change"]);
+
+const baseSeries: IFormItem[] = [
+  {
+    label: "样式",
+    prop: "",
+    type: "divider",
+  },
+  {
+    label: "固定柱宽",
+    prop: "series.bar.fixedBarWidth",
+    type: "radioGroup",
+    fieldProps: {
+      options: [
+        { label: "是", value: true },
+        { label: "否", value: false },
+      ],
+    },
+    defaultValue: false,
+    format: (formatModel, value) => {
+      formatModel.value["series.bar.barWidth"] =
+        value && formatModel.value?.["series.bar.barWidth"] !== "auto"
+          ? formatModel.value?.["series.bar.barWidth"] || 20
+          : "auto";
+    },
+  },
+  {
+    label: "",
+    prop: "",
+    type: "dependency",
+    name: ["series.bar.fixedBarWidth"],
+    children: (model) => {
+      return model["series.bar.fixedBarWidth"]
+        ? [
+            {
+              label: "柱宽",
+              prop: "series.bar.barWidth",
+              type: "inputNumber",
+              fieldProps: {
+                addonAfter: "px",
+              },
+              defaultValue: 20,
+            },
+          ]
+        : [];
+    },
+  },
+  {
+    label: "系列间隔",
+    prop: "series.bar.barGap",
+    type: "slider",
+    defaultValue: 30,
+    format: (formatFormatModel, value) => {
+      formatFormatModel.value['series.bar.barGap'] = value + "%";
+    },
+    valueToForm: (value) => {
+      return +(value?.replace("%", "") || 0);
+    },
+  },
+  {
+    label: "分类间隔",
+    prop: "series.bar.barCategoryGap",
+    type: "slider",
+    defaultValue: 20,
+    format: (formatFormatModel, value) => {
+      formatFormatModel.value['series.bar.barCategoryGap'] = value + "%";
+    },
+    valueToForm: (value) => {
+      return +(value?.replace("%", "") || 0);
+    },
+  },
+  {
+    label: "边框",
+    prop: "",
+    type: "divider",
+  },
+  {
+    label: "线宽",
+    prop: "series.bar.itemStyle.borderWidth",
+    type: "inputNumber",
+    fieldProps: {
+      addonAfter: "px",
+    },
+    defaultValue: 0,
+  },
+  {
+    label: "颜色",
+    prop: "series.bar.itemStyle.borderColor",
+    type: "colorSelect",
+    defaultValue: "#ccc",
+  },
+  {
+    label: "圆角",
+    prop: "series.bar.itemStyle.borderRadius",
+    type: "inputNumber",
+    fieldProps: {
+      addonAfter: "px",
+    },
+    defaultValue: 0,
+  },
+];
+const formItems: IFormItem[] = [
+  chartFormItemsMap.title,
+  chartFormItemsMap.legend,
+  chartFormItemsMap.label,
+  {
+    ...chartFormItemsMap.series,
+    children: (chartFormItemsMap.series.children as IFormItem[]).concat(
+      baseSeries
+    ),
+  },
+  chartFormItemsMap.xAxis,
+  chartFormItemsMap.yAxis,
+  chartFormItemsMap.tooltip,
+];
+
+const handleDataSourceChange = (data: any) => {
+  emit("change", {
+    ...props,
+    dataSource: data,
+  });
+};
+const handleFormChange = (formatData: any) => {
+  const obj = cloneDeep(props);
+  Object.keys(formatData).forEach((key) => {
+    set(obj, key, formatData[key]);
+  });
+  emit("change", obj);
+};
+</script>
+
+<style lang="less" scoped>
+.config-tab {
+  text-align: center;
+  margin-bottom: 12px;
+}
+</style>

+ 43 - 0
packages/shalu-dashboard-ui/components/charts/Bar/BasicBar/src/props.d.ts

@@ -0,0 +1,43 @@
+import { PropType, ExtractPropTypes } from 'vue';
+import { EChartsOption } from 'echarts';
+
+export declare const basicBarProps: {
+    width: {
+        type: PropType<number>;
+        default: number;
+    };
+    height: {
+        type: PropType<number>;
+        default: number;
+    };
+    dataSource: any;
+    title: {
+        type: PropType<EChartsOption["title"]>;
+    };
+    legend: {
+        type: PropType<EChartsOption["legend"]>;
+    };
+    backgroundColor: {
+        type: PropType<string>;
+    };
+    grid: {
+        type: PropType<EChartsOption["grid"]>;
+    };
+    tooltip: {
+        type: PropType<EChartsOption["tooltip"]>;
+    };
+    xAxis: {
+        type: PropType<EChartsOption["xAxis"]>;
+    };
+    yAxis: {
+        type: PropType<EChartsOption["yAxis"]>;
+    };
+    series: {
+        type: PropType<EChartsOption["series"]>;
+    };
+    color: {
+        type: PropType<EChartsOption["color"]>;
+    };
+};
+export declare const defaultPropsValue: EChartsOption;
+export type BasicBarProps = ExtractPropTypes<typeof basicBarProps>;

+ 133 - 0
packages/shalu-dashboard-ui/components/charts/Bar/BasicBar/src/props.ts

@@ -0,0 +1,133 @@
+import type { PropType, ExtractPropTypes } from "vue";
+import { EChartsOption } from "echarts";
+import { getNormalizedChart, dataSource } from "../../../utils";
+import { DataSourceType } from "../../../chartEnum";
+
+export const basicBarProps = {
+  width: {
+    type: Number as PropType<number>,
+    default: 400,
+  },
+  height: {
+    type: Number as PropType<number>,
+    default: 260,
+  },
+  dataSource,
+  // 标题
+  title: {
+    type: Object as PropType<EChartsOption["title"]>,
+  },
+  // 图例
+  legend: {
+    type: Object as PropType<EChartsOption["legend"]>,
+  },
+  // 背景
+  backgroundColor: {
+    type: String as PropType<string>,
+  },
+  // 边框
+  grid: {
+    type: Object as PropType<EChartsOption["grid"]>,
+  },
+  // 提示框
+  tooltip: {
+    type: Object as PropType<EChartsOption["tooltip"]>,
+  },
+  // x轴数据
+  xAxis: {
+    type: Object as PropType<EChartsOption["xAxis"]>,
+  },
+  // y轴数据
+  yAxis: {
+    type: Object as PropType<EChartsOption["yAxis"]>,
+  },
+  // 折线
+  series: {
+    type: Array as PropType<EChartsOption["series"]>,
+  },
+  // color
+  color: {
+    type: Object as PropType<EChartsOption["color"]>
+  }
+};
+
+/* 系列相关 */
+const series: EChartsOption['series'] = [];
+series['bar' as unknown as number] = {
+  // @ts-ignore
+  fixedBarWidth: false,
+  barWidth: 'auto',
+  barGap: '30%',
+  barCategoryGap: '20%',
+  itemStyle: {
+    borderColor: '#ccc',
+    borderRadius: 0,
+    borderWidth: 0,
+  }
+};
+
+const chartOptions = getNormalizedChart({
+  title: {
+    text: "柱状图标题",
+  },
+  xAxis: {
+    data: ['轴标签A', '轴标签B', '轴标签C', '轴标签D']
+  },
+  series
+})
+
+export const defaultPropsValue: EChartsOption = {
+  // 组件容器默认属性
+  container: {
+    props: {
+      width: 400,
+      height: 260,
+    },
+  },
+  // 图表默认属性
+  props: {
+    // 数据源
+    dataSource: {
+      sourceType: DataSourceType.STATIC,
+      data: {
+        xData: ['轴标签A', '轴标签B', '轴标签C', '轴标签D'],
+        series: [
+          {
+            type: 'bar',
+            name: '系列1',
+            data: [89.3, 92.1, 94.4, 85.4]
+          },
+          {
+            type: 'bar',
+            name: '系列2',
+            data: [95.8, 89.4, 91.2, 76.9]
+          },
+        ]
+      },
+      url: location.origin + "/mock/api/get/example/bar",
+      method: "POST",
+      params: {},
+      headers: {},
+      refreshTime: 0,
+      dataProcess: `
+        (res) => {
+          // 取出列表
+          const data = res.data;
+          // x轴数据
+          const xData = data.map((item) => item.name); 
+          // 系列数据
+          const series = [
+            { type: 'bar', name: '价格', data: data.map(item => item.price) },
+            { type: 'bar', name: '总量', data: data.map(item => item.count) },
+          ];
+
+          // 返回图表数据
+          return { xData, series };
+        }
+      `
+    },
+    ...chartOptions
+  },
+};
+
+export type BasicBarProps = ExtractPropTypes<typeof basicBarProps>;

+ 53 - 0
packages/shalu-dashboard-ui/components/charts/Charts.vue

@@ -0,0 +1,53 @@
+<!-- charts基础组件 -->
+<template>
+  <Spin :spinning="loading" :indicator="indicator">
+    <div ref="chartRef" :style="{width: width + 'px', height: height + 'px'}"></div>
+  </Spin>
+</template>
+
+<script setup lang="ts">
+import { ref, Ref, defineProps, watch, nextTick, h } from "vue";
+import { Spin } from "ant-design-vue";
+import { LoadingOutlined } from "@ant-design/icons-vue";
+import { useEcharts } from "./hooks/useEcharts";
+import type { EChartsOption } from "echarts";
+import { throttle } from "lodash-es";
+
+const props = defineProps<{
+  echartsOptions: EChartsOption;
+  width: number;
+  height: number;
+  loading?: boolean;
+}>();
+const chartRef: Ref<null | HTMLDivElement> = ref(null);
+const { setOptions, resize } = useEcharts(chartRef as Ref<HTMLDivElement>);
+
+const indicator = h(LoadingOutlined, {
+  style: {
+    fontSize: "24px",
+  },
+});
+
+watch(
+  () => [props.width, props.height],
+  throttle(async () => {
+    resize();
+  }, 200)
+);
+
+watch(
+  () => props,
+  async () => {
+    await nextTick();
+    const { echartsOptions } = props;
+
+    setOptions(echartsOptions);
+  },
+  {
+    immediate: true,
+    deep: true,
+  }
+);
+</script>
+
+<style scoped></style>

+ 2 - 0
packages/shalu-dashboard-ui/components/charts/Charts.vue.d.ts

@@ -0,0 +1,2 @@
+declare const _default: any;
+export default _default;

+ 161 - 0
packages/shalu-dashboard-ui/components/charts/DataConfig.vue

@@ -0,0 +1,161 @@
+<template>
+  <Form
+    size="small"
+    layout="horizontal"
+    :model="formModel"
+    :label-col="{ span: 8 }"
+    :wrapper-col="{ span: 16 }"
+  >
+    <Form.Item label="类型" name="sourceType">
+      <Select v-model:value="formModel.sourceType">
+        <SelectOption :value="DataSourceType.STATIC">静态数据</SelectOption>
+        <SelectOption :value="DataSourceType.API">动态数据</SelectOption>
+      </Select>
+    </Form.Item>
+    <!-- 静态数据 -->
+    <template v-if="formModel.sourceType === DataSourceType.STATIC">
+      <Form.Item label="数据" name="data">
+        <Button type="default" size="small" @click="handleEditData('data')"
+          >编辑</Button
+        >
+      </Form.Item>
+    </template>
+    <!-- 接口 -->
+    <template v-else-if="formModel.sourceType === DataSourceType.API">
+      <Form.Item label="接口地址" name="url">
+        <Input.TextArea
+          :auto-size="{ minRows: 5 }"
+          placeholder="请输入接口地址"
+          v-model:value="formModel.url"
+        ></Input.TextArea>
+      </Form.Item>
+      <Form.Item label="请求方式" name="method">
+        <RadioGroup v-model:value="formModel.method">
+          <Radio value="GET">GET</Radio>
+          <Radio value="POST">POST</Radio>
+        </RadioGroup>
+      </Form.Item>
+      <Form.Item label="刷新时间" name="refreshTime">
+        <InputNumber
+          v-model:value="formModel.refreshTime"
+          :step="1"
+          style="width: 100%"
+          @change="handleRefreshTimeChange"
+        >
+          <template #addonAfter>
+            <span class="text-gray-500">秒</span>
+          </template>
+        </InputNumber>
+      </Form.Item>
+      <Form.Item label="请求参数" name="params">
+        <Button type="default" size="small" @click="handleEditData('params')"
+          >编辑</Button
+        >
+      </Form.Item>
+      <Form.Item label="请求头" name="headers">
+        <Button type="default" size="small" @click="handleEditData('headers')"
+          >编辑</Button
+        >
+      </Form.Item>
+      <Form.Item label="数据处理" name="dataProcess">
+        <Button
+          type="default"
+          size="small"
+          @click="handleEditData('dataProcess')"
+          >编辑</Button
+        >
+      </Form.Item>
+    </template>
+  </Form>
+  <CodeEditorModal ref="codeEditorRef" title="编辑数据" @ok="handleCodeSave" />
+</template>
+
+<script setup lang="ts">
+import type { DataSource } from "./types";
+import type { Ref } from "vue";
+import { ref, defineProps, watch, defineEmits } from "vue";
+import {
+  Form,
+  Input,
+  Button,
+  InputNumber,
+  Select,
+  SelectOption,
+  RadioGroup,
+  Radio
+} from "ant-design-vue";
+import { DataSourceType } from "./chartEnum";
+import {
+  CodeEditorModal,
+  type CodeEditorModalInstance,
+} from "../codeEditor";
+
+/**
+ * 通用数据data约定内容结构
+ * {
+ *  xData: ['x轴标签A', 'x轴标签B', 'x轴标签C', 'x轴标签D'],
+ *  yData: ['y轴数据A', 'y轴数据B', 'y轴数据C', 'y轴数据D'],
+ *  // 根据不同类型的图表配置不同的series
+ *  series: [
+ *    {
+ *      name: '系列A',
+ *      data: [10, 20, 30, 40]
+ *    },
+ *    {
+ *      name: '系列B',
+ *      data: [10, 20, 30, 40]
+ *    }
+ *  ]
+ * }
+ */
+const emit = defineEmits(["change"]);
+const props = defineProps<{
+  dataSource: DataSource;
+}>();
+const formModel = ref({
+  sourceType: DataSourceType.STATIC,
+  // 静态数据相关
+  data: "",
+  // 接口相关
+  url: '',
+  method: "",
+  params: {},
+  headers: {},
+  refreshTime: 0,
+  // 数据处理
+  dataProcess: "",
+});
+
+const handleRefreshTimeChange = (val: unknown) => {
+  formModel.value.refreshTime = val === 0 || val as number >= 60 ? val as number : 60;
+};
+
+/* =====================编辑代码======================= */
+let pathKey: "data" | "params" | "headers" | "dataProcess";
+const codeEditorRef = ref<Ref<CodeEditorModalInstance> | null>(null);
+const handleEditData = (key: "data" | "params" | "headers" | "dataProcess") => {
+  pathKey = key;
+  codeEditorRef.value?.open(JSON.stringify(formModel.value[key]));
+};
+const handleCodeSave = (code: string) => {
+  formModel.value[pathKey] = JSON.parse(code);
+};
+
+watch(
+  () => props.dataSource,
+  (val) => {
+    Object.assign(formModel.value, val || {});
+  },
+  { immediate: true }
+);
+
+watch(
+  () => formModel.value,
+  (val) => {
+    emit("change", val);
+  },
+  { deep: true }
+);
+</script>
+
+<style scoped></style>

+ 11 - 0
packages/shalu-dashboard-ui/components/charts/Line/BasicLine/index.ts

@@ -0,0 +1,11 @@
+import BasicLine from './src/BasicLine.vue';
+import Config from './src/Config.vue';
+
+BasicLine.Config = Config;
+BasicLine.install = (app: any) => {
+  app.component('FmBasicLine', BasicLine);
+  return app;
+};
+export default BasicLine;
+export { Config };
+export { defaultPropsValue, basicLineProps } from './src/props';

+ 17 - 0
packages/shalu-dashboard-ui/components/charts/Line/BasicLine/src/BasicLine.vue

@@ -0,0 +1,17 @@
+<template>
+  <Charts :width="width" :height="height" :echarts-options="options" :loading="loading"></Charts>
+</template>
+
+<script setup lang="ts">
+import { defineProps } from "vue";
+import Charts from '../../../Charts.vue';
+import { basicLineProps } from "./props";
+import { useChartOptions } from "../../../hooks/useChartOptions";
+
+const props = defineProps(basicLineProps);
+
+const { options, loading } = useChartOptions(props);
+
+</script>
+
+<style scoped></style>

+ 183 - 0
packages/shalu-dashboard-ui/components/charts/Line/BasicLine/src/Config.vue

@@ -0,0 +1,183 @@
+<template>
+  <div class="chart-config">
+    <div class="config-tab">
+      <Tabs v-model:activeKey="activeTab" size="small" centered>
+        <TabPane key="1">
+          <template #tab>
+            <DatabaseOutlined />
+            <span>数据设置</span>
+          </template>
+        </TabPane>
+        <TabPane key="2">
+          <template #tab>
+            <SkinOutlined />
+            <span>样式设置</span>
+          </template>
+        </TabPane>
+      </Tabs>
+    </div>
+
+    <DataConfig
+      v-if="activeTab === '1'"
+      :dataSource="dataSource"
+      @change="handleChange"
+    />
+    <CusForm
+      v-if="activeTab === '2'"
+      :columns="formItems"
+      :formModel="props"
+      @change="handleFormChange"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { IFormItem } from "../../../../cusForm";
+import { ref, defineProps, defineEmits } from "vue";
+import { Tabs, TabPane } from "ant-design-vue";
+import { DatabaseOutlined, SkinOutlined } from "@ant-design/icons-vue";
+import DataConfig from "../../../DataConfig.vue";
+import { CusForm } from "../../../../cusForm";
+import { basicLineProps } from "./props";
+import { chartFormItemsMap } from "../../../config/chartFormItemsMap";
+import { set, cloneDeep, get } from "lodash-es";
+
+const props = defineProps(basicLineProps);
+const activeTab = ref("1");
+const emit = defineEmits(["change"]);
+
+const baseSeries: IFormItem[] = [
+  {
+    label: "线",
+    prop: "",
+    type: "divider",
+  },
+  {
+    label: "线条样式",
+    prop: "series.line.lineStyle.type",
+    type: "select",
+    fieldProps: {
+      options: [
+        { label: "实线", value: "solid" },
+        { label: "虚线", value: "dashed" },
+        { label: "点线", value: "dotted" },
+      ],
+    },
+  },
+  {
+    label: "线宽",
+    prop: "series.line.lineStyle.width",
+    type: "inputNumber",
+    fieldProps: {
+      min: 0,
+      addonAfter: "px",
+    },
+  },
+  {
+    label: "形态",
+    prop: "series.line.lineType",
+    type: "radioGroupButton",
+    fieldProps: {
+      options: [
+        { label: "普通", value: "normal" },
+        { label: "平滑", value: "smooth" },
+        { label: "阶梯", value: "step" },
+      ],
+    },
+    format: (formatModel, value) => {
+      switch(value) {
+        case 'smooth':
+          formatModel.value["series.line.smooth"] = true;
+          formatModel.value["series.line.step"] = false;
+          break;
+        case 'step':
+          formatModel.value["series.line.smooth"] = false;
+          formatModel.value["series.line.step"] = 'end';
+          break;
+        default:
+          formatModel.value["series.line.smooth"] = false;
+          formatModel.value["series.line.step"] = false;
+      }
+    },
+    valueToForm: (_, model) => {
+      const step = get(model, 'series.line.step');
+      const smooth = get(model, 'series.line.smooth');
+      return step ? 'step' : smooth ? 'smooth' : 'normal';
+    },
+  },
+  {
+    label: "标记点",
+    prop: "",
+    type: "divider",
+  },
+  {
+    label: "图形",
+    prop: "series.line.symbol",
+    type: "select",
+    fieldProps: {
+      options: [
+        { label: "圆", value: "circle" },
+        { label: "方", value: "rect" },
+        { label: "三角", value: "triangle" },
+        { label: "菱形", value: "diamond" },
+        { label: "标记", value: "pin" },
+        { label: "箭头", value: "arrow" },
+        { label: "无", value: "none" },
+      ],
+    },
+  },
+  {
+    label: "大小",
+    prop: "series.line.symbolSize",
+    type: "inputNumber",
+    fieldProps: {
+      min: 0,
+      addonAfter: "px",
+    },
+  },
+  {
+    label: "旋转",
+    prop: "series.line.symbolRotate",
+    type: "inputNumber",
+    fieldProps: {
+      min: 0,
+      addonAfter: "°",
+    },
+  }
+];
+
+const formItems = [
+  chartFormItemsMap.title,
+  chartFormItemsMap.legend,
+  chartFormItemsMap.label,
+  {
+    ...chartFormItemsMap.series,
+    children: (chartFormItemsMap.series.children as IFormItem[]).concat(
+      baseSeries
+    ),
+  },
+  chartFormItemsMap.tooltip,
+  chartFormItemsMap.background,
+];
+
+const handleChange = (data: any) => {
+  emit("change", {
+    ...props,
+    dataSource: data,
+  });
+};
+const handleFormChange = (formatData: any) => {
+  const obj = cloneDeep(props);
+  Object.keys(formatData).forEach((key) => {
+    set(obj, key, formatData[key]);
+  });
+  emit("change", obj);
+};
+</script>
+
+<style lang="less" scoped>
+.config-tab {
+  text-align: center;
+  margin-bottom: 12px;
+}
+</style>

+ 132 - 0
packages/shalu-dashboard-ui/components/charts/Line/BasicLine/src/props.ts

@@ -0,0 +1,132 @@
+import type { PropType, ExtractPropTypes } from "vue";
+import { EChartsOption } from "echarts";
+import { getNormalizedChart } from "../../../utils";
+import { dataSource } from "../../../utils";
+import { DataSourceType } from "../../../chartEnum";
+
+export const basicLineProps = {
+  width: {
+    type: Number as PropType<number>,
+    default: 400,
+  },
+  height: {
+    type: Number as PropType<number>,
+    default: 260,
+  },
+  dataSource,
+  // 标题
+  title: {
+    type: Object as PropType<EChartsOption["title"]>,
+  },
+  // 图例
+  legend: {
+    type: Object as PropType<EChartsOption["legend"]>,
+  },
+  // 背景
+  backgroundColor: {
+    type: String as PropType<string>,
+  },
+  // 边框
+  grid: {
+    type: Object as PropType<EChartsOption["grid"]>,
+  },
+  // 提示框
+  tooltip: {
+    type: Object as PropType<EChartsOption["tooltip"]>,
+  },
+  // x轴数据
+  xAxis: {
+    type: Object as PropType<EChartsOption["xAxis"]>,
+  },
+  // y轴数据
+  yAxis: {
+    type: Object as PropType<EChartsOption["yAxis"]>,
+  },
+  // 折线
+  series: {
+    type: Array as PropType<EChartsOption["series"]>,
+  },
+  // 数据集
+  dataset: {
+    type: Object as PropType<EChartsOption["dataset"]>,
+  },
+};
+
+/* 系列相关 */
+const series: EChartsOption['series'] = [];
+series['line' as unknown as number] = {
+  lineStyle: {
+    type: "solid",
+    width: 1
+  },
+  symbol: "rect",
+  symbolSize: 4,
+  symbolRotate: 0,
+  smooth: false,
+  step: false,
+};
+
+const chartOptions = getNormalizedChart({
+  title: {
+    text: "折线图标题",
+  },
+  xAxis: {
+    data: ['轴标签A', '轴标签B', '轴标签C', '轴标签D']
+  },
+  series
+});
+
+export const defaultPropsValue: EChartsOption = {
+  container: {
+    props: {
+      width: 400,
+      height: 260,
+    },
+  },
+  props: {
+    // 数据源
+    dataSource: {
+      sourceType: DataSourceType.STATIC,
+      data: {
+        xData: ['轴标签A', '轴标签B', '轴标签C', '轴标签D'],
+        series: [
+          {
+            type: 'line',
+            name: '系列1',
+            data: [89.3, 92.1, 94.4, 85.4]
+          },
+          {
+            type: 'line',
+            name: '系列2',
+            data: [95.8, 89.4, 91.2, 76.9]
+          },
+        ]
+      },
+      url: location.origin + "/mock/api/get/example/line",
+      method: "POST",
+      params: {},
+      headers: {},
+      refreshTime: 0,
+      dataProcess: `
+        (res) => {
+          // 取出列表
+          const data = res.data;
+          // x轴数据
+          const xData = data.map((item) => item.name); 
+          // 系列数据
+          const series = [
+            { type: 'line', name: '苹果', data: data.map(item => item.apple) },
+            { type: 'line', name: 'VIVO', data: data.map(item => item.vivo) },
+            { type: 'line', name: '小米', data: data.map(item => item.mi) },
+          ];
+
+          // 返回图表数据
+          return { xData, series };
+        }
+      `
+    },
+    ...chartOptions
+  },
+};
+
+export type BasicLineProps = ExtractPropTypes<typeof basicLineProps>;

+ 12 - 0
packages/shalu-dashboard-ui/components/charts/Pie/BasicPie/index.ts

@@ -0,0 +1,12 @@
+import BasicPie from './src/BasicPie.vue';
+import Config from './src/Config.vue';
+
+BasicPie.Config = Config;
+BasicPie.install = (app: any) => {
+  app.component('FmBasicPie', BasicPie);
+  return app;
+};
+
+export default BasicPie;
+export { Config };
+export { defaultPropsValue, basicPieProps } from './src/props';

+ 17 - 0
packages/shalu-dashboard-ui/components/charts/Pie/BasicPie/src/BasicPie.vue

@@ -0,0 +1,17 @@
+<template>
+  <Charts :width="width" :height="height" :echarts-options="options" :loading="loading"></Charts>
+</template>
+
+<script setup lang="ts" name="fmDashboardBasicBar">
+import { defineProps } from 'vue';
+import Charts from '../../../Charts.vue';
+import { basicPieProps } from "./props";
+import { useChartOptions } from "../../../hooks/useChartOptions";
+
+const props = defineProps(basicPieProps);
+
+const { options, loading } = useChartOptions(props);
+
+</script>
+
+<style scoped></style>

+ 2 - 0
packages/shalu-dashboard-ui/components/charts/Pie/BasicPie/src/BasicPie.vue.d.ts

@@ -0,0 +1,2 @@
+declare const _default: any;
+export default _default;

+ 124 - 0
packages/shalu-dashboard-ui/components/charts/Pie/BasicPie/src/Config.vue

@@ -0,0 +1,124 @@
+<template>
+  <div class="chart-config">
+    <div class="config-tab">
+      <Tabs v-model:activeKey="activeTab" size="small" centered>
+        <TabPane key="1">
+          <template #tab>
+            <DatabaseOutlined />
+            <span>数据设置</span>
+          </template>
+        </TabPane>
+        <TabPane key="2">
+          <template #tab>
+            <SkinOutlined />
+            <span>样式设置</span>
+          </template>
+        </TabPane>
+      </Tabs>
+    </div>
+
+    <DataConfig
+      v-if="activeTab === '1'"
+      :dataSource="dataSource"
+      @change="handleDataSourceChange"
+    />
+    <CusForm
+      v-if="activeTab === '2'"
+      :columns="formItems"
+      :formModel="props"
+      @change="handleFormChange"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, defineProps, defineEmits } from "vue";
+import { Tabs, TabPane } from "ant-design-vue";
+import { DatabaseOutlined, SkinOutlined } from "@ant-design/icons-vue";
+import DataConfig from "../../../DataConfig.vue";
+import { CusForm, IFormItem } from "../../../../cusForm";
+import { basicPieProps } from "./props";
+import { chartFormItemsMap } from "../../../config/chartFormItemsMap";
+import { set, cloneDeep } from "lodash-es";
+
+const props = defineProps(basicPieProps);
+const activeTab = ref("1");
+const emit = defineEmits(["change"]);
+
+const baseSeries: IFormItem[] = [
+  {
+    label: "样式",
+    prop: "",
+    type: "divider",
+  },
+  {
+    label: "起始角度",
+    prop: "series.pie.startAngle",
+    type: "inputNumber",
+    fieldProps: {
+      min: 0,
+      max: 360,
+      addonAfter: "°",
+    },
+  },
+  {
+    label: "完结角度",
+    prop: "series.pie.endAngle",
+    type: "inputNumber",
+    fieldProps: {
+      min: 0,
+      max: 360,
+      addonAfter: "°",
+    },
+  },
+  {
+    label: "内径占比",
+    prop: "series.pie.radius",
+    type: "slider",
+    format: (formatModel, value) => {
+      console.log("formatModel", formatModel);
+      const inner = value * 0.75;
+      formatModel.value["series.pie.radius"] = [inner + "%", "75%"];
+      return value * 100;
+    },
+    valueToForm: (value) => {
+      const inner = (value || ["0%", "75%"])[0].replace("%", "");
+      return inner / 0.75;
+    },
+  },
+];
+
+const formItems = [
+  chartFormItemsMap.title,
+  chartFormItemsMap.legend,
+  chartFormItemsMap.label,
+  {
+    ...chartFormItemsMap.series,
+    children: (chartFormItemsMap.series.children as IFormItem[]).concat(
+      baseSeries
+    ),
+  },
+  chartFormItemsMap.tooltip,
+];
+
+const handleDataSourceChange = (data: any) => {
+  emit("change", {
+    ...props,
+    dataSource: data,
+  });
+};
+const handleFormChange = (formatData: any) => {
+  const obj = cloneDeep(props);
+  Object.keys(formatData).forEach((key) => {
+    set(obj, key, formatData[key]);
+  });
+  emit("change", obj);
+};
+</script>
+
+<style lang="less" scoped>
+.config-tab {
+  text-align: center;
+  margin-bottom: 12px;
+}
+</style>

+ 37 - 0
packages/shalu-dashboard-ui/components/charts/Pie/BasicPie/src/props.d.ts

@@ -0,0 +1,37 @@
+import { PropType, ExtractPropTypes } from 'vue';
+import { EChartsOption } from 'echarts';
+
+export declare const basicPieProps: {
+    width: {
+        type: PropType<number>;
+        default: number;
+    };
+    height: {
+        type: PropType<number>;
+        default: number;
+    };
+    dataSource: any;
+    title: {
+        type: PropType<EChartsOption["title"]>;
+    };
+    legend: {
+        type: PropType<EChartsOption["legend"]>;
+    };
+    backgroundColor: {
+        type: PropType<string>;
+    };
+    grid: {
+        type: PropType<EChartsOption["grid"]>;
+    };
+    tooltip: {
+        type: PropType<EChartsOption["tooltip"]>;
+    };
+    series: {
+        type: PropType<EChartsOption["series"]>;
+    };
+    color: {
+        type: PropType<EChartsOption["color"]>;
+    };
+};
+export declare const defaultPropsValue: EChartsOption;
+export type BasicPieProps = ExtractPropTypes<typeof basicPieProps>;

+ 122 - 0
packages/shalu-dashboard-ui/components/charts/Pie/BasicPie/src/props.ts

@@ -0,0 +1,122 @@
+import type { PropType, ExtractPropTypes } from "vue";
+import { EChartsOption } from "echarts";
+import { getNormalizedChart, dataSource } from "../../../utils";
+import { DataSourceType } from "../../../chartEnum";
+
+export const basicPieProps = {
+  width: {
+    type: Number as PropType<number>,
+    default: 400,
+  },
+  height: {
+    type: Number as PropType<number>,
+    default: 260,
+  },
+  dataSource,
+  // 标题
+  title: {
+    type: Object as PropType<EChartsOption["title"]>,
+  },
+  // 图例
+  legend: {
+    type: Object as PropType<EChartsOption["legend"]>,
+  },
+  // 背景
+  backgroundColor: {
+    type: String as PropType<string>,
+  },
+  // 边框
+  grid: {
+    type: Object as PropType<EChartsOption["grid"]>,
+  },
+  // 提示框
+  tooltip: {
+    type: Object as PropType<EChartsOption["tooltip"]>,
+  },
+  // 饼图数据
+  series: {
+    type: Array as PropType<EChartsOption["series"]>,
+  },
+  // color
+  color: {
+    type: Object as PropType<EChartsOption["color"]>
+  }
+};
+
+/* 系列相关 */
+const series: EChartsOption['series'] = [];
+series['pie' as unknown as number] = {
+  top: 70,
+  startAngle: 0,
+  endAngle: 360,
+  radius: ['0%', '75%'],
+};
+
+const chartOptions = getNormalizedChart({
+  title: {
+    text: "饼图标题",
+  },
+  grid: {
+    show: false,
+  },
+  xAxis: {
+    show: false
+  },
+  yAxis: {
+    show: false
+  },
+  series
+})
+
+export const defaultPropsValue: EChartsOption = {
+  // 组件容器默认属性
+  container: {
+    props: {
+      width: 400,
+      height: 260,
+    },
+  },
+  // 图表默认属性
+  props: {
+    // 数据源
+    dataSource: {
+      sourceType: DataSourceType.STATIC,
+      data: {
+        series: [
+          {
+            type: 'pie',
+            name: '系列1',
+            data: [
+              { value: 335, name: '直接访问' },
+              { value: 310, name: '邮件营销' },
+              { value: 234, name: '联盟广告' },
+              { value: 135, name: '视频广告' },
+            ]
+          }
+        ]
+      },
+      url: location.origin + "/mock/api/get/example/pie",
+      method: "POST",
+      params: {},
+      headers: {},
+      refreshTime: 0,
+      dataProcess: `
+        (res) => {
+          // 取出列表
+          const data = res.data;
+
+          // 系列数据
+          const series = [
+            { type: 'pie', name: '价格', data: data.map(item => item.price) },
+          ];
+
+          // 返回图表数据
+          return { series };
+        }
+      `
+    },
+    ...chartOptions
+  },
+};
+
+export type BasicPieProps = ExtractPropTypes<typeof basicPieProps>;

+ 4 - 0
packages/shalu-dashboard-ui/components/charts/chartEnum.d.ts

@@ -0,0 +1,4 @@
+export declare enum DataSourceType {
+    STATIC = 0,
+    API = 1
+}

+ 7 - 0
packages/shalu-dashboard-ui/components/charts/chartEnum.ts

@@ -0,0 +1,7 @@
+/* 数据来源 */
+export enum DataSourceType {
+  /* 静态数据 */
+  STATIC,
+  /* 接口数据 */
+  API,
+}

+ 0 - 0
packages/shalu-dashboard-ui/components/charts/config/chartFormItemsMap.ts


Some files were not shown because too many files changed in this diff