Browse Source

init project

zhoucg 1 year ago
parent
commit
1aad4c7d87
100 changed files with 21038 additions and 2 deletions
  1. 0 0
      .env
  2. 0 0
      .env.development
  3. 0 0
      .env.production
  4. 17 2
      README.md
  5. 9 0
      auto-imports.d.ts
  6. 84 0
      components.d.ts
  7. 15 0
      index.html
  8. 2751 0
      package-lock.json
  9. 35 0
      package.json
  10. 1 0
      public/vite.svg
  11. 16 0
      src/App.vue
  12. 2337 0
      src/assets/font-awesome/css/font-awesome.css
  13. 4 0
      src/assets/font-awesome/css/font-awesome.min.css
  14. BIN
      src/assets/font-awesome/fonts/FontAwesome.otf
  15. BIN
      src/assets/font-awesome/fonts/fontawesome-webfont.eot
  16. 2671 0
      src/assets/font-awesome/fonts/fontawesome-webfont.svg
  17. BIN
      src/assets/font-awesome/fonts/fontawesome-webfont.ttf
  18. BIN
      src/assets/font-awesome/fonts/fontawesome-webfont.woff
  19. BIN
      src/assets/font-awesome/fonts/fontawesome-webfont.woff2
  20. 279 0
      src/assets/style/main.less
  21. 249 0
      src/assets/style/variable.css
  22. 1 0
      src/assets/vue.svg
  23. 159 0
      src/components/DataSource.vue
  24. 222 0
      src/components/DataSourceMaintain.vue
  25. 441 0
      src/components/GlobalSetting.vue
  26. 81 0
      src/components/PageComponent.vue
  27. 54 0
      src/components/PageComponents/RoyCircle.vue
  28. 70 0
      src/components/PageComponents/RoyGroup.vue
  29. 58 0
      src/components/PageComponents/RoyImage.vue
  30. 54 0
      src/components/PageComponents/RoyLine.vue
  31. 54 0
      src/components/PageComponents/RoyRect.vue
  32. 196 0
      src/components/PageComponents/RoySimpleText.vue
  33. 56 0
      src/components/PageComponents/RoyStar.vue
  34. 91 0
      src/components/PageComponents/RoyTable/ResizeObserver.js
  35. 323 0
      src/components/PageComponents/RoyTable/RoyComplexTable.vue
  36. 624 0
      src/components/PageComponents/RoyTable/RoySimpleTable.vue
  37. 213 0
      src/components/PageComponents/RoyTable/RoySimpleTextInTable.vue
  38. 113 0
      src/components/PageComponents/RoyTable/RoyTextInTable.vue
  39. 319 0
      src/components/PageComponents/RoyTable/TableDataSetting.vue
  40. 111 0
      src/components/PageComponents/RoyText.vue
  41. 131 0
      src/components/PageComponents/WangEditorVue/WangEditor.vue
  42. 42 0
      src/components/PageComponents/WangEditorVue/WangToolbar.vue
  43. 432 0
      src/components/PageComponents/style.js
  44. 176 0
      src/components/PagePalette.vue
  45. 179 0
      src/components/PageToc.vue
  46. 250 0
      src/components/config/componentList.ts
  47. 122 0
      src/components/config/editorConfig.js
  48. 47 0
      src/components/config/menuConfig.ts
  49. 14 0
      src/components/config/pageFormatConfig.ts
  50. 1532 0
      src/components/config/paletteConfig.js
  51. 47 0
      src/components/config/toolbarConfig.ts
  52. 147 0
      src/components/sketch-ruler/README.MD
  53. 175 0
      src/components/sketch-ruler/app.vue
  54. 114 0
      src/components/sketch-ruler/canvasRuler/canvasRuler.vue
  55. 163 0
      src/components/sketch-ruler/canvasRuler/utils.js
  56. 30 0
      src/components/sketch-ruler/index.js
  57. 144 0
      src/components/sketch-ruler/line.vue
  58. 237 0
      src/components/sketch-ruler/rulerWrapper.vue
  59. 260 0
      src/components/sketch-ruler/sketchRuler.vue
  60. 299 0
      src/components/yvan-context/components/Context.vue
  61. 33 0
      src/components/yvan-context/components/ContextGroup.vue
  62. 76 0
      src/components/yvan-context/components/ContextItem.vue
  63. 91 0
      src/components/yvan-context/components/ContextSubmenu.vue
  64. 10 0
      src/components/yvan-context/directive.js
  65. 7 0
      src/components/yvan-context/index.js
  66. 39 0
      src/components/yvan-editor/Area.vue
  67. 532 0
      src/components/yvan-editor/ComponentAdjuster.vue
  68. 666 0
      src/components/yvan-editor/Editor.vue
  69. 76 0
      src/components/yvan-editor/EditorCoordinate.vue
  70. 268 0
      src/components/yvan-editor/EditorLine.vue
  71. 152 0
      src/components/yvan-print-designer-left.vue
  72. 103 0
      src/components/yvan-print-designer-main.vue
  73. 160 0
      src/components/yvan-print-designer-right.vue
  74. 256 0
      src/components/yvan-print-designer.vue
  75. 122 0
      src/components/yvan-print-page-format.vue
  76. 76 0
      src/components/yvan-ui/yvan-model/RoyModal.vue
  77. 27 0
      src/components/yvan-ui/yvan-print-aside.vue
  78. 51 0
      src/components/yvan-ui/yvan-print-container.vue
  79. 84 0
      src/components/yvan-ui/yvan-print-dialog/dialog.vue
  80. 32 0
      src/components/yvan-ui/yvan-print-dialog/index.ts
  81. 27 0
      src/components/yvan-ui/yvan-print-header.vue
  82. 26 0
      src/components/yvan-ui/yvan-print-main.vue
  83. 31 0
      src/components/yvan-ui/yvan-print-toast/index.ts
  84. 263 0
      src/components/yvan-ui/yvan-print-toast/toast.vue
  85. 173 0
      src/components/yvan-ui/yvan-print-toolbar.vue
  86. 246 0
      src/components/yvan-ui/yvan-sidebar-menu/SidebarMenu.vue
  87. 22 0
      src/components/yvan-ui/yvan-sidebar-menu/SidebarMenuBadge.vue
  88. 22 0
      src/components/yvan-ui/yvan-sidebar-menu/SidebarMenuIcon.vue
  89. 340 0
      src/components/yvan-ui/yvan-sidebar-menu/SidebarMenuItem.vue
  90. 36 0
      src/components/yvan-ui/yvan-sidebar-menu/SidebarMenuLink.vue
  91. 216 0
      src/components/yvan-ui/yvan-sidebar-menu/style/_base.less
  92. 36 0
      src/components/yvan-ui/yvan-sidebar-menu/style/_variables.less
  93. 124 0
      src/components/yvan-ui/yvan-sidebar-menu/style/themes/default-theme.less
  94. 121 0
      src/components/yvan-ui/yvan-sidebar-menu/style/themes/white-theme.less
  95. 7 0
      src/components/yvan-ui/yvan-sidebar-menu/style/vue-sidebar-menu.less
  96. 15 0
      src/main.ts
  97. 29 0
      src/mixin/commonMixin.js
  98. 104 0
      src/store/compose.ts
  99. 90 0
      src/store/copy.ts
  100. 0 0
      src/store/global.ts

+ 0 - 0
.env


+ 0 - 0
.env.development


+ 0 - 0
.env.production


+ 17 - 2
README.md

@@ -1,3 +1,18 @@
-# yvan-print
+# Vue 3 + TypeScript + Vite
 
-Yvan Print
+This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
+
+## Recommended IDE Setup
+
+- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
+
+## Type Support For `.vue` Imports in TS
+
+TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
+
+If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
+
+1. Disable the built-in TypeScript Extension
+   1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
+   2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
+2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.

+ 9 - 0
auto-imports.d.ts

@@ -0,0 +1,9 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// noinspection JSUnusedGlobalSymbols
+// Generated by unplugin-auto-import
+export {}
+declare global {
+
+}

+ 84 - 0
components.d.ts

@@ -0,0 +1,84 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+export {}
+
+declare module 'vue' {
+  export interface GlobalComponents {
+    App: typeof import('./src/components/sketch-ruler/app.vue')['default']
+    Area: typeof import('./src/components/yvan-editor/Area.vue')['default']
+    CanvasRuler: typeof import('./src/components/sketch-ruler/canvasRuler/canvasRuler.vue')['default']
+    ComponentAdjuster: typeof import('./src/components/yvan-editor/ComponentAdjuster.vue')['default']
+    Context: typeof import('./src/components/yvan-context/components/Context.vue')['default']
+    ContextGroup: typeof import('./src/components/yvan-context/components/ContextGroup.vue')['default']
+    ContextItem: typeof import('./src/components/yvan-context/components/ContextItem.vue')['default']
+    ContextSubmenu: typeof import('./src/components/yvan-context/components/ContextSubmenu.vue')['default']
+    DataSource: typeof import('./src/components/DataSource.vue')['default']
+    DataSourceMaintain: typeof import('./src/components/DataSourceMaintain.vue')['default']
+    Dialog: typeof import('./src/components/yvan-ui/yvan-print-dialog/dialog.vue')['default']
+    Editor: typeof import('./src/components/yvan-editor/Editor.vue')['default']
+    EditorCoordinate: typeof import('./src/components/yvan-editor/EditorCoordinate.vue')['default']
+    EditorLine: typeof import('./src/components/yvan-editor/EditorLine.vue')['default']
+    ElButton: typeof import('element-plus/es')['ElButton']
+    ElCard: typeof import('element-plus/es')['ElCard']
+    ElCheckboxButton: typeof import('element-plus/es')['ElCheckboxButton']
+    ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
+    ElCol: typeof import('element-plus/es')['ElCol']
+    ElCollapse: typeof import('element-plus/es')['ElCollapse']
+    ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
+    ElDialog: typeof import('element-plus/es')['ElDialog']
+    ElDivider: typeof import('element-plus/es')['ElDivider']
+    ElForm: typeof import('element-plus/es')['ElForm']
+    ElFormItem: typeof import('element-plus/es')['ElFormItem']
+    ElIcon: typeof import('element-plus/es')['ElIcon']
+    ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
+    ElOption: typeof import('element-plus/es')['ElOption']
+    ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
+    ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
+    ElRow: typeof import('element-plus/es')['ElRow']
+    ElSelect: typeof import('element-plus/es')['ElSelect']
+    ElSpace: typeof import('element-plus/es')['ElSpace']
+    GlobalSetting: typeof import('./src/components/GlobalSetting.vue')['default']
+    Line: typeof import('./src/components/sketch-ruler/line.vue')['default']
+    PageComponent: typeof import('./src/components/PageComponent.vue')['default']
+    PagePalette: typeof import('./src/components/PagePalette.vue')['default']
+    PageToc: typeof import('./src/components/PageToc.vue')['default']
+    RoyCircle: typeof import('./src/components/PageComponents/RoyCircle.vue')['default']
+    RoyComplexTable: typeof import('./src/components/PageComponents/RoyTable/RoyComplexTable.vue')['default']
+    RoyGroup: typeof import('./src/components/PageComponents/RoyGroup.vue')['default']
+    RoyImage: typeof import('./src/components/PageComponents/RoyImage.vue')['default']
+    RoyLine: typeof import('./src/components/PageComponents/RoyLine.vue')['default']
+    RoyModal: typeof import('./src/components/yvan-ui/yvan-model/RoyModal.vue')['default']
+    RoyRect: typeof import('./src/components/PageComponents/RoyRect.vue')['default']
+    RoySimpleTable: typeof import('./src/components/PageComponents/RoyTable/RoySimpleTable.vue')['default']
+    RoySimpleText: typeof import('./src/components/PageComponents/RoySimpleText.vue')['default']
+    RoySimpleTextInTable: typeof import('./src/components/PageComponents/RoyTable/RoySimpleTextInTable.vue')['default']
+    RoyStar: typeof import('./src/components/PageComponents/RoyStar.vue')['default']
+    RoyText: typeof import('./src/components/PageComponents/RoyText.vue')['default']
+    RoyTextInTable: typeof import('./src/components/PageComponents/RoyTable/RoyTextInTable.vue')['default']
+    RulerWrapper: typeof import('./src/components/sketch-ruler/rulerWrapper.vue')['default']
+    SidebarMenu: typeof import('./src/components/yvan-ui/yvan-sidebar-menu/SidebarMenu.vue')['default']
+    SidebarMenuBadge: typeof import('./src/components/yvan-ui/yvan-sidebar-menu/SidebarMenuBadge.vue')['default']
+    SidebarMenuIcon: typeof import('./src/components/yvan-ui/yvan-sidebar-menu/SidebarMenuIcon.vue')['default']
+    SidebarMenuItem: typeof import('./src/components/yvan-ui/yvan-sidebar-menu/SidebarMenuItem.vue')['default']
+    SidebarMenuLink: typeof import('./src/components/yvan-ui/yvan-sidebar-menu/SidebarMenuLink.vue')['default']
+    SketchRuler: typeof import('./src/components/sketch-ruler/sketchRuler.vue')['default']
+    TableDataSetting: typeof import('./src/components/PageComponents/RoyTable/TableDataSetting.vue')['default']
+    Toast: typeof import('./src/components/yvan-ui/yvan-print-toast/toast.vue')['default']
+    WangEditor: typeof import('./src/components/PageComponents/WangEditorVue/WangEditor.vue')['default']
+    WangToolbar: typeof import('./src/components/PageComponents/WangEditorVue/WangToolbar.vue')['default']
+    YvanPrintAside: typeof import('./src/components/yvan-ui/yvan-print-aside.vue')['default']
+    YvanPrintContainer: typeof import('./src/components/yvan-ui/yvan-print-container.vue')['default']
+    YvanPrintDesigner: typeof import('./src/components/yvan-print-designer.vue')['default']
+    YvanPrintDesignerAside: typeof import('./src/components/yvan-print-designer-left.vue')['default']
+    YvanPrintDesignerLeft: typeof import('./src/components/yvan-print-designer-left.vue')['default']
+    YvanPrintDesignerMain: typeof import('./src/components/yvan-print-designer-main.vue')['default']
+    YvanPrintDesignerRight: typeof import('./src/components/yvan-print-designer-right.vue')['default']
+    YvanPrintHeader: typeof import('./src/components/yvan-ui/yvan-print-header.vue')['default']
+    YvanPrintMain: typeof import('./src/components/yvan-ui/yvan-print-main.vue')['default']
+    YvanPrintPageFormat: typeof import('./src/components/yvan-print-page-format.vue')['default']
+    YvanPrintToolbar: typeof import('./src/components/yvan-ui/yvan-print-toolbar.vue')['default']
+  }
+}

+ 15 - 0
index.html

@@ -0,0 +1,15 @@
+<!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"/>
+<!--    <link rel="stylesheet" href="./src/assets/iconfont/iconfont.css">-->
+    <link rel="stylesheet" href="./src/assets/font-awesome/css/font-awesome.min.css">
+    <title>Yvan打印系统</title>
+</head>
+<body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+</body>
+</html>

File diff suppressed because it is too large
+ 2751 - 0
package-lock.json


+ 35 - 0
package.json

@@ -0,0 +1,35 @@
+{
+  "name": "yvan-print",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "ts": "vue-tsc --noEmit",
+    "build": "vue-tsc && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@wangeditor/editor": "^5.1.22",
+    "big.js": "^6.2.1",
+    "element-plus": "^2.3.7",
+    "mitt": "^3.0.0",
+    "nanoid": "^4.0.2",
+    "pinia": "^2.1.4",
+    "vue": "^3.2.47",
+    "vue3-styled-components": "^1.2.1",
+    "vuedraggable": "^2.24.3",
+    "yarn": "^1.22.19"
+  },
+  "devDependencies": {
+    "@types/node": "^20.3.1",
+    "@vitejs/plugin-vue": "^4.1.0",
+    "less": "^4.1.3",
+    "less-loader": "^11.1.3",
+    "typescript": "^5.0.2",
+    "unplugin-auto-import": "^0.16.4",
+    "unplugin-vue-components": "^0.25.1",
+    "vite": "^4.3.9",
+    "vue-tsc": "^1.4.2"
+  }
+}

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


+ 16 - 0
src/App.vue

@@ -0,0 +1,16 @@
+<template>
+  <div class="yvan-print">
+    <yvan-print-designer ></yvan-print-designer>
+  </div>
+</template>
+
+<script setup lang="ts">
+
+import YvanPrintDesigner from "@/components/yvan-print-designer.vue";
+</script>
+
+<style lang="less" scoped>
+.yvan-print {
+  height: 100%;
+}
+</style>

File diff suppressed because it is too large
+ 2337 - 0
src/assets/font-awesome/css/font-awesome.css


File diff suppressed because it is too large
+ 4 - 0
src/assets/font-awesome/css/font-awesome.min.css


BIN
src/assets/font-awesome/fonts/FontAwesome.otf


BIN
src/assets/font-awesome/fonts/fontawesome-webfont.eot


File diff suppressed because it is too large
+ 2671 - 0
src/assets/font-awesome/fonts/fontawesome-webfont.svg


BIN
src/assets/font-awesome/fonts/fontawesome-webfont.ttf


BIN
src/assets/font-awesome/fonts/fontawesome-webfont.woff


BIN
src/assets/font-awesome/fonts/fontawesome-webfont.woff2


+ 279 - 0
src/assets/style/main.less

@@ -0,0 +1,279 @@
+.roy-designer-container {
+  .roy-fade-enter-active {
+    animation: fadeIn 1s;
+  }
+
+  .roy-fade-leave-active {
+    animation: fadeOut 1s;
+  }
+
+  .roy-slide-enter-active {
+    animation: slideInLeft 0.58s;
+  }
+
+  .roy-slide-leave-active {
+    animation: slideOutLeft 0.8s;
+  }
+
+  .indicator .value {
+    // 修改标尺提示坐标背景色
+    background: #4579e1;
+    color: #fff;
+  }
+
+  .roy-wang-editor {
+    border: 1px solid var(--roy-border);
+    box-shadow: rgba(99, 99, 99, 0.2) 0 2px 8px 0;
+    cursor: initial;
+
+    .w-e-drop-panel {
+      min-width: 400px;
+    }
+  }
+
+  .roy-binding-value {
+    background: var(--roy-color-info-light-8);
+    padding: 3px;
+    border-radius: 3px;
+    border: 1px solid var(--roy-color-info);
+  }
+}
+
+.roy-btn-radio-group {
+  .roy-btn-radio-group__btn {
+    background: var(--roy-bg-color-page);
+    height: 24px;
+    width: 24px;
+    font-size: 14px;
+    line-height: 24px;
+    padding: 0;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+
+    &:hover {
+      color: #ffffff !important;
+      background: var(--roy-color-primary-light-3);
+    }
+
+    & + .roy-btn-radio-group__btn {
+      margin-left: 5px;
+    }
+
+    &.roy-btn-radio-group__btn--active {
+      background: var(--roy-color-primary);
+      color: #fff;
+    }
+  }
+}
+
+.roy-dropdown-menu {
+  display: grid;
+  justify-content: center;
+  max-height: 200px;
+  overflow-y: auto;
+  width: 100%;
+
+  li {
+    list-style-type: none;
+    cursor: pointer;
+    margin: 0;
+    text-align: left;
+    width: 100%;
+    padding: 8px 5px;
+
+    &:hover {
+      background: #3e6dcb;
+      color: #fff;
+    }
+  }
+}
+
+.yvan-print-container {
+  font-family: Helvetica Neue, Helvetica, Arial, PingFang SC, Microsoft YaHei, WenQuanYi Micro Hei, sans-serif;
+  font-weight: 400;
+  font-size: var(--yvan-font-size-base);
+  color: var(--yvan-text-color-primary);
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  -webkit-tap-highlight-color: transparent;
+
+  a {
+    color: var(--yvan-color-primary);
+    text-decoration: none;
+
+    &:hover,
+    &:focus {
+      color: var(--yvan-color-primary-light-3);
+    }
+
+    &:active {
+      color: var(--yvan-color-primary-dark-2);
+    }
+  }
+
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6 {
+    color: var(--yvan-text-color-regular);
+    font-weight: inherit;
+  }
+
+  h1:first-child,
+  h2:first-child,
+  h3:first-child,
+  h4:first-child,
+  h5:first-child,
+  h6:first-child {
+    margin-top: 0;
+  }
+
+  h1:last-child,
+  h2:last-child,
+  h3:last-child,
+  h4:last-child,
+  h5:last-child,
+  h6:last-child {
+    margin-bottom: 0;
+  }
+
+  h1 {
+    font-size: calc(var(--yvan-font-size-base) + 6px);
+  }
+
+  h2 {
+    font-size: calc(var(--yvan-font-size-base) + 4px);
+  }
+
+  h3 {
+    font-size: calc(var(--yvan-font-size-base) + 2px);
+  }
+
+  h4,
+  h5,
+  h6,
+  p {
+    font-size: inherit;
+  }
+
+  p:first-child {
+    margin-top: 0;
+  }
+
+  p:last-child {
+    margin-bottom: 0;
+  }
+
+  sup,
+  sub {
+    font-size: calc(var(--yvan-font-size-base) - 1px);
+  }
+
+  small {
+    font-size: calc(var(--yvan-font-size-base) - 2px);
+  }
+
+  hr {
+    margin-top: 20px;
+    margin-bottom: 20px;
+    border: 0;
+    border-top: 1px solid var(--yvan-border-color-lighter);
+  }
+
+  .bold {
+    font-weight: bold;
+  }
+
+  .vxe-form {
+    color: inherit;
+    background: inherit;
+  }
+
+  .rotate-90 {
+    transform: rotate(90deg);
+    display: block;
+  }
+
+  .halo-tree {
+    padding: 0;
+
+    .tree-node-el,
+    .tree-expand {
+      background: var(--yvan-bg-color-overlay);
+    }
+
+    .node-title:hover,
+    .node-selected {
+      background: var(--yvan-color-primary-light-5);
+    }
+  }
+
+  .splitter-pane-resizer {
+    background: #555 !important;
+    background-clip: padding-box !important;
+
+    &.vertical {
+      height: calc(100% - 20px) !important;
+      margin: 10px 0 0 -5px !important;
+    }
+  }
+
+  ::-webkit-scrollbar {
+    width: 12px;
+    height: 12px;
+  }
+
+  ::-webkit-scrollbar-track {
+    background-color: var(--prism-background);
+    border-radius: 8px;
+  }
+
+  ::-webkit-scrollbar-track-piece {
+    background-color: var(--prism-background);
+    border-radius: 8px;
+  }
+
+  ::-webkit-scrollbar-thumb {
+    border-radius: 2px;
+    background: #e2e2e2;
+  }
+
+  ::-webkit-scrollbar-thumb:hover {
+    background-color: #d9d9d9;
+  }
+
+  ::selection {
+    background: var(--yvan-color-primary-dark-2);
+    color: #fff;
+  }
+
+  @media print {
+    @page {
+      size: A4 portrait;
+      margin: 0;
+    }
+    .roy-component-adjuster__move,
+    .roy-simple-table-cell__icon {
+      display: none;
+    }
+  }
+}
+
+#roy-print-template-designer[theme='dark'] {
+  ::-webkit-scrollbar-thumb {
+    background: #424242;
+  }
+
+  ::-webkit-scrollbar-thumb:hover {
+    background: #606060;
+  }
+
+  ::selection {
+    background: #ccc;
+    color: #000;
+  }
+}

+ 249 - 0
src/assets/style/variable.css

@@ -0,0 +1,249 @@
+:root {
+  --primary-color-50: rgba(69, 121, 225, 0.5);
+  --primary-color-30: rgba(69, 121, 225, 0.3);
+  --prism-background: #f4f4f4;
+  --prism-line-highlight-background: #eeeeee;
+  --prism-selection-background: #aaaaaa;
+  --prism-inline-background: var(--prism-background);
+  --bg-color-rgb: 255, 255, 255;
+  --bg-color-soft: #fafafa;
+  --bg-color-mute: #f2f2f2;
+  --yvan-text-color-primary: #303133;
+  --yvan-text-color-regular: #606266;
+  --yvan-text-color-secondary: #909399;
+  --yvan-text-color-placeholder: #a8abb2;
+  --yvan-text-color-disabled: #c0c4cc;
+  --yvan-color-white: #ffffff;
+  --yvan-color-black: #000000;
+  --yvan-color-primary: #4579e1;
+  --yvan-color-primary-light-3: #7da1ea;
+  --yvan-color-primary-light-5: #a2bcf0;
+  --yvan-color-primary-light-7: #c7d7f6;
+  --yvan-color-primary-light-8: #dae4f9;
+  --yvan-color-primary-light-9: #ecf2fc;
+  --yvan-color-primary-dark-2: #3e6dcb;
+  --yvan-color-success: #009688;
+  --yvan-color-success-light-3: #4db6ac;
+  --yvan-color-success-light-5: #80cbc4;
+  --yvan-color-success-light-7: #b3e0db;
+  --yvan-color-success-light-8: #cceae7;
+  --yvan-color-success-light-9: #e6f5f3;
+  --yvan-color-success-dark-2: #08777a;
+  --yvan-color-warning: #f18f18;
+  --yvan-color-warning-light-3: #f5b15d;
+  --yvan-color-warning-light-5: #f8c78c;
+  --yvan-color-warning-light-7: #fbddba;
+  --yvan-color-warning-light-8: #fce9d1;
+  --yvan-color-warning-light-9: #fef4e8;
+  --yvan-color-warning-dark-2: #d98116;
+  --yvan-color-danger: #f54536;
+  --yvan-color-danger-light-3: #f87d72;
+  --yvan-color-danger-light-5: #faa29b;
+  --yvan-color-danger-light-7: #fcc7c3;
+  --yvan-color-danger-light-8: #fddad7;
+  --yvan-color-danger-light-9: #feeceb;
+  --yvan-color-danger-dark-2: #dd3e31;
+  --yvan-color-error: #f54536;
+  --yvan-color-error-light-3: #f87d72;
+  --yvan-color-error-light-5: #faa29b;
+  --yvan-color-error-light-7: #fcc7c3;
+  --yvan-color-error-light-8: #fddad7;
+  --yvan-color-error-light-9: #feeceb;
+  --yvan-color-error-dark-2: #dd3e31;
+  --yvan-color-info: #00bcd3;
+  --yvan-color-info-light-3: #4dd0e0;
+  --yvan-color-info-light-5: #80dee9;
+  --yvan-color-info-light-7: #b3ebf2;
+  --yvan-color-info-light-8: #ccf2f6;
+  --yvan-color-info-light-9: #e6f8fb;
+  --yvan-color-info-dark-2: #0a9be;
+  --yvan-bg-color: #ffffff;
+  --yvan-bg-color-page: #f2f3f5;
+  --yvan-bg-color-overlay: #ffffff;
+  --yvan-bg-block-color: #f0f0f0;
+  --yvan-border-block-color: #d5d5d5;
+  --yvan-border-color: #dcdfe6;
+  --yvan-border-color-light: #e4e7ed;
+  --yvan-border-color-lighter: #ebeef5;
+  --yvan-border-color-extra-light: #f2f6fc;
+  --yvan-border-color-dark: #d4d7de;
+  --yvan-border-color-darker: #cdd0d6;
+  --yvan-fill-color: #f0f2f5;
+  --yvan-fill-color-light: #f5f7fa;
+  --yvan-fill-color-lighter: #fafafa;
+  --yvan-fill-color-extra-light: #fafcff;
+  --yvan-fill-color-dark: #ebedf0;
+  --yvan-fill-color-darker: #e6e8eb;
+  --yvan-fill-color-blank: #ffffff;
+  --yvan-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.04),
+    0px 8px 20px rgba(0, 0, 0, 0.08);
+  --yvan-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, 0.12);
+  --yvan-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.12);
+  --yvan-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.08),
+    0px 12px 32px rgba(0, 0, 0, 0.12), 0px 8px 16px -8px rgba(0, 0, 0, 0.16);
+  --yvan-disabled-bg-color: var(--yvan-fill-color-light);
+  --yvan-disabled-text-color: var(--yvan-text-color-placeholder);
+  --yvan-disabled-border-color: var(--yvan-border-color-light);
+  --yvan-overlay-color: rgba(0, 0, 0, 0.8);
+  --yvan-overlay-color-light: rgba(0, 0, 0, 0.7);
+  --yvan-overlay-color-lighter: rgba(0, 0, 0, 0.5);
+  --yvan-mask-color: rgba(255, 255, 255, 0.9);
+  --yvan-mask-color-extra-light: rgba(255, 255, 255, 0.3);
+  --yvan-border-width: 1px;
+  --yvan-border-style: solid;
+  --yvan-border-color-hover: var(--yvan-text-color-disabled);
+  --yvan-border: var(--yvan-border-width) var(--yvan-border-style)
+    var(--yvan-border-color);
+  --yvan-svg-monochrome-grey: var(--yvan-border-color);
+  --yvan-color-primary-rgb: 64, 158, 255;
+  --yvan-color-success-rgb: 103, 194, 58;
+  --yvan-color-warning-rgb: 230, 162, 60;
+  --yvan-color-danger-rgb: 245, 108, 108;
+  --yvan-color-error-rgb: 245, 108, 108;
+  --yvan-color-info-rgb: 144, 147, 153;
+  --yvan-font-size-extra-large: 20px;
+  --yvan-font-size-large: 18px;
+  --yvan-font-size-medium: 16px;
+  --yvan-font-size-base: 14px;
+  --yvan-font-size-small: 13px;
+  --yvan-font-size-extra-small: 12px;
+  --yvan-font-family: 'Helvetica Neue', Helvetica, 'PingFang SC',
+    'Hiragino Sans GB', 'Microsoft YaHei', '\5fae\8f6f\96c5\9ed1', Arial,
+    sans-serif;
+  --yvan-font-weight-primary: 500;
+  --yvan-font-line-height-primary: 24px;
+  --yvan-index-normal: 1;
+  --yvan-index-top: 1000;
+  --yvan-index-popper: 2000;
+  --yvan-border-radius-base: 4px;
+  --yvan-border-radius-small: 2px;
+  --yvan-border-radius-round: 20px;
+  --yvan-border-radius-circle: 100%;
+  --yvan-transition-duration: 0.3s;
+  --yvan-transition-duration-fast: 0.2s;
+  --yvan-transition-function-ease-in-out-bezier: cubic-bezier(
+    0.645,
+    0.045,
+    0.355,
+    1
+  );
+  --yvan-transition-function-fast-bezier: cubic-bezier(0.23, 1, 0.32, 1);
+  --yvan-transition-all: all var(--yvan-transition-duration)
+    var(--yvan-transition-function-ease-in-out-bezier);
+  --yvan-transition-fade: opacity var(--yvan-transition-duration)
+    var(--yvan-transition-function-fast-bezier);
+  --yvan-transition-md-fade: transform var(--yvan-transition-duration)
+      var(--yvan-transition-function-fast-bezier),
+    opacity var(--yvan-transition-duration)
+      var(--yvan-transition-function-fast-bezier);
+  --yvan-transition-fade-linear: opacity var(--yvan-transition-duration-fast)
+    linear;
+  --yvan-transition-border: border-color var(--yvan-transition-duration-fast)
+    var(--yvan-transition-function-ease-in-out-bezier);
+  --yvan-transition-box-shadow: box-shadow var(--yvan-transition-duration-fast)
+    var(--yvan-transition-function-ease-in-out-bezier);
+  --yvan-transition-color: color var(--yvan-transition-duration-fast)
+    var(--yvan-transition-function-ease-in-out-bezier);
+  --yvan-component-size-large: 40px;
+  --yvan-component-size: 32px;
+  --yvan-component-size-small: 24px;
+  --yvan-menu-active-color: var(--yvan-color-primary);
+  --yvan-menu-text-color: var(--yvan-text-color-primary);
+  --yvan-menu-hover-text-color: var(--yvan-color-primary);
+  --yvan-menu-bg-color: var(--yvan-fill-color-blank);
+  --yvan-menu-hover-bg-color: var(--yvan-color-primary-light-9);
+  --yvan-menu-item-height: 56px;
+  --yvan-menu-sub-item-height: calc(var(--yvan-menu-item-height) - 6px);
+  --yvan-menu-horizontal-sub-item-height: 36px;
+  --yvan-menu-item-font-size: var(--yvan-font-size-base);
+  --yvan-menu-item-hover-fill: var(--yvan-color-primary-light-9);
+  --yvan-menu-border-color: var(--yvan-border-color);
+  --yvan-menu-base-level-padding: 20px;
+  --yvan-menu-level-padding: 20px;
+  --yvan-menu-icon-width: 24px;
+  --yvan-menu-bar-background: #4978e4;
+}
+
+#yvan-print-template-designer[theme='dark'] {
+  --prism-background: #181818;
+  --prism-line-highlight-background: #444444;
+  --prism-selection-background: #444444;
+  --prism-inline-background: #2d2d2d;
+  --bg-color-rgb: 0, 0, 0;
+  --bg-color-soft: #242424;
+  --bg-color-mute: #2c2c2c;
+  --yvan-text-color-primary: #e5eaf3;
+  --yvan-text-color-regular: #cfd3dc;
+  --yvan-text-color-secondary: #a3a6ad;
+  --yvan-text-color-placeholder: #8d9095;
+  --yvan-text-color-disabled: #6c6e72;
+  --yvan-color-primary: #4579e1;
+  --yvan-color-primary-light-3: #30559e;
+  --yvan-color-primary-light-5: #233d71;
+  --yvan-color-primary-light-7: #1c305a;
+  --yvan-color-primary-light-8: #152444;
+  --yvan-color-primary-light-9: #0e182d;
+  --yvan-color-primary-dark-2: #5886e4;
+  --yvan-color-success: #009688;
+  --yvan-color-success-light-3: #00695f;
+  --yvan-color-success-light-5: #004b44;
+  --yvan-color-success-light-7: #002d29;
+  --yvan-color-success-light-8: #001e1b;
+  --yvan-color-success-light-9: #000f0e;
+  --yvan-color-success-dark-2: #1aa194;
+  --yvan-color-warning: #f18f18;
+  --yvan-color-warning-light-3: #a96411;
+  --yvan-color-warning-light-5: #79480c;
+  --yvan-color-warning-light-7: #482b07;
+  --yvan-color-warning-light-8: #301d05;
+  --yvan-color-warning-light-9: #180e02;
+  --yvan-color-warning-dark-2: #f29a2f;
+  --yvan-color-danger: #f54536;
+  --yvan-color-danger-light-3: #ac3026;
+  --yvan-color-danger-light-5: #7b231b;
+  --yvan-color-danger-light-7: #4a1510;
+  --yvan-color-danger-light-8: #310e0b;
+  --yvan-color-danger-light-9: #180705;
+  --yvan-color-danger-dark-2: #f6584a;
+  --yvan-color-error: #f54536;
+  --yvan-color-error-light-3: #ac3026;
+  --yvan-color-error-light-5: #7b231b;
+  --yvan-color-error-light-7: #4a1510;
+  --yvan-color-error-light-8: #310e0b;
+  --yvan-color-error-light-9: #180705;
+  --yvan-color-error-dark-2: #f6584a;
+  --yvan-color-info: #00bcd3;
+  --yvan-color-info-light-3: #008494;
+  --yvan-color-info-light-5: #005e6a;
+  --yvan-color-info-light-7: #00383f;
+  --yvan-color-info-light-8: #00262a;
+  --yvan-color-info-light-9: #001315;
+  --yvan-color-info-dark-2: #1ac3d7;
+  --yvan-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.36),
+    0px 8px 20px rgba(0, 0, 0, 0.72);
+  --yvan-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, 0.72);
+  --yvan-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.72);
+  --yvan-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.72),
+    0px 12px 32px #000000, 0px 8px 16px -8px #000000;
+  --yvan-bg-color-page: #0a0a0a;
+  --yvan-bg-color: #141414;
+  --yvan-bg-color-overlay: #1d1e1f;
+  --yvan-bg-block-color: #4d4d4f;
+  --yvan-border-block-color: #878787;
+  --yvan-border-color-darker: #636466;
+  --yvan-border-color-dark: #58585b;
+  --yvan-border-color: #4c4d4f;
+  --yvan-border-color-light: #414243;
+  --yvan-border-color-lighter: #363637;
+  --yvan-border-color-extra-light: #2b2b2c;
+  --yvan-fill-color-darker: #424243;
+  --yvan-fill-color-dark: #39393a;
+  --yvan-fill-color: #303030;
+  --yvan-fill-color-light: #262727;
+  --yvan-fill-color-lighter: #1d1d1d;
+  --yvan-fill-color-extra-light: #191919;
+  --yvan-fill-color-blank: transparent;
+  --yvan-mask-color: rgba(0, 0, 0, 0.8);
+  --yvan-mask-color-extra-light: rgba(0, 0, 0, 0.3);
+  --yvan-menu-bar-background: #222222;
+}

+ 1 - 0
src/assets/vue.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

+ 159 - 0
src/components/DataSource.vue

@@ -0,0 +1,159 @@
+<template>
+  <yvan-print-main class="DataSource">
+    <el-button
+      icon="ri-database-line"
+      size="small"
+      status="primary"
+      style="float: right; padding: 5px 10px"
+      type="text"
+      @click="showDataSourceMaintainer = true"
+    >
+      编辑数据源
+    </el-button>
+    <div class="roy-datasource-node-list" @dragstart="handleDragStart">
+      <div
+        v-for="(item, index) in dataSourceIn"
+        :key="index"
+        :data-index="index"
+        :title="item.title"
+        class="roy-datasource-node"
+        draggable="true"
+      >
+        <div class="roy-datasource-node--title">
+          <span class="roy-datasource-node--tag">{{ item.typeName }}</span>
+          <span>
+            <span class="roy-datasource-node--text">{{ item.title }}</span>
+          </span>
+        </div>
+        <div class="roy-datasource-node--content">{{ item.field }}</div>
+      </div>
+    </div>
+    <DataSourceMaintain v-if="showDataSourceMaintainer" :visible.sync="showDataSourceMaintainer" />
+  </yvan-print-main>
+</template>
+
+<script>
+import {mapState} from "pinia";
+import {globalStore} from "@/store";
+import commonMixin from '@/mixin/commonMixin'
+import DataSourceMaintain from '@/components/DataSourceMaintain.vue'
+import YvanPrintMain from "@/components/yvan-ui/yvan-print-main.vue";
+
+/**
+ * 数据源
+ */
+export default {
+  name: 'DataSource',
+  mixins: [commonMixin],
+  components: {
+    YvanPrintMain,
+    DataSourceMaintain
+  },
+  props: {},
+  data() {
+    return {
+      showDataSourceMaintainer: false,
+      dataSourceIn: []
+    }
+  },
+  computed: {
+    ...mapState(globalStore, {
+      dataSource: (state) => state.dataSource,
+    }),
+  },
+  methods: {
+    initMounted() {
+      this.dataSourceIn = this.deepCopy(this.dataSource)
+    },
+    handleDragStart(e) {
+      e.dataTransfer.setData('datasource-index', e.target.dataset.index)
+    }
+  },
+  created() {},
+  mounted() {
+    this.initMounted()
+  },
+  watch: {
+    dataSource(newVal) {
+      this.dataSourceIn = this.deepCopy(newVal)
+    }
+  }
+}
+</script>
+
+<style lang="less">
+.DataSource {
+  height: 100%;
+  padding: 2px !important;
+
+  .roy-datasource-node-list {
+    width: 100%;
+  }
+
+  .roy-datasource-node {
+    box-sizing: border-box;
+    display: inline-block;
+    position: relative;
+    margin: 0;
+    padding: 3px;
+    border: 2px dashed transparent;
+    text-align: center;
+    user-select: none;
+    cursor: move;
+    width: 100%;
+
+    .roy-datasource-node--title {
+      text-align: center;
+      font-size: 12px;
+      font-weight: 700;
+      height: 20px;
+      line-height: 20px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      background-color: var(--roy-color-primary);
+      color: var(--roy-bg-color-page);
+      border-radius: 4px 4px 0 0;
+    }
+
+    .roy-datasource-node--text {
+      margin: auto;
+      display: block;
+      width: 35%;
+      text-overflow: ellipsis;
+      overflow: hidden;
+    }
+
+    .roy-datasource-node--content {
+      box-sizing: border-box;
+      width: 100%;
+      height: 20px;
+      font-size: 11px;
+      line-height: 18px;
+      border: 1px solid var(--roy-color-primary);
+      border-radius: 0 0 4px 4px;
+      text-align: center;
+      background-color: var(--roy-bg-color-page);
+      color: var(--roy-text-color-primary);
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    .roy-datasource-node--tag {
+      font-size: 10px;
+      position: absolute;
+      color: #fff;
+      background: var(--roy-color-info);
+      font-weight: 100;
+      border-radius: 2px;
+      height: 13px;
+      line-height: 13px;
+      zoom: 0.8;
+      padding: 2px;
+      left: 10px;
+      top: 8px;
+    }
+  }
+}
+</style>

+ 222 - 0
src/components/DataSourceMaintain.vue

@@ -0,0 +1,222 @@
+<template>
+  <!--<RoyModal-->
+  <!--  v-if="visible"-->
+  <!--  :show="visible"-->
+  <!--  class="DataSourceMaintain"-->
+  <!--  height="80%"-->
+  <!--  title="数据源设置"-->
+  <!--  width="60%"-->
+  <!--  @close="handleModalClose"-->
+  <!--  @mousedown.stop.prevent="handleMouseDown"-->
+  <!--&gt;-->
+  <!--  <vxe-grid-->
+  <!--    ref="xGrid"-->
+  <!--    :data="tableDataIn"-->
+  <!--    height="100%"-->
+  <!--    v-bind="gridOptions"-->
+  <!--    @toolbar-button-click="handleToolbarButtonClick"-->
+  <!--  />-->
+  <!--</RoyModal>-->
+</template>
+
+<script>
+import commonMixin from '@/mixin/commonMixin'
+// import RoyModal from '@/components/RoyModal/RoyModal'
+
+/**
+ * 数据源设置
+ */
+export default {
+  name: 'DataSourceMaintain',
+  mixins: [commonMixin],
+  components: {
+    // RoyModal
+  },
+  props: {
+    visible: {
+      type: Boolean,
+      default: false
+    }
+  },
+  computed: {
+    // ...mapState({
+    //   dataSource: (state) => state.printTemplateModule.dataSource
+    // })
+  },
+  data() {
+    return {
+      tableDataIn: [],
+      gridOptions: {
+        border: true,
+        showHeaderOverflow: true,
+        showOverflow: true,
+        keepSource: true,
+        id: 'full_edit_1',
+        rowId: 'id',
+        rowConfig: {
+          isHover: true
+        },
+        columnConfig: {
+          resizable: true
+        },
+        toolbarConfig: {
+          buttons: [
+            {
+              code: 'save',
+              name: '保存',
+              icon: 'ri-save-line',
+              status: 'primary'
+            },
+            {
+              code: 'add',
+              name: '新增行',
+              icon: 'ri-add-line'
+            },
+            {
+              code: 'remove',
+              name: '删除行',
+              icon: 'ri-subtract-line'
+            }
+          ],
+          refresh: false,
+          import: false,
+          export: false,
+          print: false,
+          zoom: false,
+          custom: false
+        },
+        columns: [
+          { type: 'checkbox', width: 50 },
+          {
+            field: 'title',
+            title: '数据名称',
+            sortable: true,
+            titlePrefix: { message: '数据源的标识名称' },
+            editRender: {
+              name: '$input',
+              props: { placeholder: '请输入数据名称' }
+            }
+          },
+          {
+            field: 'field',
+            title: '英文字段',
+            sortable: true,
+            titlePrefix: { message: '用于匹配对应数据集' },
+            editRender: {
+              name: '$input',
+              props: { placeholder: '请输入英文字段' }
+            }
+          },
+          {
+            field: 'typeName',
+            title: '类型',
+            sortable: true,
+            titlePrefix: { message: '该数据的类型' },
+            editRender: {
+              name: '$select',
+              options: [
+                {
+                  value: 'String',
+                  label: '文本'
+                },
+                {
+                  value: 'Array',
+                  label: '数组(表格数据)'
+                },
+                {
+                  value: 'Money',
+                  label: '金额'
+                },
+                {
+                  value: 'BigNumber',
+                  label: '中文大写数字'
+                },
+                {
+                  value: 'BigMoney',
+                  label: '中文大写金额'
+                },
+                {
+                  value: 'CurDateTime',
+                  label: '当前日期(2022.11.28)'
+                },
+                {
+                  value: 'BigCurDate',
+                  label: '当前中文日期(二零二二年十一月二十八日)'
+                }
+              ],
+              props: {
+                placeholder: '请选择类型'
+              }
+            }
+          }
+        ],
+        checkboxConfig: {
+          reserve: true,
+          highlight: true,
+          range: false
+        },
+        editRules: {
+          title: [{ required: true, message: '请输入数据名称' }],
+          field: [{ required: true, message: '请输入英文字段名' }],
+          typeName: [{ required: true, message: '请选择数据类型' }]
+        },
+        editConfig: {
+          trigger: 'click',
+          enabled: true,
+          mode: 'row',
+          showStatus: true,
+          showUpdateStatus: true,
+          showInsertStatus: true,
+          showAsterisk: true
+        }
+      }
+    }
+  },
+  methods: {
+    initMounted() {
+      this.tableDataIn = this.deepCopy(this.dataSource)
+    },
+    handleMouseDown() {},
+    handleModalClose() {
+      this.$emit('update:visible', false)
+    },
+    handleToolbarButtonClick({ code }) {
+      switch (code) {
+        case 'save':
+          this.doSaveTableSetting()
+          break
+        case 'add':
+          this.doAddRow()
+          break
+      }
+    },
+    doAddRow() {
+      const defaultData = {
+        id: this.getUuid(16),
+        typeName: 'String',
+        type: String
+      }
+      this.$refs.xGrid.insertAt(defaultData, -1)
+    },
+    doSaveTableSetting() {
+      const tableData = this.$refs.xGrid.getTableData().fullData
+      this.$refs.xGrid.fullValidate(tableData, () => {
+        this.$store.commit('printTemplateModule/setDataSource', tableData)
+        this.handleModalClose()
+      })
+    }
+  },
+  created() {},
+  mounted() {
+    this.initMounted()
+  },
+  watch: {}
+}
+</script>
+
+<style lang="less">
+.DataSourceMaintain {
+  height: 100%;
+  padding: 6px;
+}
+</style>

+ 441 - 0
src/components/GlobalSetting.vue

@@ -0,0 +1,441 @@
+<template>
+  <yvan-print-main class="roy-designer-global">
+    <el-form
+        ref="global-setting-form"
+        :align="formGlobalConfigIn.align"
+        :data="globalSettingConfig"
+        :items="globalSettingItem"
+        :loading="formGlobalConfigIn.loading"
+        :prevent-submit="formGlobalConfigIn.preventSubmit"
+        :rules="{}"
+        :size="formGlobalConfigIn.size"
+        :span="formGlobalConfigIn.span"
+        :title-align="formGlobalConfigIn.titleAlign"
+        :title-colon="formGlobalConfigIn.titleColon"
+        :title-overflow="formGlobalConfigIn.titleOverflow"
+        :title-width="formGlobalConfigIn.titleWidth"
+        :valid-config="formGlobalConfigIn.validConfig"
+        sync-resize
+    />
+    <el-row class="roy-designer-global__pages">
+      <el-col :span="24" class="roy-designer-global__title">纸张大小:</el-col>
+      <el-col :span="24">
+        <div class="roy-designer-global__pages__container">
+          <div
+              v-for="page in Object.values(pages)"
+              :key="page.name"
+              :class="currentPage === page.name ? 'roy-designer-global__pages__item--active' : ''"
+              class="roy-designer-global__pages__item"
+              @click="currentPage = page.name"
+          >
+            {{ page.name }}
+          </div>
+        </div>
+      </el-col>
+    </el-row>
+  </yvan-print-main>
+</template>
+
+<script>
+import {mapActions, mapState} from "pinia";
+import {globalStore, ruleStore} from "@/store";
+import commonMixin from '@/mixin/commonMixin'
+import YvanPrintMain from "@/components/yvan-ui/yvan-print-main.vue";
+
+/**
+ * GlobalSetting
+ */
+export default {
+  name: 'GlobalSetting',
+  mixins: [commonMixin],
+  components: {YvanPrintMain},
+  props: {},
+  data() {
+    return {
+      pages: {
+        // A1: {
+        //   name: 'A1',
+        //   w: 594,
+        //   h: 841
+        // },
+        // A2: {
+        //   name: 'A2',
+        //   w: 420,
+        //   h: 594
+        // },
+        A3: {
+          name: 'A3',
+          w: 297,
+          h: 420
+        },
+        A4: {
+          name: 'A4',
+          w: 210,
+          h: 297
+        },
+        A5: {
+          name: 'A5',
+          w: 148,
+          h: 210
+        },
+        A6: {
+          name: 'A6',
+          w: 105,
+          h: 148
+        },
+        A7: {
+          name: 'A7',
+          w: 74,
+          h: 105
+        },
+        // B1: {
+        //   name: 'B1',
+        //   w: 707,
+        //   h: 1000
+        // },
+        // B2: {
+        //   name: 'B2',
+        //   w: 500,
+        //   h: 707
+        // },
+        B3: {
+          name: 'B3',
+          w: 353,
+          h: 500
+        },
+        B4: {
+          name: 'B4',
+          w: 250,
+          h: 353
+        },
+        B5: {
+          name: 'B5',
+          w: 176,
+          h: 250
+        },
+        B6: {
+          name: 'B6',
+          w: 125,
+          h: 176
+        },
+        B7: {
+          name: 'B7',
+          w: 88,
+          h: 125
+        },
+        // C1: {
+        //   name: 'C1',
+        //   w: 648,
+        //   h: 917
+        // },
+        // C2: {
+        //   name: 'C2',
+        //   w: 458,
+        //   h: 648
+        // },
+        C3: {
+          name: 'C3',
+          w: 324,
+          h: 458
+        },
+        C4: {
+          name: 'C4',
+          w: 229,
+          h: 324
+        },
+        C5: {
+          name: 'C5',
+          w: 162,
+          h: 229
+        },
+        C6: {
+          name: 'C6',
+          w: 114,
+          h: 162
+        },
+        C7: {
+          name: 'C7',
+          w: 81,
+          h: 114
+        }
+      },
+      currentPage: 'A4',
+      globalSettingConfig: {},
+      formGlobalConfigIn: {
+        titleOverflow: true,
+        span: 8,
+        align: 'left',
+        size: 'medium',
+        titleAlign: 'right',
+        titleWidth: '200',
+        titleColon: false,
+        preventSubmit: false,
+        loading: false,
+        validConfig: {
+          autoPos: true
+        }
+      },
+      globalSettingItem: [
+        {
+          title: '模板名称',
+          field: 'title',
+          span: 24,
+          itemRender: {
+            name: '$input'
+          }
+        },
+        {
+          title: '纸张方向',
+          field: 'pageDirection',
+          span: 24,
+          itemRender: {
+            name: '$select',
+            options: [
+              {
+                label: '横向',
+                value: 'l'
+              },
+              {
+                label: '纵向',
+                value: 'p'
+              }
+            ],
+            props: {
+              disabled: true
+            }
+          }
+        },
+        {
+          title: '页面上边距',
+          field: 'pageMarginTop',
+          span: 24,
+          itemRender: {
+            name: '$input',
+            props: {
+              type: 'number',
+              min: 0,
+              max: 50
+            }
+          }
+        },
+        {
+          title: '页面下边距',
+          field: 'pageMarginBottom',
+          span: 24,
+          itemRender: {
+            name: '$input',
+            props: {
+              type: 'number',
+              min: 0,
+              max: 50
+            }
+          }
+        },
+        {
+          title: '背景颜色',
+          field: 'background',
+          span: 24,
+          itemRender: {
+            name: '$colorPicker',
+            props: {}
+          }
+        },
+        {
+          title: '默认字体',
+          field: 'fontFamily',
+          span: 24,
+          itemRender: {
+            name: '$select',
+            options: [
+              {
+                label: '宋体',
+                value: 'simsun'
+              },
+              {
+                label: '黑体',
+                value: 'simhei'
+              },
+              {
+                label: '楷体',
+                value: 'kaiti'
+              },
+              {
+                label: '仿宋',
+                value: 'fangsong'
+              },
+              {
+                label: '微软雅黑',
+                value: 'Microsoft YaHei'
+              }
+            ]
+          }
+        },
+        {
+          title: '默认行高',
+          field: 'lineHeight',
+          span: 24,
+          itemRender: {
+            name: '$select',
+            options: [
+              {
+                value: '1',
+                label: '1'
+              },
+              {
+                value: '1.5',
+                label: '1.5'
+              },
+              {
+                value: '2',
+                label: '2'
+              },
+              {
+                value: '2.5',
+                label: '2.5'
+              },
+              {
+                value: '3',
+                label: '3'
+              }
+            ]
+          }
+        }
+        // {
+        //   title: '默认字体颜色',
+        //   field: 'color',
+        //   span: 24,
+        //   itemRender: {
+        //     name: '$colorPicker',
+        //     props: {}
+        //   }
+        // },
+        // {
+        //   title: '默认字体大小(pt)',
+        //   field: 'fontSize',
+        //   span: 24,
+        //   itemRender: {
+        //     name: '$input',
+        //     props: {
+        //       type: 'number',
+        //       size: 'mini',
+        //       min: 10,
+        //       max: 120
+        //     }
+        //   }
+        // }
+      ]
+    }
+  },
+  computed: {
+    ...mapState(globalStore, {
+      pageConfig: (state) => state.pageConfig
+    })
+  },
+  methods: {
+    ...mapActions(globalStore, ['setPageSize', 'setPageConfig']),
+    ...mapActions(ruleStore, ['setReDrawRuler', 'setRect']),
+    initMounted() {
+      this.globalSettingConfig = this.deepCopy(this.pageConfig)
+    }
+  },
+  created() {
+  },
+  mounted() {
+    this.initMounted()
+  },
+  watch: {
+    currentPage(newVal) {
+      let page = this.pages[newVal]
+      this.setRect(page)
+      this.setReDrawRuler()
+      this.setPageSize({
+        pageSize: newVal,
+        w: page.w,
+        h: page.h
+      })
+    },
+    globalSettingConfig(newVal) {
+      this.setPageConfig(newVal)
+    }
+  }
+}
+</script>
+
+<style lang="less">
+.roy-designer-global {
+  height: 100%;
+  padding: 12px 8px;
+  font-size: 12px;
+
+  .vxe-form.size--medium .vxe-form--item-inner {
+    display: grid;
+  }
+
+  .vxe-form--item-title {
+    font-size: 10px;
+    text-align: left !important;
+    margin-bottom: 5px;
+
+    .vxe-form--item-title-label:before {
+      content: '';
+      width: 1px;
+      height: 80%;
+      margin-right: 5px;
+      border-left: var(--roy-color-primary) 3px solid;
+    }
+  }
+
+  .vxe-form--item {
+    float: inherit !important;
+  }
+
+  .vxe-input--inner {
+    border-radius: unset;
+    background: transparent;
+    color: var(--roy-text-color-primary);
+    border-color: var(--roy-border-color);
+  }
+
+  .roy-designer-global__pages {
+    .roy-designer-global__pages__container {
+      margin: 8px;
+      display: grid;
+      grid-template-columns: repeat(4, auto);
+      grid-gap: 5px;
+      grid-template-rows: 50px;
+    }
+
+    .roy-designer-global__pages__item {
+      font-size: 16px;
+      line-height: 50px;
+      text-align: center;
+      border: 1px solid #ccc;
+      user-select: none;
+      cursor: pointer;
+
+      &:hover {
+        border: 1px solid #4579e1;
+        background: var(--prism-background);
+      }
+
+      &.roy-designer-global__pages__item--active {
+        border: 1px solid #4579e1;
+        color: #4579e1;
+      }
+    }
+  }
+
+  .roy-designer-global__title {
+    padding: 6px 5px;
+    margin: 4px;
+
+    &:before {
+      content: '';
+      width: 1px;
+      height: 80%;
+      margin-right: 5px;
+      border-left: var(--roy-color-primary) 3px solid;
+    }
+  }
+}
+</style>

+ 81 - 0
src/components/PageComponent.vue

@@ -0,0 +1,81 @@
+<template>
+  <div class="roy-page-component" @dragstart="handleDragStart">
+    <div
+      v-for="(item, index) in componentItems"
+      :key="item.code"
+      :data-index="index"
+      class="roy-page-component__item"
+      draggable="true"
+    >
+      <i :class="item.icon"></i>
+      <span>{{ item.name }}</span>
+    </div>
+  </div>
+</template>
+
+<script>
+
+export default {
+  name: 'PageComponent',
+  data() {
+    return {
+      componentItems: []
+    }
+  },
+  methods: {
+    handleDragStart(e) {
+      e.dataTransfer.setData('index', e.target.dataset.index)
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.roy-page-component {
+  align-items: center;
+  grid-auto-rows: 105px;
+  grid-template-columns: auto auto auto;
+  overflow: auto;
+  height: calc(100% - 16px);
+  padding: 8px;
+  display: grid;
+  justify-content: center;
+}
+
+.roy-page-component__item {
+  background: var(--roy-bg-color);
+  border-radius: 6px;
+  box-shadow: rgba(99, 99, 99, 0.2) 0 2px 8px 0;
+  display: grid;
+  text-align: center;
+  align-content: center;
+  border: 1px solid var(--roy-border-color-dark);
+  user-select: none;
+  width: 68px;
+  min-width: 67px;
+  max-width: 69px;
+  height: 95px;
+  margin: 0 4px;
+  color: var(--roy-text-color-regular);
+
+  &:hover {
+    box-shadow: rgba(0, 0, 0, 0.19) 0 10px 20px, rgba(0, 0, 0, 0.23) 0 6px 6px;
+    cursor: grab;
+    animation-name: pulse;
+    animation-duration: 1s;
+    animation-delay: 1.5s;
+    animation-iteration-count: infinite;
+    border: 2px solid var(--roy-color-primary);
+  }
+
+  i {
+    font-size: 32px;
+    margin: 0;
+  }
+
+  span {
+    font-size: 10px;
+    padding-top: 10px;
+  }
+}
+</style>

+ 54 - 0
src/components/PageComponents/RoyCircle.vue

@@ -0,0 +1,54 @@
+<template>
+  <div class="RoyCircle">
+    <StyledCircle v-bind="style" />
+  </div>
+</template>
+
+<script>
+import commonMixin from '@/mixin/commonMixin'
+import { StyledCircle } from '@/components/PageComponents/style'
+
+/**
+ * 矩形组件
+ */
+export default {
+  name: 'RoyCircle',
+  mixins: [commonMixin],
+  components: {
+    StyledCircle
+  },
+  props: {
+    element: {
+      type: Object,
+      default: () => {}
+    },
+    propValue: {
+      type: String,
+      default: ''
+    }
+  },
+  computed: {
+    style() {
+      return this.element.style || {}
+    }
+  },
+  data() {
+    return {}
+  },
+  methods: {
+    initMounted() {}
+  },
+  created() {},
+  mounted() {
+    this.initMounted()
+  },
+  watch: {}
+}
+</script>
+
+<style lang="less">
+.RoyCircle {
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 70 - 0
src/components/PageComponents/RoyGroup.vue

@@ -0,0 +1,70 @@
+<template>
+  <div class="roy-group">
+    <div>
+      <component
+        :is="item.component"
+        v-for="item in propValue"
+        :id="'roy-component-' + item.id"
+        :key="item.id"
+        :element="item"
+        :prop-value="item.propValue"
+        :request="item.request"
+        :style="item.groupStyle"
+        class="roy-group-component"
+      />
+    </div>
+  </div>
+</template>
+
+<script>
+import commonMixin from '@/mixin/commonMixin'
+import RoyText from '@/components/PageComponents/RoyText'
+import RoyRect from '@/components/PageComponents/RoyRect'
+
+/**
+ * roy-group
+ */
+export default {
+  name: 'RoyGroup',
+  mixins: [commonMixin],
+  components: {
+    RoyText,
+    RoyRect
+  },
+  props: {
+    propValue: {
+      type: Array,
+      default: () => []
+    },
+    element: {
+      type: Object,
+      default: () => {}
+    }
+  },
+  data() {
+    return {}
+  },
+  methods: {
+    initMounted() {}
+  },
+  created() {},
+  mounted() {
+    this.initMounted()
+  },
+  watch: {}
+}
+</script>
+
+<style lang="less" scoped>
+.roy-group {
+  & > div {
+    position: relative;
+    width: 100%;
+    height: 100%;
+
+    .roy-group-component {
+      position: absolute;
+    }
+  }
+}
+</style>

+ 58 - 0
src/components/PageComponents/RoyImage.vue

@@ -0,0 +1,58 @@
+<template>
+  <div class="RoyImage">
+    <StyledImage v-bind="style">
+      <img :alt="element.title || 'RoyImage'" :src="element.src" />
+    </StyledImage>
+  </div>
+</template>
+
+<script>
+import commonMixin from '@/mixin/commonMixin'
+import { StyledImage } from '@/components/PageComponents/style'
+
+/**
+ * 图片组件
+ */
+export default {
+  name: 'RoyImage',
+  mixins: [commonMixin],
+  components: {
+    StyledImage
+  },
+  props: {
+    element: {
+      type: Object,
+      default: () => {}
+    },
+    propValue: {
+      type: Object,
+      default: () => {
+        return {}
+      }
+    }
+  },
+  computed: {
+    style() {
+      return this.element.style || {}
+    }
+  },
+  data() {
+    return {}
+  },
+  methods: {
+    initMounted() {}
+  },
+  created() {},
+  mounted() {
+    this.initMounted()
+  },
+  watch: {}
+}
+</script>
+
+<style lang="less">
+.RoyImage {
+  height: 100%;
+  padding: 0;
+}
+</style>

+ 54 - 0
src/components/PageComponents/RoyLine.vue

@@ -0,0 +1,54 @@
+<template>
+  <div class="yvan-print-line">
+    <StyledLine v-bind="style" />
+  </div>
+</template>
+
+<script>
+import commonMixin from '@/mixin/commonMixin'
+import { StyledLine } from '@/components/PageComponents/style'
+
+/**
+ * 矩形组件
+ */
+export default {
+  name: 'RoyLine',
+  mixins: [commonMixin],
+  components: {
+    StyledLine
+  },
+  props: {
+    element: {
+      type: Object,
+      default: () => {}
+    },
+    propValue: {
+      type: String,
+      default: ''
+    }
+  },
+  computed: {
+    style() {
+      return this.element.style || {}
+    }
+  },
+  data() {
+    return {}
+  },
+  methods: {
+    initMounted() {}
+  },
+  created() {},
+  mounted() {
+    this.initMounted()
+  },
+  watch: {}
+}
+</script>
+
+<style lang="less">
+.yvan-print-line {
+  height: 100%;
+  width: 100%;
+}
+</style>

+ 54 - 0
src/components/PageComponents/RoyRect.vue

@@ -0,0 +1,54 @@
+<template>
+  <div class="RoyRect">
+    <StyledRect v-bind="style" />
+  </div>
+</template>
+
+<script>
+import commonMixin from '@/mixin/commonMixin'
+import { StyledRect } from '@/components/PageComponents/style'
+
+/**
+ * 矩形组件
+ */
+export default {
+  name: 'RoyRect',
+  mixins: [commonMixin],
+  components: {
+    StyledRect
+  },
+  props: {
+    element: {
+      type: Object,
+      default: () => {}
+    },
+    propValue: {
+      type: String,
+      default: ''
+    }
+  },
+  computed: {
+    style() {
+      return this.element.style || {}
+    }
+  },
+  data() {
+    return {}
+  },
+  methods: {
+    initMounted() {}
+  },
+  created() {},
+  mounted() {
+    this.initMounted()
+  },
+  watch: {}
+}
+</script>
+
+<style lang="less">
+.RoyRect {
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 196 - 0
src/components/PageComponents/RoySimpleText.vue

@@ -0,0 +1,196 @@
+<template>
+  <div
+    class="RoySimpleText"
+    style="width: 100%; height: 100%"
+    @click="setEdit"
+    @contextmenu="setEdit"
+    @dragenter="handleDragEnter"
+    @dragleave="handleDragLeave"
+    @drop="handleDrop"
+  >
+    <StyledSimpleText
+        ref="editArea"
+        :class="{
+        'can-edit': canEdit,
+        'is-drag-over': dragOver
+      }"
+        :contenteditable="canEdit"
+        class="edit-area"
+        tabindex="0"
+        v-bind="style"
+        @blur="handleBlur"
+        @keydown="handleKeyDown"
+        @mousedown="handleMouseDown"
+        @paste="clearStyle"
+    >
+      <div class="roy-simple-text-inner" v-html="propValue"></div>
+    </StyledSimpleText>
+  </div>
+</template>
+
+<script>
+import { mapState } from 'pinia'
+import {globalStore} from "@/store";
+import { StyledSimpleText } from '@/components/PageComponents/style'
+import commonMixin from '@/mixin/commonMixin'
+import toast from '@/utils/toast'
+
+/**
+ *
+ */
+export default {
+  name: 'RoySimpleText',
+  mixins: [commonMixin],
+  components: {
+    StyledSimpleText
+  },
+  props: {
+    element: {
+      type: Object,
+      default: () => {}
+    },
+    propValue: {
+      type: String,
+      default: ''
+    },
+    bindValue: {
+      type: Object,
+      default: null
+    }
+  },
+  computed: {
+    ...mapState(globalStore, {
+      curComponent: (state) => state.curComponent,
+      dataSource: (state) => state.dataSource
+    }),
+    style() {
+      return this.element.style || {}
+    },
+    mayEdit() {
+      return this.curComponent?.id === this.element?.id
+    }
+  },
+  data() {
+    return {
+      canEdit: false,
+      dragOver: false
+    }
+  },
+  methods: {
+    initMounted() {},
+    setEdit() {
+      if (this.canEdit) {
+        return
+      }
+      if (this.bindValue) {
+        return
+      }
+      if (this.element.isLock) {
+        return
+      }
+      this.canEdit = true
+      // 全选
+      this.selectText(this.$refs.editArea.$el)
+      // 聚焦
+      this.$refs.editArea.$el.focus()
+    },
+    selectText(element) {
+      const selection = window.getSelection()
+      const range = document.createRange()
+      range.selectNodeContents(element)
+      selection.removeAllRanges()
+      selection.addRange(range)
+    },
+    handleDrop(e) {
+      e.preventDefault()
+      e.stopPropagation()
+
+      this.dragOver = false
+
+      const index = e.dataTransfer.getData('datasource-index')
+      if (index) {
+        let bindingDataSource = this.dataSource[index]
+        if (bindingDataSource) {
+          this.$store.commit('printTemplateModule/setBindValue', {
+            id: this.element.id,
+            bindValue: bindingDataSource
+          })
+          this.$store.commit('printTemplateModule/setPropValue', {
+            id: this.element.id,
+            propValue: `<span class="roy-binding-value">[绑定:${bindingDataSource.title}]</span>`
+          })
+          this.canEdit = false
+        }
+      } else {
+        toast('拖拽元素非数据源元素,此次拖拽无效', 'info')
+      }
+    },
+    handleBlur() {
+      this.canEdit = false
+    },
+    handleMouseDown(e) {
+      if (this.canEdit) {
+        e.stopPropagation()
+      }
+    },
+    handleKeyDown(e) {
+      if (this.canEdit && [13].includes(e.keyCode)) {
+        e.preventDefault()
+        document.execCommand('insertLineBreak')
+        return false
+      }
+    },
+    clearStyle(e) {
+      this.$emit('input', this.element, e.target.innerHTML)
+    },
+    handleDragEnter() {
+      this.dragOver = true
+    },
+    handleDragLeave() {
+      this.dragOver = false
+    }
+  },
+  created() {
+    console.log(" RoySimpleText >>> ", this)
+  },
+  mounted() {
+    this.initMounted()
+  },
+  watch: {
+    mayEdit(newVal) {
+      if (!newVal) {
+        this.canEdit = false
+      }
+    },
+    canEdit(newVal) {
+      if (!newVal) {
+        globalStore().setPropValue({
+          id: this.element.id,
+          propValue: this.$refs.editArea.$el.innerHTML
+        })
+      }
+    }
+  }
+}
+</script>
+
+<style lang="less">
+.RoySimpleText {
+  .edit-area {
+    width: 100%;
+    height: 100%;
+    outline: none;
+    word-break: break-all;
+  }
+
+  .is-drag-over {
+    border: 2px solid var(--roy-color-warning);
+    background: #cccccc;
+  }
+
+  .can-edit {
+    height: 100%;
+    cursor: text;
+  }
+}
+</style>

+ 56 - 0
src/components/PageComponents/RoyStar.vue

@@ -0,0 +1,56 @@
+<template>
+  <div class="RoyStar">
+    <StyledStar v-bind="style">
+      <span :class="style.icon" class="iconfont roy-star-icon"></span>
+    </StyledStar>
+  </div>
+</template>
+
+<script>
+import commonMixin from '@/mixin/commonMixin'
+import { StyledStar } from '@/components/PageComponents/style'
+
+/**
+ * 五角星
+ */
+export default {
+  name: 'RoyStar',
+  mixins: [commonMixin],
+  components: {
+    StyledStar
+  },
+  props: {
+    element: {
+      type: Object,
+      default: () => {}
+    },
+    propValue: {
+      type: String,
+      default: ''
+    }
+  },
+  computed: {
+    style() {
+      return this.element.style || {}
+    }
+  },
+  data() {
+    return {}
+  },
+  methods: {
+    initMounted() {}
+  },
+  created() {},
+  mounted() {
+    this.initMounted()
+  },
+  watch: {}
+}
+</script>
+
+<style lang="less">
+.RoyStar {
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 91 - 0
src/components/PageComponents/RoyTable/ResizeObserver.js

@@ -0,0 +1,91 @@
+/**
+ * @description 监听元素尺寸变化
+ */
+export default class ResizeObserver {
+  constructor() {
+    this.passiveEvents = false
+    try {
+      let opts = Object.defineProperty({}, 'passive', {
+        // eslint-disable-next-line getter-return
+        get: function () {
+          this.passiveEvents = { passive: true }
+        }
+      })
+      window.addEventListener('test', null, opts)
+      // eslint-disable-next-line no-empty
+    } catch (e) {}
+  }
+
+  onElResize(el, handler) {
+    if (!(el instanceof HTMLElement)) {
+      throw new TypeError("Parameter 1 is not instance of 'HTMLElement'.")
+    }
+    // https://www.w3.org/TR/html/syntax.html#writing-html-documents-elements
+    if (
+      /^(area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr|script|style|textarea|title)$/i.test(
+        el.tagName
+      )
+    ) {
+      throw new TypeError(
+        'Unsupported tag type. Change the tag or wrap it in a supported tag(e.g. div).'
+      )
+    }
+    if (typeof handler !== 'function') {
+      throw new TypeError("Parameter 2 is not of type 'function'.")
+    }
+
+    let lastWidth = el.offsetWidth || 1
+    let lastHeight = el.offsetHeight || 1
+    let maxWidth = 10000 * lastWidth
+    let maxHeight = 10000 * lastHeight
+
+    let expand = document.createElement('div')
+    expand.style.cssText =
+      'position:absolute;top:0;bottom:0;left:0;right:0;z-index=-10000;overflow:hidden;visibility:hidden;'
+    let shrink = expand.cloneNode(false)
+
+    let expandChild = document.createElement('div')
+    expandChild.style.cssText = 'transition:0s;animation:none;'
+    let shrinkChild = expandChild.cloneNode(false)
+
+    expandChild.style.width = maxWidth + 'px'
+    expandChild.style.height = maxHeight + 'px'
+    shrinkChild.style.width = '250%'
+    shrinkChild.style.height = '250%'
+
+    expand.appendChild(expandChild)
+    shrink.appendChild(shrinkChild)
+    el.appendChild(expand)
+    el.appendChild(shrink)
+
+    if (expand.offsetParent !== el) {
+      el.style.position = 'relative'
+    }
+
+    expand.scrollTop = shrink.scrollTop = maxHeight
+    expand.scrollLeft = shrink.scrollLeft = maxWidth
+
+    let newWidth = 0
+    let newHeight = 0
+    const onResize = () => {
+      if (newWidth !== lastWidth || newHeight !== lastHeight) {
+        lastWidth = newWidth
+        lastHeight = newHeight
+        handler()
+      }
+    }
+
+    const onScroll = () => {
+      newWidth = el.offsetWidth || 1
+      newHeight = el.offsetHeight || 1
+      if (newWidth !== lastWidth || newHeight !== lastHeight) {
+        requestAnimationFrame(onResize)
+      }
+      expand.scrollTop = shrink.scrollTop = maxHeight
+      expand.scrollLeft = shrink.scrollLeft = maxWidth
+    }
+
+    expand.addEventListener('scroll', onScroll, this.passiveEvents)
+    shrink.addEventListener('scroll', onScroll, this.passiveEvents)
+  }
+}

+ 323 - 0
src/components/PageComponents/RoyTable/RoyComplexTable.vue

@@ -0,0 +1,323 @@
+<!--
+* @description 复杂表格
+* @filename RoyComplexTable.vue
+!-->
+<template>
+  <div v-if="initCompleted" class="roy-complex-table">
+    <StyledComplexTable v-bind="style">
+      <table class="roy-complex-table__container">
+        <tr v-if="element.showPrefix">
+          <td>
+            <div class="roy-complex-table__prefix">
+              <RoyTextInTable
+                key="prefix"
+                :element="prefixTextElement"
+                :prop-value="prefixTextElement.propValue"
+                style="min-height: 40px; min-width: 200px"
+                @componentUpdated="componentUpdated"
+              />
+            </div>
+          </td>
+        </tr>
+        <tr v-if="element.showHead">
+          <td>
+            <div class="roy-complex-table__head">
+              <RoySimpleTable
+                key="head"
+                :element="headSimpleTableElement"
+                :prop-value="headSimpleTableElement.propValue"
+                :scale="scale"
+                @componentUpdated="componentUpdated"
+              />
+            </div>
+          </td>
+        </tr>
+        <tr>
+          <td>
+            <div
+              :style="{
+                marginTop: element.showHead ? `-${style.borderWidth - 0.5}px` : '',
+                marginBottom: element.showFoot ? `-${style.borderWidth - 0.5}px` : ''
+              }"
+              class="roy-complex-table__body"
+            >
+              <table :style="`width: ${bodyTableWidth}px`">
+                <thead>
+                  <tr>
+                    <th
+                      v-for="(item, index) in tableCols"
+                      :key="index"
+                      :style="{
+                        width: `${item.width}px`,
+                        height: `${tableRowHeight}px`
+                      }"
+                    >
+                      <div style="display: inline; width: 100%">
+                        {{ item.title }}
+                      </div>
+                    </th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr :style="`height: ${tableRowHeight}px`">
+                    <td :colspan="tableCols.length" :style="`height: ${tableRowHeight}px`">
+                      <div class="roy-complex-table__auto_fill">自动填充</div>
+                    </td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+          </td>
+        </tr>
+        <tr v-if="element.showFoot">
+          <td>
+            <div class="roy-complex-table__foot">
+              <RoySimpleTable
+                key="foot"
+                :element="footSimpleTableElement"
+                :prop-value="footSimpleTableElement.propValue"
+                :scale="scale"
+                @componentUpdated="componentUpdated"
+              />
+            </div>
+          </td>
+        </tr>
+        <tr v-if="element.showSuffix">
+          <td>
+            <div class="roy-complex-table__suffix">
+              <RoyTextInTable
+                key="suffix"
+                :element="suffixTextElement"
+                :prop-value="suffixTextElement.propValue"
+                style="min-height: 40px; min-width: 200px"
+                @componentUpdated="componentUpdated"
+              />
+            </div>
+          </td>
+        </tr>
+      </table>
+    </StyledComplexTable>
+    <TableDataSetting
+      v-if="showTableDataSetting"
+      :table-config="bodyDataTableElement"
+      :visible="showTableDataSetting"
+      @onSave="handleTableSettingSave"
+    />
+  </div>
+</template>
+
+<script>
+import commonMixin from '@/mixin/commonMixin'
+import RoyTextInTable from './RoyTextInTable'
+import RoySimpleTable from './RoySimpleTable'
+import ResizeObserver from '@/components/PageComponents/RoyTable/ResizeObserver'
+import TableDataSetting from '@/components/PageComponents/RoyTable/TableDataSetting'
+import { StyledComplexTable } from '@/components/PageComponents/style'
+import { mapState } from 'vuex'
+
+const defaultTextProp = {
+  icon: 'ri-text',
+  code: 'text',
+  name: '文本',
+  component: 'RoyTextIn',
+  propValue: '',
+  style: {
+    width: '100%',
+    height: '100%',
+    fontSize: 12,
+    background: null,
+    rotate: 0
+  },
+  groupStyle: {}
+}
+
+const defaultSimpleTableProp = {
+  icon: 'ri-table-2',
+  code: 'table',
+  name: '单元格',
+  component: 'RoySimpleTable',
+  propValue: {},
+  style: {
+    width: '100%',
+    height: 'auto',
+    fontSize: 12,
+    background: '#FFFFFF',
+    borderWidth: 2,
+    borderColor: '#212121',
+    rotate: 0,
+    isRelative: true
+  },
+  groupStyle: {}
+}
+
+const defaultDataTableProp = {
+  tableRowHeight: 30,
+  tableDataSource: '',
+  tableCols: [
+    {
+      field: 'field1',
+      title: '表头R1',
+      width: 100,
+      align: 'left',
+      formatter: 'String'
+    },
+    {
+      field: 'field2',
+      title: '表头R2',
+      width: 100,
+      align: 'center',
+      formatter: 'String'
+    },
+    {
+      field: 'field3',
+      title: '表头R3',
+      width: 100,
+      align: 'right',
+      formatter: 'String'
+    }
+  ]
+}
+
+/**
+ * 复杂表格
+ */
+export default {
+  name: 'RoyComplexTable',
+  mixins: [commonMixin],
+  components: {
+    RoyTextInTable,
+    RoySimpleTable,
+    StyledComplexTable,
+    TableDataSetting
+  },
+  props: {
+    element: {
+      type: Object,
+      default: () => {}
+    },
+    propValue: {
+      type: Object,
+      default: () => {
+        return {}
+      }
+    },
+    scale: {
+      required: true,
+      type: [Number, String],
+      default: 1
+    }
+  },
+  computed: {
+    ...mapState({
+      curTableSettingId: (state) => state.printTemplateModule.curTableSettingId
+    }),
+    showTableDataSetting() {
+      return this.curTableSettingId !== null && this.curTableSettingId === this.element.id
+    },
+    style() {
+      return this.element.style || {}
+    },
+    tableCols() {
+      return this.bodyDataTableElement.tableCols || []
+    },
+    tableRowHeight() {
+      return this.bodyDataTableElement.tableRowHeight || 40
+    },
+    bodyTableWidth() {
+      return this.tableCols
+        .map((item) => {
+          return Number(item.width)
+        })
+        .reduce((a, b) => {
+          return a + b
+        })
+    }
+  },
+  data() {
+    return {
+      initCompleted: false,
+      prefixTextElement: {},
+      suffixTextElement: {},
+      bodyDataTableElement: {},
+      headSimpleTableElement: {},
+      footSimpleTableElement: {}
+    }
+  },
+  methods: {
+    initMounted() {
+      const {
+        prefixTextElement,
+        suffixTextElement,
+        headSimpleTableElement,
+        footSimpleTableElement,
+        bodyDataTableElement
+      } = this.propValue
+      this.prefixTextElement = prefixTextElement || this.deepCopy(defaultTextProp)
+      this.suffixTextElement = suffixTextElement || this.deepCopy(defaultTextProp)
+      this.headSimpleTableElement = headSimpleTableElement || this.deepCopy(defaultSimpleTableProp)
+      this.footSimpleTableElement = footSimpleTableElement || this.deepCopy(defaultSimpleTableProp)
+      this.bodyDataTableElement = bodyDataTableElement || this.deepCopy(defaultDataTableProp)
+      setTimeout(() => {
+        this.initCompleted = true
+        // this.observeElementWidth()
+      })
+    },
+    componentUpdated() {
+      this.bodyDataTableElement.bodyTableWidth = this.bodyTableWidth
+      const propValue = Object.assign({}, this.propValue, {
+        prefixTextElement: this.prefixTextElement,
+        suffixTextElement: this.suffixTextElement,
+        headSimpleTableElement: this.headSimpleTableElement,
+        footSimpleTableElement: this.footSimpleTableElement,
+        bodyDataTableElement: this.bodyDataTableElement
+      })
+      this.$store.commit('printTemplateModule/setPropValue', {
+        id: this.element.id,
+        propValue
+      })
+      this.$emit('componentUpdated')
+    },
+    handleTableSettingSave(data) {
+      this.bodyDataTableElement = data
+      this.componentUpdated()
+    },
+    observeElementWidth() {
+      this.$nextTick(() => {
+        const element = this.$el.querySelector('.roy-complex-table__container')
+        if (!element) {
+          return
+        }
+        const resizeObserver = new ResizeObserver()
+        const callback = () => {
+          this.$nextTick(() => {
+            this.$store.commit('printTemplateModule/setShapeStyle', {
+              width: element.clientWidth,
+              height: element.clientHeight
+            })
+          })
+        }
+        resizeObserver.onElResize(element, callback)
+      })
+    }
+  },
+  created() {},
+  mounted() {
+    this.initMounted()
+  },
+  watch: {
+    style: {
+      handler() {
+        Object.assign(this.headSimpleTableElement.style, this.style)
+        Object.assign(this.footSimpleTableElement.style, this.style)
+      },
+      deep: true
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.roy-complex-table {
+  padding: 0;
+}
+</style>

+ 624 - 0
src/components/PageComponents/RoyTable/RoySimpleTable.vue

@@ -0,0 +1,624 @@
+<template>
+  <div class="roy-simple-table">
+    <Context ref="simple-table-contextmenu" :theme="contextTheme">
+      <ContextItem
+        v-for="item in contextMenu"
+        :key="item.code"
+        :class="`roy-context--${item.status}`"
+        @click="item.event"
+      >
+        <i :class="item.icon"></i>
+        <span>{{ item.label }}</span>
+      </ContextItem>
+    </Context>
+    <StyledSimpleTable v-bind="style">
+      <table ref="royTable">
+        <tbody>
+          <tr v-for="(row, index) in tableConfig.rows" :key="index">
+            <td
+              v-for="(col, index) in tableConfig.cols"
+              v-show="isNeedShow(row - 1, col - 1)"
+              :key="index"
+              v-contextmenu="'simple-table-contextmenu'"
+              :class="{
+                'roy-simple-table-cell--selected': getIsActiveCell(row, col)
+              }"
+              :colspan="getItColSpan(row, col)"
+              :rowspan="getItRowSpan(row, col)"
+              :style="{
+                width: `${tableData[`${row}-${col}`].width}px`,
+                height: `${tableData[`${row}-${col}`].height}px`,
+                padding: '0',
+                overflow: 'hidden'
+              }"
+              @contextmenu="handleContendMenu"
+              @mouseup="handleMouseUp"
+              @mousedown.stop="(e) => handleCellMousedown(e, row, col)"
+              @mouseenter.stop.prevent="handleCellMouseenter(row, col)"
+            >
+              <component
+                :is="tableData[`${row}-${col}`] && tableData[`${row}-${col}`].component"
+                v-if="tableData[`${row}-${col}`]"
+                :id="`roy-component-${tableData[`${row}-${col}`].id}`"
+                :bind-value.sync="tableData[`${row}-${col}`].bindValue"
+                :cur-id="curClickedId"
+                :element="tableData[`${row}-${col}`]"
+                :prop-value.sync="tableData[`${row}-${col}`].propValue"
+                :style="{
+                  width: `${tableData[`${row}-${col}`].width}px`,
+                  height: `${tableData[`${row}-${col}`].height}px`
+                }"
+                @activeCell="onCellActive"
+                @componentUpdated="
+                  (value) => {
+                    componentUpdated(row, col, value)
+                  }
+                "
+              />
+              <div
+                v-if="getIsActiveCell(row, col) && selectedCells.length === 1"
+                class="roy-simple-table__cell__corner"
+                @mousedown="handleMouseDownOnResize(row, col, $event)"
+              ></div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </StyledSimpleTable>
+  </div>
+</template>
+<script>
+import { mapState } from 'vuex'
+import { Context, ContextItem, directive } from '@/components/RoyContext'
+import RoySimpleTextIn from './RoySimpleTextInTable.vue'
+import RoyTextIn from '@/components/PageComponents/RoyTable/RoyTextInTable'
+import commonMixin from '@/mixin/commonMixin'
+import toast from '@/utils/toast'
+import { StyledSimpleTable } from '@/components/PageComponents/style'
+
+const defaultTableCell = {
+  icon: 'ri-text',
+  code: 'text',
+  name: '文本',
+  component: 'RoySimpleTextIn',
+  propValue: '',
+  width: 100,
+  height: 30,
+  textStyle: {
+    width: '100%',
+    height: '100%',
+    fontSize: 12,
+    background: null,
+    rotate: 0,
+    padding: '0'
+  },
+  simpleTextStyle: {},
+  style: {
+    color: '#212121',
+    borderRadius: 'inherit',
+    padding: '0',
+    margin: '0',
+    fontFamily: 'default',
+    lineHeight: '1',
+    letterSpacing: '0',
+    borderWidth: 0,
+    borderColor: '#212121',
+    borderType: 'none',
+    width: '100%',
+    height: '100%',
+    fontSize: 12,
+    background: null,
+    rotate: 0,
+    justifyContent: 'flex-start',
+    alignItems: 'center',
+    fontWeight: 'normal',
+    fontStyle: 'normal',
+    isUnderLine: false,
+    isDelLine: false
+  },
+  groupStyle: {}
+}
+
+export default {
+  name: 'RoySimpleTable',
+  mixins: [commonMixin],
+  directives: {
+    contextmenu: directive
+  },
+  components: {
+    Context,
+    ContextItem,
+    RoyTextIn,
+    RoySimpleTextIn,
+    StyledSimpleTable
+  },
+  props: {
+    element: {
+      type: Object,
+      default: () => {}
+    },
+    propValue: {
+      type: Object,
+      default: () => {
+        return {}
+      }
+    },
+    scale: {
+      required: true,
+      type: [Number, String],
+      default: 1
+    }
+  },
+  data() {
+    return {
+      curClickedId: '',
+      // 这块其实初始设置 tableConfig: {cols: 3, rows: 2} 就可以 把tabelDate设置成计算属性,layoutDetail 用js生成更方便
+      tableConfig: {
+        cols: 2,
+        rows: 2,
+        layoutDetail: []
+      },
+      tableData: {
+        '1-1': {
+          ...this.deepCopy(defaultTableCell),
+          id: this.getUuid()
+        },
+        '1-2': {
+          ...this.deepCopy(defaultTableCell),
+          id: this.getUuid()
+        },
+        '2-1': {
+          ...this.deepCopy(defaultTableCell),
+          id: this.getUuid()
+        },
+        '2-2': {
+          ...this.deepCopy(defaultTableCell),
+          id: this.getUuid()
+        }
+      },
+      selectedCells: [],
+      // mousedown的时候设置为其他值 否则都是-1
+      selectionHold: -1,
+      startX: -1,
+      startY: -1,
+      endX: -1,
+      endY: -1,
+      contextPos: {
+        l: 0,
+        t: 0
+      },
+      contextMenu: [
+        {
+          label: '添加行',
+          code: 'addRow',
+          icon: 'ri-insert-row-bottom',
+          status: 'default',
+          event: () => {
+            this.menyItemCmd('addRow')
+          }
+        },
+        {
+          label: '添加列',
+          code: 'addCol',
+          icon: 'ri-insert-column-right',
+          status: 'default',
+          event: () => {
+            this.menyItemCmd('addCol')
+          }
+        },
+        {
+          label: '删除行',
+          code: 'delRow',
+          icon: 'ri-delete-row',
+          status: 'default',
+          event: () => {
+            this.menyItemCmd('delRow')
+          }
+        },
+        {
+          label: '删除列',
+          code: 'delCol',
+          icon: 'ri-delete-column',
+          status: 'default',
+          event: () => {
+            this.menyItemCmd('delCol')
+          }
+        },
+        {
+          label: '合并',
+          code: 'merge',
+          icon: 'ri-merge-cells-horizontal',
+          status: 'default',
+          event: () => {
+            this.menyItemCmd('merge')
+          }
+        },
+        {
+          label: '拆分',
+          code: 'split',
+          icon: 'ri-split-cells-horizontal',
+          status: 'default',
+          event: () => {
+            this.menyItemCmd('split')
+          }
+        },
+        {
+          label: '清空选择',
+          code: 'clearSelection',
+          icon: 'ri-eraser-line',
+          status: 'default',
+          event: () => {
+            this.menyItemCmd('clearSelection')
+          }
+        },
+        {
+          code: 'setting',
+          icon: 'ri-list-settings-line',
+          label: '属性',
+          status: 'default',
+          event: () => {
+            this.$store.commit('printTemplateModule/setPaletteCount')
+          }
+        }
+      ]
+    }
+  },
+  created() {},
+  mounted() {
+    this.initMounted()
+  },
+  computed: {
+    ...mapState({
+      editor: (state) => state.printTemplateModule.editor,
+      isNightMode: (state) => state.printTemplateModule.nightMode.isNightMode
+    }),
+    style() {
+      return this.element.style || {}
+    },
+    contextTheme() {
+      return this.isNightMode ? 'dark' : 'default'
+    },
+    hiddenTdMaps() {
+      let hiddenTdMaps = {}
+      let tableConfig = this.tableConfig
+      for (let i = 0; i < tableConfig.rows; i++) {
+        for (let j = 0; j < tableConfig.cols; j++) {
+          if (tableConfig.layoutDetail[i * tableConfig.cols + j]) {
+            let colInfo = tableConfig.layoutDetail[i * tableConfig.cols + j]
+            if (
+              (colInfo.colSpan && colInfo.colSpan > 1) ||
+              (colInfo.rowSpan && colInfo.rowSpan > 1)
+            ) {
+              for (let row = i; row < i + (colInfo.rowSpan || 1); row++) {
+                // col = (row === i ? j + 1 : j) 是为了避开自己
+                for (let col = row === i ? j + 1 : j; col < j + (colInfo.colSpan || 1); col++) {
+                  hiddenTdMaps[`${row}_${col}`] = true
+                }
+              }
+            }
+          }
+        }
+      }
+      return hiddenTdMaps
+    }
+  },
+  methods: {
+    initMounted() {
+      let preSettled = false
+      if (this.propValue.tableConfig) {
+        preSettled = true
+        this.tableConfig = this.deepCopy(this.propValue.tableConfig)
+      }
+      if (this.propValue.tableData) {
+        preSettled = true
+        this.tableData = this.deepCopy(this.propValue.tableData)
+      }
+      if (!preSettled) {
+        this.reRenderTableLayout()
+      }
+    },
+    clearSelection() {
+      this.selectedCells = []
+    },
+    isNeedShow(row, col) {
+      return !this.hiddenTdMaps[`${row}_${col}`]
+    },
+    getIsActiveCell(row, col) {
+      return this.selectedCells.includes((row - 1) * this.tableConfig.cols + col - 1)
+    },
+    getItColSpan(row, col) {
+      return (
+        this.tableConfig.layoutDetail[(row - 1) * this.tableConfig.cols + col - 1] &&
+        this.tableConfig.layoutDetail[(row - 1) * this.tableConfig.cols + col - 1]['colSpan']
+      )
+    },
+    getItRowSpan(row, col) {
+      return (
+        this.tableConfig.layoutDetail[(row - 1) * this.tableConfig.cols + col - 1] &&
+        this.tableConfig.layoutDetail[(row - 1) * this.tableConfig.cols + col - 1]['rowSpan']
+      )
+    },
+    handleCellMousedown(e, x, y) {
+      this.$refs['simple-table-contextmenu'].hide()
+      this.$store.commit('printTemplateModule/setInEditorStatus', true)
+      this.$store.commit('printTemplateModule/setClickComponentStatus', true)
+      this.$store.commit('printTemplateModule/setCurTableCell', {
+        component: this.tableData[`${x}-${y}`]
+      })
+      // e.witch = 1 是鼠标左键
+      if (e.which !== 1) {
+        if (this.endX === -1 && this.endY === -1) {
+          let cellIndex = (x - 1) * this.tableConfig.cols + y - 1
+          this.startX = x
+          this.startY = y
+          this.selectedCells = [cellIndex]
+        }
+        return
+      }
+      let cellIndex = (x - 1) * this.tableConfig.cols + y - 1
+      this.startX = x
+      this.startY = y
+      this.selectedCells = [cellIndex]
+      this.endX = this.endY = -1
+      // mousedown标志
+      this.selectionHold = cellIndex
+    },
+    handleCellMouseenter(x, y) {
+      if (this.selectionHold !== -1) {
+        this.endX = x
+        this.endY = y
+        this.rendSelectedCell()
+      }
+    },
+    rendSelectedCell() {
+      let startX = Math.min(this.startX, this.endX)
+      let startY = Math.min(this.startY, this.endY)
+      let endX = Math.max(this.startX, this.endX)
+      let endY = Math.max(this.startY, this.endY)
+      let tableConfig = this.tableConfig
+      let selectedCells = []
+      for (let row = 1; row <= tableConfig.rows; row++) {
+        for (let col = 1; col <= tableConfig.cols; col++) {
+          if (row >= startX && row <= endX && col >= startY && col <= endY) {
+            selectedCells.push((row - 1) * this.tableConfig.cols + col - 1)
+          }
+        }
+      }
+      this.selectedCells = selectedCells
+    },
+    handleMouseUp() {
+      this.selectionHold = -1
+    },
+    handleContendMenu(e) {
+      e.preventDefault()
+      e.stopPropagation()
+    },
+    reRenderTableLayout() {
+      let arr = []
+      for (let i = 0; i < this.tableConfig.cols * this.tableConfig.rows; i++) {
+        arr.push({
+          uniId: this.getUuid(),
+          colSpan: 1,
+          rowSpan: 1
+        })
+      }
+      this.tableConfig.layoutDetail = arr
+    },
+    menyItemCmd(cmd) {
+      switch (cmd) {
+        case 'merge':
+          this.mergeCell()
+          break
+        case 'split':
+          this.splitCell()
+          break
+        case 'delRow':
+          if (this.tableConfig.rows === 1) {
+            toast('只剩一行了')
+            return
+          }
+          this.tableConfig.rows = this.tableConfig.rows - 1
+          // 行号 列号变化时候  需要重新渲染 this.tableConfig.layoutDetail
+          this.reRenderTableLayout()
+          break
+        case 'delCol':
+          if (this.tableConfig.cols === 1) {
+            toast('只剩一列了')
+            return
+          }
+          this.tableConfig.cols = this.tableConfig.cols - 1
+          this.reRenderTableLayout()
+          break
+        case 'addRow':
+          this.addRow()
+          break
+        case 'addCol':
+          this.addCol()
+          break
+        case 'clearSelection':
+          this.clearSelection()
+          break
+      }
+    },
+    mergeCell() {
+      let tableConfig = this.tableConfig
+      const startX = Math.min(this.startX, this.endX)
+      const startY = Math.min(this.startY, this.endY)
+      const endX = Math.max(this.startX, this.endX)
+      const endY = Math.max(this.startY, this.endY)
+      const startIndex = (startX - 1) * tableConfig.cols + startY - 1
+      const groupId = this.getUuid()
+      if (
+        startX === -1 ||
+        startY === -1 ||
+        endX === -1 ||
+        endY === -1 ||
+        (startX === endX && startY === endY)
+      ) {
+        toast('请选中要合并的单元格')
+        return
+      }
+      for (let i = startX; i <= endX; i++) {
+        for (let j = startY; j <= endY; j++) {
+          const curIndex = (i - 1) * tableConfig.cols + j - 1
+          this.tableConfig.layoutDetail[curIndex].groupId = groupId
+          if (curIndex === startIndex) {
+            let curTableData = this.tableData[`${i}-${j}`]
+            let startCellData = this.tableData[`${startX}-${startY}`]
+            let endCellData = this.tableData[`${endX}-${endY}`]
+            let startComponent = document
+              .getElementById(`roy-component-${startCellData.id}`)
+              .getBoundingClientRect()
+            let endComponent = document
+              .getElementById(`roy-component-${endCellData.id}`)
+              .getBoundingClientRect()
+            const { x: startAriaX, y: startAriaY } = startComponent
+            const { x: endAriaX, y: endAriaY, width: endWidth, height: endHeight } = endComponent
+            curTableData.width = Math.abs(endAriaX - startAriaX) + endWidth
+            curTableData.height = Math.abs(endAriaY - startAriaY) + endHeight
+            this.tableConfig.layoutDetail[curIndex].rowSpan = endX - startX + 1
+            this.tableConfig.layoutDetail[curIndex].colSpan = endY - startY + 1
+          } else {
+            this.tableConfig.layoutDetail[curIndex].rowSpan = 0
+            this.tableConfig.layoutDetail[curIndex].colSpan = 0
+          }
+        }
+      }
+    },
+    splitCell() {
+      let tableConfig = this.tableConfig
+      let startX = this.startX
+      let startY = this.startY
+      if (startX === -1 || startY === -1) {
+        toast('请选中要拆分的单元格')
+        return
+      }
+      let startIndex = (startX - 1) * tableConfig.cols + startY - 1
+      const { groupId } = this.tableConfig.layoutDetail[startIndex]
+      if (!groupId) {
+        return
+      }
+      this.tableConfig.layoutDetail.forEach((v) => {
+        if (v.groupId && v.groupId === groupId) {
+          v.rowSpan = 1
+          v.colSpan = 1
+          v.groupId = undefined
+        }
+      })
+    },
+    addCol() {
+      this.tableConfig.cols = this.tableConfig.cols + 1
+      const colArray = [...new Array(this.tableConfig.cols).keys()]
+      colArray.map((key) => {
+        const firstCol = this.tableData[`${key + 1}-1`]
+        this.tableData[`${key + 1}-${this.tableConfig.cols}`] = {
+          ...defaultTableCell,
+          height: firstCol ? firstCol.height : defaultTableCell.height,
+          id: this.getUuid()
+        }
+      })
+      this.reRenderTableLayout()
+    },
+    addRow() {
+      this.tableConfig.rows = this.tableConfig.rows + 1
+      const colArray = [...new Array(this.tableConfig.rows).keys()]
+      colArray.map((key) => {
+        const firstRow = this.tableData[`1-${key + 1}`]
+        this.tableData[`${this.tableConfig.rows}-${key + 1}`] = {
+          ...defaultTableCell,
+          width: firstRow ? firstRow.width : defaultTableCell.width,
+          id: this.getUuid()
+        }
+      })
+      this.reRenderTableLayout()
+    },
+    onCellActive({ id }) {
+      this.curClickedId = id
+    },
+    handleMouseDownOnResize(row, col, e) {
+      e.stopPropagation()
+      e.preventDefault()
+      const element = this.tableData[`${row}-${col}`]
+      const curIndex = (row - 1) * this.tableConfig.cols + col - 1
+      const curTableConfig = this.tableConfig.layoutDetail[curIndex]
+      if (!element) {
+        return
+      }
+      const comEle = document.getElementById(`roy-component-${element.id}`)
+      if (!comEle) {
+        return
+      }
+      const move = (moveEvent) => {
+        const { width, height } = comEle.getBoundingClientRect()
+        const deltaX = moveEvent.movementX
+        const deltaY = moveEvent.movementY
+
+        for (let ir = 1; ir <= this.tableConfig.rows; ir++) {
+          const irIndex = (ir - 1) * this.tableConfig.cols + col - 1
+          const irCellConfig = this.tableConfig.layoutDetail[irIndex]
+          if (irCellConfig.colSpan === curTableConfig.colSpan) {
+            this.tableData[`${ir}-${col}`].width = (width + deltaX) / this.scale
+          }
+        }
+        for (let ic = 1; ic <= this.tableConfig.cols; ic++) {
+          const icIndex = (row - 1) * this.tableConfig.cols + ic - 1
+          const icCellConfig = this.tableConfig.layoutDetail[icIndex]
+          if (icCellConfig.rowSpan === curTableConfig.rowSpan) {
+            this.tableData[`${row}-${ic}`].height = (height + deltaY) / this.scale
+          }
+        }
+      }
+      const up = () => {
+        document.removeEventListener('mousemove', move)
+        document.removeEventListener('mouseup', up)
+      }
+      document.addEventListener('mousemove', move)
+      document.addEventListener('mouseup', up)
+    },
+    setTablePropValue() {
+      const propValue = {
+        tableData: this.deepCopy(this.tableData),
+        tableConfig: this.deepCopy(this.tableConfig)
+      }
+      this.$store.commit('printTemplateModule/setPropValue', {
+        id: this.element.id,
+        propValue
+      })
+      // this.$emit('update:propValue', propValue)
+      this.$store.commit('printTemplateModule/updateDataValue', {
+        data: this.element,
+        value: propValue,
+        key: 'propValue'
+      })
+      this.$emit('componentUpdated', propValue)
+    },
+    componentUpdated(row, col, value) {
+      let curTableCell = this.tableData[`${row}-${col}`]
+      curTableCell.propValue = value
+      this.setTablePropValue()
+    }
+  },
+  watch: {
+    tableData: {
+      handler() {
+        this.setTablePropValue()
+      },
+      deep: true,
+      immediate: true
+    },
+    tableConfig: {
+      handler() {
+        this.setTablePropValue()
+      },
+      deep: true,
+      immediate: true
+    }
+  }
+}
+</script>
+
+<style lang="less">
+.roy-simple-table {
+  user-select: none;
+}
+</style>

+ 213 - 0
src/components/PageComponents/RoyTable/RoySimpleTextInTable.vue

@@ -0,0 +1,213 @@
+<!--
+* @description RoySimpleTextInTable
+* @filename RoySimpleText.vue
+!-->
+<template>
+  <div
+    class="RoySimpleText"
+    style="width: 100%; height: 100%"
+    @click="activeCell"
+    @contextmenu="setEdit"
+    @dblclick="setEdit"
+    @dragenter="handleDragEnter"
+    @dragleave="handleDragLeave"
+    @drop="handleDrop"
+  >
+    <StyledSimpleText
+      ref="editArea"
+      :class="{
+        'can-edit': canEdit,
+        'is-drag-over': dragOver
+      }"
+      :contenteditable="canEdit"
+      class="edit-area"
+      tabindex="0"
+      v-bind="style"
+      @blur="handleBlur"
+      @keydown="handleKeyDown"
+      @mousedown="handleMouseDown"
+      @paste="clearStyle"
+    >
+      <div class="roy-simple-text-inner" v-html="propValue"></div>
+    </StyledSimpleText>
+  </div>
+</template>
+
+<script>
+import { StyledSimpleText } from '@/components/PageComponents/style'
+import commonMixin from '@/mixin/commonMixin'
+import { mapState } from 'vuex'
+import toast from '@/utils/toast'
+
+/**
+ *
+ */
+export default {
+  name: 'RoySimpleTextInTable',
+  mixins: [commonMixin],
+  components: {
+    StyledSimpleText
+  },
+  props: {
+    element: {
+      type: Object,
+      default: () => {}
+    },
+    propValue: {
+      type: String,
+      default: ''
+    },
+    bindValue: {
+      type: Object,
+      default: null
+    },
+    curId: {
+      type: String,
+      default: ''
+    }
+  },
+  computed: {
+    ...mapState({
+      curComponent: (state) => state.printTemplateModule.curComponent,
+      dataSource: (state) => state.printTemplateModule.dataSource
+    }),
+    style() {
+      return this.element.style || {}
+    },
+    mayEdit() {
+      return this.curId === this.element?.id
+    }
+  },
+  data() {
+    return {
+      canEdit: false,
+      dragOver: false
+    }
+  },
+  methods: {
+    initMounted() {},
+    activeCell() {
+      this.$emit('activeCell', {
+        id: this.element.id
+      })
+    },
+    setEdit() {
+      if (this.canEdit) {
+        return
+      }
+      if (this.bindValue) {
+        return
+      }
+      if (this.element.isLock) {
+        return
+      }
+      this.canEdit = true
+      // 全选
+      this.selectText(this.$refs.editArea.$el)
+      // 聚焦
+      this.$refs.editArea.$el.focus()
+    },
+    selectText(element) {
+      const selection = window.getSelection()
+      const range = document.createRange()
+      range.selectNodeContents(element)
+      selection.removeAllRanges()
+      selection.addRange(range)
+    },
+    handleBlur() {
+      this.canEdit = false
+    },
+    handleMouseDown(e) {
+      if (this.canEdit) {
+        e.stopPropagation()
+      }
+    },
+    handleKeyDown(e) {
+      if (this.canEdit && [13].includes(e.keyCode)) {
+        e.preventDefault()
+        document.execCommand('insertLineBreak')
+        return false
+      }
+    },
+    clearStyle(e) {
+      this.$emit('input', this.element, e.target.innerHTML)
+    },
+    handleDrop(e) {
+      e.preventDefault()
+      e.stopPropagation()
+
+      this.dragOver = false
+
+      const index = e.dataTransfer.getData('datasource-index')
+      if (index) {
+        let bindingDataSource = this.dataSource[index]
+        if (bindingDataSource) {
+          this.$emit('update:bindValue', bindingDataSource)
+          // this.$emit('update:propValue', `[绑定:${bindingDataSource.title}]`)
+          this.$store.commit('printTemplateModule/updateDataValue', {
+            data: this.element,
+            value: `<span class="roy-binding-value">[绑定:${bindingDataSource.title}]</span>`,
+            key: 'propValue'
+          })
+          this.$emit(
+            'componentUpdated',
+            `<span class="roy-binding-value">[绑定:${bindingDataSource.title}]</span>`
+          )
+          this.canEdit = false
+        }
+      } else {
+        toast('拖拽元素非数据源元素,此次拖拽无效', 'info')
+      }
+    },
+    handleDragEnter() {
+      this.dragOver = true
+    },
+    handleDragLeave() {
+      this.dragOver = false
+    }
+  },
+  created() {},
+  mounted() {
+    this.initMounted()
+  },
+  watch: {
+    mayEdit(newVal) {
+      if (!newVal) {
+        this.canEdit = false
+      }
+    },
+    canEdit(newVal) {
+      if (!newVal) {
+        // this.$emit('update:propValue', this.$refs.editArea.$el.innerHTML)
+        this.$store.commit('printTemplateModule/updateDataValue', {
+          data: this.element,
+          value: this.$refs.editArea.$el.innerHTML,
+          key: 'propValue'
+        })
+        this.$emit('componentUpdated', this.$refs.editArea.$el.innerHTML)
+      }
+    }
+  }
+}
+</script>
+
+<style lang="less">
+.RoySimpleText {
+  .edit-area {
+    width: 100%;
+    height: 100%;
+    outline: none;
+    word-break: break-all;
+  }
+
+  .is-drag-over {
+    border: 2px solid var(--roy-color-warning);
+    background: #cccccc;
+  }
+
+  .can-edit {
+    height: 100%;
+    cursor: text;
+  }
+}
+</style>

+ 113 - 0
src/components/PageComponents/RoyTable/RoyTextInTable.vue

@@ -0,0 +1,113 @@
+<template>
+  <div style="width: 100%; height: 100%" @click="activeCell" @dblclick="onDblClick">
+    <RoyModal
+      v-if="showEditor"
+      :show.sync="showEditor"
+      height="70%"
+      title="长文本编辑"
+      width="60%"
+      @close="handleTextClosed"
+    >
+      <div class="roy-wang-editor" @mousedown="handleMouseDown">
+        <WangToolbar
+          :defaultConfig="toolbarConfig"
+          :editor="wangEditor"
+          style="border-bottom: 1px solid #ccc"
+        />
+        <WangEditor
+          v-model="html"
+          :defaultConfig="editorConfig"
+          :mode="mode"
+          style="height: 300px"
+          @onCreated="onCreated"
+        />
+      </div>
+    </RoyModal>
+    <StyledText v-bind="style">
+      <div class="roy-text-inner" v-html="propValue"></div>
+    </StyledText>
+  </div>
+</template>
+
+<script>
+import { StyledText } from '@/components/PageComponents/style'
+import RoyModal from '@/components/RoyModal/RoyModal'
+import WangToolbar from '@/components/PageComponents/WangEditorVue/WangToolbar'
+import WangEditor from '@/components/PageComponents/WangEditorVue/WangEditor'
+import { toolBarConfig, editorConfig, mode } from '@/components/config/editorConfig'
+import commonMixin from '@/mixin/commonMixin'
+import { mapState } from 'vuex'
+
+export default {
+  name: 'RoyTextInTable',
+  mixins: [commonMixin],
+  props: {
+    element: {
+      type: Object,
+      default: () => {}
+    },
+    propValue: {
+      type: String,
+      default: ''
+    }
+  },
+  components: {
+    WangEditor,
+    WangToolbar,
+    StyledText,
+    RoyModal
+  },
+  computed: {
+    ...mapState({
+      curComponent: (state) => state.printTemplateModule.curComponent
+    }),
+    style() {
+      return this.element.style || {}
+    }
+  },
+  data() {
+    return {
+      wangEditor: null,
+      showEditor: false,
+      html: this.deepCopy(this.propValue),
+      toolbarConfig: toolBarConfig,
+      editorConfig: editorConfig,
+      mode: mode
+    }
+  },
+  methods: {
+    activeCell() {
+      this.$emit('activeCell', {
+        id: this.element.id
+      })
+    },
+    onCreated(editor) {
+      this.wangEditor = Object.seal(editor) // 一定要用 Object.seal() ,否则会报错
+    },
+    onDblClick() {
+      this.showEditor = true
+    },
+    handleMouseDown(e) {
+      e.stopPropagation()
+    },
+    handleTextClosed() {
+      this.$store.commit('printTemplateModule/updateDataValue', {
+        data: this.element,
+        value: this.html,
+        key: 'propValue'
+      })
+      // this.$emit('update:propValue', this.html)
+      this.$emit('componentUpdated', this.html)
+    }
+  },
+  created() {},
+  watch: {},
+  beforeDestroy() {
+    const editor = this.wangEditor
+    if (editor == null) {
+      return
+    }
+    editor.destroy() // 组件销毁时,及时销毁编辑器
+  }
+}
+</script>

+ 319 - 0
src/components/PageComponents/RoyTable/TableDataSetting.vue

@@ -0,0 +1,319 @@
+<!--
+* @description 表格数据设置
+* @filename TableDataSetting.vue
+!-->
+<template>
+  <RoyModal
+    v-if="visible"
+    :show="visible"
+    class="TableDataSetting"
+    height="80%"
+    title="数据表格设置"
+    width="60%"
+    @close="handleModalClose"
+    @mousedown.stop.prevent="handleMouseDown"
+  >
+    <vxe-grid
+      ref="xGrid"
+      :data="tableDataIn"
+      height="100%"
+      v-bind="gridOptions"
+      @toolbar-button-click="handleToolbarButtonClick"
+    >
+      <template v-slot:form>
+        <div class="roy-table-data-form">
+          <div>
+            <span>绑定数据源:</span>
+            <vxe-select v-model="tableDataSource" placeholder="请选择数据源">
+              <vxe-option
+                v-for="opt in dataSourceOption"
+                :key="opt.field"
+                :label="opt.title"
+                :value="opt.field"
+              ></vxe-option>
+            </vxe-select>
+          </div>
+          <div>
+            <span>表格行高:</span>
+            <vxe-input
+              v-model="tableRowHeight"
+              max="100"
+              min="20"
+              placeholder="20-100"
+              type="number"
+            ></vxe-input>
+          </div>
+        </div>
+      </template>
+    </vxe-grid>
+  </RoyModal>
+</template>
+
+<script>
+import commonMixin from '@/mixin/commonMixin'
+import RoyModal from '@/components/RoyModal/RoyModal'
+import { mapState } from 'vuex'
+import toast from '@/utils/toast'
+
+/**
+ * 表格数据设置
+ */
+export default {
+  name: 'TableDataSetting',
+  mixins: [commonMixin],
+  components: {
+    RoyModal
+  },
+  props: {
+    visible: {
+      type: Boolean,
+      default: false
+    },
+    tableConfig: {
+      type: Object,
+      default: () => {
+        return {}
+      }
+    }
+  },
+  computed: {
+    ...mapState({
+      dataSource: (state) => state.printTemplateModule.dataSource
+    }),
+    dataSourceOption() {
+      return this.dataSource.filter((item) => {
+        return item.typeName === 'Array'
+      })
+    }
+  },
+  data() {
+    return {
+      tableDataIn: [],
+      tableDataSource: '',
+      tableRowHeight: 40,
+      gridOptions: {
+        border: true,
+        showHeaderOverflow: true,
+        showOverflow: true,
+        keepSource: true,
+        id: 'full_edit_1',
+        rowId: 'id',
+        rowConfig: {
+          isHover: true
+        },
+        columnConfig: {
+          resizable: true
+        },
+        toolbarConfig: {
+          buttons: [
+            {
+              code: 'save',
+              name: '保存',
+              icon: 'ri-save-line',
+              status: 'primary'
+            },
+            {
+              code: 'add',
+              name: '新增行',
+              icon: 'ri-add-line'
+            },
+            {
+              code: 'remove',
+              name: '删除行',
+              icon: 'ri-subtract-line'
+            }
+          ],
+          refresh: false,
+          import: false,
+          export: false,
+          print: false,
+          zoom: false,
+          custom: false
+        },
+        columns: [
+          { type: 'checkbox', width: 50 },
+          {
+            field: 'title',
+            title: '列标题',
+            sortable: true,
+            titlePrefix: { message: '用于展示于表头名称' },
+            editRender: {
+              name: '$input',
+              props: { placeholder: '请输入列标题' }
+            }
+          },
+          {
+            field: 'field',
+            title: '英文字段',
+            sortable: true,
+            titlePrefix: { message: '用于匹配对应列数据' },
+            editRender: {
+              name: '$input',
+              props: { placeholder: '请输入英文字段' }
+            }
+          },
+          {
+            field: 'align',
+            title: '对齐方式',
+            sortable: true,
+            titlePrefix: { message: '该列的对齐方式' },
+            editRender: {
+              name: '$select',
+              options: [
+                {
+                  value: 'left',
+                  label: '居左'
+                },
+                {
+                  value: 'center',
+                  label: '居中'
+                },
+                {
+                  value: 'right',
+                  label: '居右'
+                }
+              ],
+              props: {
+                placeholder: '请选择对齐方式'
+              }
+            }
+          },
+          {
+            field: 'width',
+            title: '列宽度',
+            sortable: true,
+            titlePrefix: { message: '该列宽度' },
+            editRender: {
+              name: '$input',
+              props: {
+                placeholder: '请输入列宽度',
+                type: 'number',
+                min: 10,
+                max: 1000
+              }
+            }
+          },
+          {
+            field: 'formatter',
+            title: '列类型',
+            sortable: true,
+            titlePrefix: { message: '该列的数据的展现方式' },
+            editRender: {
+              name: '$select',
+              options: [
+                {
+                  value: 'String',
+                  label: '文本'
+                },
+                {
+                  value: 'Money',
+                  label: '金额'
+                },
+                {
+                  value: 'BigNumber',
+                  label: '中文大写数字'
+                },
+                {
+                  value: 'BigMoney',
+                  label: '中文大写金额'
+                }
+              ],
+              props: {
+                placeholder: '请选择列类型'
+              }
+            }
+          }
+        ],
+        checkboxConfig: {
+          reserve: true,
+          highlight: true,
+          range: false
+        },
+        editRules: {
+          title: [{ required: true, message: '请输入列标题' }],
+          field: [{ required: true, message: '请输入英文字段名' }],
+          align: [{ required: true, message: '请选择对齐方式' }],
+          width: [{ required: true, message: '请输入列宽度' }],
+          formatter: [{ required: true, message: '请选择列类型' }]
+        },
+        editConfig: {
+          trigger: 'click',
+          mode: 'row',
+          showStatus: true
+        }
+      }
+    }
+  },
+  methods: {
+    initMounted() {
+      const { tableCols, tableRowHeight, tableDataSource } = this.tableConfig
+      this.tableDataIn = this.deepCopy(tableCols)
+      this.tableRowHeight = tableRowHeight
+      this.tableDataSource = tableDataSource
+    },
+    handleMouseDown() {},
+    handleModalClose() {
+      this.$store.commit('printTemplateModule/setCurTableSettingId', null)
+    },
+    handleToolbarButtonClick({ code }) {
+      switch (code) {
+        case 'save':
+          this.doSaveTableSetting()
+          break
+        case 'add':
+          this.doAddRow()
+          break
+      }
+    },
+    doAddRow() {
+      const defaultData = {
+        width: 100,
+        align: 'left',
+        formatter: 'String'
+      }
+      this.$refs.xGrid.insertAt(defaultData, -1)
+    },
+    doSaveTableSetting() {
+      const tableData = this.$refs.xGrid.getTableData().fullData
+      this.$refs.xGrid.fullValidate(tableData, () => {
+        if (this.isBlank(this.tableDataSource)) {
+          toast('请选择数据源')
+          return
+        }
+        if (this.isBlank(this.tableRowHeight)) {
+          toast('请填写表格行高')
+          return
+        }
+        this.$emit('onSave', {
+          tableCols: tableData,
+          tableRowHeight: this.tableRowHeight,
+          tableDataSource: this.tableDataSource
+        })
+        this.handleModalClose()
+      })
+    }
+  },
+  created() {},
+  mounted() {
+    this.initMounted()
+  },
+  watch: {}
+}
+</script>
+
+<style lang="less">
+.TableDataSetting {
+  height: 100%;
+  padding: 6px;
+
+  .roy-table-data-form {
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+
+    & > div {
+      margin-left: 10px;
+    }
+  }
+}
+</style>

+ 111 - 0
src/components/PageComponents/RoyText.vue

@@ -0,0 +1,111 @@
+<template>
+  <div style="width: 100%; height: 100%" @dblclick="onDblClick">
+<!--    <RoyModal-->
+<!--        v-if="showEditor"-->
+<!--        :show.sync="showEditor"-->
+<!--        height="70%"-->
+<!--        title="长文本编辑"-->
+<!--        width="60%"-->
+<!--        @close="handleTextClosed"-->
+<!--    >-->
+<!--      <div class="roy-wang-editor" @mousedown="handleMouseDown">-->
+<!--        <WangToolbar-->
+<!--            :defaultConfig="toolbarConfig"-->
+<!--            :editor="wangEditor"-->
+<!--            style="border-bottom: 1px solid #ccc"-->
+<!--        />-->
+<!--        <WangEditor-->
+<!--            v-model="html"-->
+<!--            :defaultConfig="editorConfig"-->
+<!--            :mode="mode"-->
+<!--            style="height: 300px"-->
+<!--            @onCreated="onCreated"-->
+<!--        />-->
+<!--      </div>-->
+<!--    </RoyModal>-->
+    <StyledText v-bind="style">
+      <div class="roy-text-inner" v-html="propValue ?? 1234"></div>
+    </StyledText>
+  </div>
+</template>
+
+<script>
+import {mapState} from "pinia";
+import {globalStore} from "@/store";
+import {StyledText} from '@/components/PageComponents/style';
+import RoyModal from '@/components/yvan-ui/yvan-model/RoyModal.vue';
+import WangToolbar from '@/components/PageComponents/WangEditorVue/WangToolbar.vue';
+import WangEditor from '@/components/PageComponents/WangEditorVue/WangEditor.vue';
+import {toolBarConfig, editorConfig, mode} from '@/components/config/editorConfig';
+import commonMixin from '@/mixin/commonMixin';
+
+export default {
+  name: 'RoyText',
+  mixins: [commonMixin],
+  props: {
+    element: {
+      type: Object,
+      default: () => {
+      }
+    },
+    propValue: {
+      type: String,
+      default: ''
+    }
+  },
+  components: {
+    WangEditor,
+    WangToolbar,
+    StyledText,
+    RoyModal
+  },
+  computed: {
+    ...mapState(globalStore, {
+      curComponent: (state) => state.curComponent
+    }),
+    style() {
+      return this.element.style || {}
+    }
+  },
+  data() {
+    return {
+      wangEditor: null,
+      showEditor: false,
+      html: this.deepCopy(this.propValue),
+      toolbarConfig: toolBarConfig,
+      editorConfig: editorConfig,
+      mode: mode
+    }
+  },
+  methods: {
+    onCreated(editor) {
+      this.wangEditor = Object.seal(editor) // 一定要用 Object.seal() ,否则会报错
+    },
+    onBlur() {
+      this.$store.commit('printTemplateModule/setPropValue', {
+        id: this.element.id,
+        propValue: this.html
+      })
+    },
+    onDblClick() {
+      this.showEditor = true
+    },
+    handleMouseDown(e) {
+      e.stopPropagation()
+    },
+    handleTextClosed() {
+      this.onBlur()
+    }
+  },
+  created() {
+  },
+  watch: {},
+  beforeDestroy() {
+    const editor = this.wangEditor
+    if (editor == null) {
+      return
+    }
+    editor.destroy() // 组件销毁时,及时销毁编辑器
+  }
+}
+</script>

+ 131 - 0
src/components/PageComponents/WangEditorVue/WangEditor.vue

@@ -0,0 +1,131 @@
+<script>
+import { createEditor } from '@wangeditor/editor'
+
+function genErrorInfo(fnName) {
+  let info = `请使用 '@${fnName}' 事件,不要放在 props 中`
+  info += `\nPlease use '@${fnName}' event instead of props`
+  return info
+}
+
+export default {
+  //【注意】单独写 <template>...</template> 时,rollup 打包完浏览器运行时报错,所以先在这里写 template
+  render(h) {
+    return h('div', { ref: 'box' })
+  },
+  name: 'WangEditor',
+  data() {
+    return {
+      curValue: '',
+      editor: null
+    }
+  },
+  props: ['defaultContent', 'defaultConfig', 'mode', 'defaultHtml', 'value'], // value 用于自定义 v-model
+  mounted() {
+    this.create()
+  },
+  watch: {
+    // 监听 'value' 属性变化 - value 用于自定义 v-model
+    value(newVal) {
+      const isEqual = newVal === this.curValue
+      if (isEqual) {
+        return
+      } // 和当前内容一样,则忽略
+      // 重置 HTML
+      this.setHtml(newVal)
+    }
+  },
+  methods: {
+    // 重置 HTML
+    setHtml(newHtml) {
+      const editor = this.editor
+      if (editor == null) {
+        return
+      }
+      editor.setHtml(newHtml)
+    },
+    // 创建 editor
+    create() {
+      if (this.$refs.box == null) {
+        return
+      }
+      const defaultConfig = this.defaultConfig || {}
+      const defaultContent = JSON.stringify(
+        Array.isArray(this.defaultContent) ? this.defaultContent : []
+      )
+      createEditor({
+        selector: this.$refs.box,
+        html: this.defaultHtml || this.value || '',
+        config: {
+          ...defaultConfig,
+          onCreated: (editor) => {
+            this.editor = Object.seal(editor) // 注意,一定要用 Object.seal() 否则会报错
+            this.$emit('onCreated', editor)
+            if (defaultConfig.onCreated) {
+              const info = genErrorInfo('onCreated')
+              throw new Error(info)
+            }
+          },
+          onChange: (editor) => {
+            const editorHtml = editor.getHtml()
+            this.curValue = editorHtml // 记录当前 html 内容
+            this.$emit('input', editorHtml) // 用于自定义 v-model
+            this.$emit('onChange', editor)
+            if (defaultConfig.onChange) {
+              const info = genErrorInfo('onChange')
+              throw new Error(info)
+            }
+          },
+          onDestroyed: (editor) => {
+            this.$emit('onDestroyed', editor)
+            if (defaultConfig.onDestroyed) {
+              const info = genErrorInfo('onDestroyed')
+              throw new Error(info)
+            }
+          },
+          onMaxLength: (editor) => {
+            this.$emit('onMaxLength', editor)
+            if (defaultConfig.onMaxLength) {
+              const info = genErrorInfo('onMaxLength')
+              throw new Error(info)
+            }
+          },
+          onFocus: (editor) => {
+            this.$emit('onFocus', editor)
+            if (defaultConfig.onFocus) {
+              const info = genErrorInfo('onFocus')
+              throw new Error(info)
+            }
+          },
+          onBlur: (editor) => {
+            this.$emit('onBlur', editor)
+            if (defaultConfig.onBlur) {
+              const info = genErrorInfo('onBlur')
+              throw new Error(info)
+            }
+          },
+          customAlert: (info, type) => {
+            this.$emit('customAlert', info, type)
+            if (defaultConfig.customAlert) {
+              const info = genErrorInfo('customAlert')
+              throw new Error(info)
+            }
+          },
+          customPaste: (editor, event) => {
+            if (defaultConfig.customPaste) {
+              const info = genErrorInfo('customPaste')
+              throw new Error(info)
+            }
+            let res
+            this.$emit('customPaste', editor, event, (val) => {
+              res = val
+            })
+            return res
+          }
+        },
+        content: JSON.parse(defaultContent),
+        mode: this.mode || 'default'
+      })
+    }
+  }
+}
+</script>

+ 42 - 0
src/components/PageComponents/WangEditorVue/WangToolbar.vue

@@ -0,0 +1,42 @@
+<script>
+import { createToolbar, DomEditor } from '@wangeditor/editor'
+
+export default {
+  name: 'WangToolbar',
+  render(h) {
+    return h('div', { ref: 'box' })
+  },
+  props: ['editor', 'defaultConfig', 'mode'],
+  methods: {
+    // 创建 toolbar
+    create(editor) {
+      if (this.$refs.box == null) {
+        return
+      }
+      if (editor == null) {
+        return
+      }
+      if (DomEditor.getToolbar(editor)) {
+        return
+      } // 不重复创建
+      createToolbar({
+        editor,
+        selector: this.$refs.box,
+        config: this.defaultConfig || {},
+        mode: this.mode || 'default'
+      })
+    }
+  },
+  watch: {
+    editor: {
+      handler(e) {
+        if (e == null) {
+          return
+        }
+        this.create(e)
+      },
+      immediate: true
+    }
+  }
+}
+</script>

+ 432 - 0
src/components/PageComponents/style.js

@@ -0,0 +1,432 @@
+import styled from 'vue3-styled-components'
+
+const commonProps = {
+    width: {
+        type: [Number, String],
+        default: 10
+    },
+    height: {
+        type: [Number, String],
+        default: 10
+    },
+    color: {
+        type: String,
+        default: '#212121'
+    },
+    background: {
+        type: String,
+        default: 'transparent'
+    },
+    border: {
+        type: String,
+        default: 'none'
+    },
+    borderRadius: {
+        type: String,
+        default: 'inherit'
+    },
+    padding: {
+        type: String,
+        default: '0'
+    },
+    margin: {
+        type: String,
+        default: '0'
+    },
+    fontSize: {
+        type: [Number, String],
+        default: 12
+    },
+    zIndex: {
+        type: [Number, String],
+        default: 1
+    },
+    fontFamily: {
+        type: String,
+        default: 'default'
+    },
+    lineHeight: {
+        type: String,
+        default: '1'
+    },
+    letterSpacing: {
+        type: String,
+        default: '0'
+    },
+    justifyContent: {
+        type: String,
+        default: 'flex-start'
+    },
+    alignItems: {
+        type: String,
+        default: 'flex-start'
+    },
+    borderWidth: {
+        type: [Number, String],
+        default: 0
+    },
+    borderColor: {
+        type: String,
+        default: '#212121'
+    },
+    borderType: {
+        type: String,
+        default: 'none'
+    },
+    fontWeight: {
+        type: String,
+        default: 'normal'
+    },
+    fontStyle: {
+        type: String,
+        default: 'normal'
+    },
+    isUnderLine: {
+        type: Boolean,
+        default: false
+    },
+    isDelLine: {
+        type: Boolean,
+        default: false
+    }
+}
+
+const textProps = Object.assign({}, commonProps, {})
+
+const imageProps = Object.assign({}, commonProps, {})
+
+const lineProps = Object.assign({}, commonProps, {})
+
+const starProps = Object.assign({}, commonProps, {})
+
+const circleProps = Object.assign({}, commonProps, {})
+
+const rectProps = Object.assign({}, commonProps, {
+    borderWidth: {
+        type: [Number, String],
+        default: 1
+    }
+})
+
+const simpleTableProps = Object.assign({}, commonProps, {
+    isRelative: {
+        type: Boolean,
+        default: false
+    }
+})
+
+const complexTableProps = Object.assign({}, commonProps, {})
+
+export const StyledText = styled('div', textProps)`
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  position: relative;
+  color: ${(props) => props.color};
+  background: ${(props) => props.background};
+  border-radius: ${(props) => props.borderRadius};
+  margin: ${(props) => props.margin};
+  z-index: ${(props) => props.zIndex};
+  border: ${(props) => {
+    const {borderWidth, borderType, borderColor} = props
+    if (borderType === 'none') {
+      return borderType
+    } else {
+      return `${borderWidth}px ${borderType} ${borderColor}`
+    }
+  }};
+
+  .roy-text-inner {
+    height: 100%;
+    padding: ${(props) => `${props.padding}px`};
+    line-height: ${(props) => props.lineHeight};
+    letter-spacing: ${(props) => `${props.letterSpacing}px`};
+    font-size: ${(props) => `${props.fontSize}pt`};
+    font-family: ${(props) => (props.fontFamily === 'default' ? 'inherit' : `${props.fontFamily}`)};
+  }
+
+  table {
+    table-layout: fixed;
+    border-collapse: separate;
+    border-spacing: 1px;
+    background-color: #000000;
+    padding: 1px;
+  }
+
+  td {
+    position: relative;
+    background-color: #fff;
+    overflow: hidden;
+  }
+
+  p {
+    margin-block-start: 0;
+    margin-block-end: 0;
+  }
+`
+
+export const StyledSimpleText = styled('div', textProps)`
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  position: absolute;
+  color: ${(props) => props.color};
+  background: ${(props) => props.background};
+  border: ${(props) => props.border};
+  border-radius: ${(props) => props.borderRadius};
+  margin: ${(props) => props.margin};
+  font-size: ${(props) => `${props.fontSize}pt`};
+  font-family: ${(props) => (props.fontFamily === 'default' ? 'inherit' : `${props.fontFamily}`)};
+  border: ${(props) => `${props.borderWidth}px ${props.borderType} ${props.borderColor}`};
+  z-index: ${(props) => props.zIndex};
+
+  .roy-simple-text-inner {
+    height: 100%;
+    display: flex;
+    text-align: left;
+    word-break: break-all;
+    justify-content: ${(props) => props.justifyContent};
+    align-items: ${(props) => props.alignItems};
+    padding: ${(props) => `${props.padding}px`};
+    line-height: ${(props) => props.lineHeight};
+    letter-spacing: ${(props) => `${props.letterSpacing}px`};
+    font-weight: ${(props) => `${props.fontWeight}`};
+    font-style: ${(props) => `${props.fontStyle}`};
+    text-decoration: ${(props) => {
+      const {isUnderLine, isDelLine} = props
+      const propsValue = {
+        underline: isUnderLine,
+        'line-through': isDelLine
+      }
+      if (isUnderLine || isDelLine) {
+        return Object.keys(propsValue)
+                .filter((item) => propsValue[item])
+                .join(' ')
+      } else {
+        return 'none'
+      }
+    }};
+  }
+`
+
+export const StyledRect = styled('div', rectProps)`
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  position: absolute;
+  background: ${(props) => props.background};
+  border-radius: ${(props) => props.borderRadius};
+  z-index: ${(props) => props.zIndex};
+  border: ${(props) => {
+    const {borderWidth, borderType, borderColor} = props
+    if (borderType === 'none') {
+      return borderType
+    } else {
+      return `${borderWidth}px ${borderType} ${borderColor}`
+    }
+  }};
+`
+
+export const StyledImage = styled('div', imageProps)`
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  position: absolute;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: ${(props) => props.background};
+  padding: ${(props) => `${props.borderWidth}px`};
+
+  img {
+    height: 100%;
+    border-radius: ${(props) =>
+            isNaN(props.borderRadius) ? props.borderRadius : `${props.borderRadius}px`};
+    z-index: ${(props) => props.zIndex};
+    border: ${(props) => {
+      const {borderWidth, borderType, borderColor} = props
+      if (borderType === 'none') {
+        return borderType
+      } else {
+        return `${borderWidth}px ${borderType} ${borderColor}`
+      }
+    }};
+  }
+`
+
+export const StyledCircle = styled('div', circleProps)`
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  position: absolute;
+  background: ${(props) => props.background};
+  border-radius: 100%;
+  z-index: ${(props) => props.zIndex};
+  border: ${(props) => {
+    const {borderWidth, borderType, borderColor} = props
+    if (borderType === 'none') {
+      return borderType
+    } else {
+      return `${borderWidth}px ${borderType} ${borderColor}`
+    }
+  }};
+`
+
+export const StyledLine = styled('div', lineProps)`
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  position: absolute;
+  background: ${(props) => props.background};
+  z-index: ${(props) => props.zIndex};
+  border: none;
+`
+
+export const StyledStar = styled('div', starProps)`
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  position: absolute;
+  border: none;
+  padding: 0;
+  margin: 0;
+  color: ${(props) => props.background};
+
+  .roy-star-icon {
+    font-size: ${(props) => `${props.height}px!important`};
+    line-height: ${(props) => `${props.height}px!important`};
+  }
+`
+
+export const StyledSimpleTable = styled('div', simpleTableProps)`
+  width: 100%;
+  height: auto;
+  position: ${(props) => (props.isRelative ? 'relative' : 'absolute')};
+  border: none;
+  padding: 0;
+  margin: 0;
+  color: ${(props) => props.color};
+  background: ${(props) => props.background};
+
+  table {
+    width: 100%;
+    //table-layout: fixed;
+    border-collapse: separate;
+    border-spacing: ${(props) => `${props.borderWidth}px`};
+    background-color: ${(props) => `${props.borderColor}`};
+  }
+
+  td {
+    position: relative;
+    padding: 2px;
+    background-color: ${(props) => `${props.background || '#FFF'}`};
+  }
+
+  .roy-simple-table-cell--selected {
+    background: #d4e7f5;
+  }
+
+  .roy-simple-table__cell__corner {
+    width: 8px;
+    height: 8px;
+    background: var(--roy-color-primary);
+    box-shadow: rgba(99, 99, 99, 0.2) 0 2px 8px 0;
+    position: absolute;
+    right: 0;
+    bottom: 0;
+    opacity: 0.5;
+    cursor: nw-resize !important;
+    z-index: 1;
+  }
+`
+
+export const StyledComplexTable = styled('div', complexTableProps)`
+  position: absolute;
+  border: none;
+  padding: 0;
+  margin: 0;
+  color: ${(props) => props.color};
+  background: ${(props) => props.background};
+  font-size: ${(props) => `${props.fontSize}pt`};
+  font-family: ${(props) => (props.fontFamily === 'default' ? 'inherit' : `${props.fontFamily}`)};
+
+  table {
+    width: 100%;
+  }
+
+  th {
+    text-align: center;
+    font-weight: bold;
+    padding: 0;
+    word-break: break-all;
+  }
+
+  td {
+    padding: 0;
+    word-break: break-all;
+    position: relative;
+  }
+
+  .roy-complex-table-cell-in {
+    height: 100%;
+    width: 100%;
+    position: absolute;
+    left: 0;
+    right: 0;
+    top: 0;
+    bottom: 0;
+    align-items: center;
+    display: flex;
+  }
+
+  .rendered-roy-complex-table__footer {
+    margin-top: ${(props) => `-${props.borderWidth - 0.5}px`};
+  }
+
+  .roy-complex-table__body table,
+  .rendered-roy-complex-table__body {
+    //table-layout: fixed;
+    border-collapse: separate;
+    border-spacing: ${(props) => `${props.borderWidth}px`};
+    background-color: ${(props) => `${props.borderColor}`};
+  }
+
+  .roy-complex-table__body td,
+  .roy-complex-table__body th,
+  .rendered-roy-complex-table__body td,
+  .rendered-roy-complex-table__body th {
+    position: relative;
+    background-color: ${(props) => `${props.background || '#FFF'}`};
+  }
+
+  .roy-complex-table__prefix,
+  .roy-complex-table__suffix,
+  .rendered-roy-text {
+    overflow: hidden;
+  }
+
+  .roy-complex-table__container,
+  .rendered-roy-complex-table {
+    border-spacing: 0;
+    border-collapse: separate;
+  }
+
+  .roy-complex-table__container > tr > td {
+    padding: 0;
+    margin: 0;
+  }
+
+  .roy-complex-table__auto_fill {
+    height: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    color: #ccc;
+    background-color: rgb(246, 246, 246);
+    background-image: linear-gradient(90deg, rgba(0, 0, 0, 0.1) 3%, transparent 1px),
+    linear-gradient(1turn, rgba(0, 0, 0, 0.1) 3%, transparent 1px);
+    background-size: 10px 10px;
+    background-position: 50%;
+    background-repeat: repeat;
+  }
+`

+ 176 - 0
src/components/PagePalette.vue

@@ -0,0 +1,176 @@
+<template>
+  <div v-if="initCompleted" class="roy-page-tools">
+    <div v-if="curActiveComponent && curActiveComponent.id">
+      <roy-divider v-if="settingFormItemConfig.length" content-position="left">
+        属性设置
+      </roy-divider>
+      <vxe-form
+        ref="setting-form"
+        :align="formGlobalConfigIn.align"
+        :data="settingFormData"
+        :items="settingFormItemConfig"
+        :loading="formGlobalConfigIn.loading"
+        :prevent-submit="formGlobalConfigIn.preventSubmit"
+        :rules="{}"
+        :size="formGlobalConfigIn.size"
+        :span="formGlobalConfigIn.span"
+        :title-align="formGlobalConfigIn.titleAlign"
+        :title-colon="formGlobalConfigIn.titleColon"
+        :title-overflow="formGlobalConfigIn.titleOverflow"
+        :title-width="formGlobalConfigIn.titleWidth"
+        :valid-config="formGlobalConfigIn.validConfig"
+        sync-resize
+      />
+      <roy-divider content-position="left">样式设置</roy-divider>
+      <vxe-form
+        ref="paletteForm"
+        :align="formGlobalConfigIn.align"
+        :data="formData"
+        :items="formItemConfig"
+        :loading="formGlobalConfigIn.loading"
+        :prevent-submit="formGlobalConfigIn.preventSubmit"
+        :rules="{}"
+        :size="formGlobalConfigIn.size"
+        :span="formGlobalConfigIn.span"
+        :title-align="formGlobalConfigIn.titleAlign"
+        :title-colon="formGlobalConfigIn.titleColon"
+        :title-overflow="formGlobalConfigIn.titleOverflow"
+        :title-width="formGlobalConfigIn.titleWidth"
+        :valid-config="formGlobalConfigIn.validConfig"
+        sync-resize
+      />
+    </div>
+    <div v-else class="roy-page-tools__empty animate__animated animate__headShake">
+      <i class="ri-door-lock-box-line animate__backInUp" style="color: var(--roy-color-warning)" />
+      <div>请先选定一个组件,再进行该组件的属性设置</div>
+    </div>
+  </div>
+</template>
+
+<script>
+import commonMixin from '@/mixin/commonMixin'
+import { paletteConfigList, settingConfigList } from '@/components/config/paletteConfig'
+
+export default {
+  name: 'PagePalette',
+  mixins: [commonMixin],
+  computed: {
+    // ...mapState({
+      curComponent: (state) => state.printTemplateModule.curComponent,
+      curTableCell: (state) => state.printTemplateModule.curTableCell,
+    // }),
+    formItemConfig() {
+      let curComponentCode = this.curActiveComponent?.component || 'no'
+      return this.formItemConfigs[curComponentCode] || []
+    },
+    settingFormItemConfig() {
+      let curComponentCode = this.curActiveComponent?.component || 'no'
+      return this.settingFormItemConfigs[curComponentCode] || []
+    },
+    curActiveComponent() {
+      return this.curTableCell || this.curComponent
+    }
+  },
+  data() {
+    return {
+      initCompleted: false,
+      formGlobalConfigIn: {
+        titleOverflow: true,
+        span: 8,
+        align: 'left',
+        size: 'medium',
+        titleAlign: 'right',
+        titleWidth: '200',
+        titleColon: false,
+        preventSubmit: false,
+        loading: false,
+        validConfig: {
+          autoPos: true
+        }
+      },
+      formData: {},
+      formItemConfigs: paletteConfigList,
+      settingFormData: {},
+      settingFormItemConfigs: settingConfigList
+    }
+  },
+  methods: {},
+  async mounted() {
+    this.$nextTick(() => {
+      this.initCompleted = true
+    })
+  },
+  watch: {
+    curActiveComponent: {
+      handler() {
+        if (this.curActiveComponent) {
+          this.formData = this.curActiveComponent.style
+          this.settingFormData = this.curActiveComponent
+        }
+      },
+      deep: true,
+      immediate: true
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.roy-page-tools {
+  overflow: auto;
+  height: calc(100% - 16px);
+  padding: 8px;
+
+  .roy-page-tools__empty {
+    font-size: 10px;
+    height: 100%;
+    width: 100%;
+    display: flex;
+    flex-flow: row wrap;
+
+    i {
+      font-size: 28px;
+      width: 100%;
+      align-self: end;
+      text-align: center;
+    }
+
+    div {
+      text-align: center;
+    }
+  }
+}
+</style>
+
+<style lang="less">
+.roy-page-tools {
+  .vxe-form.size--medium .vxe-form--item-inner {
+    display: grid;
+  }
+
+  .vxe-form--item-title {
+    font-size: 10px;
+    text-align: left !important;
+    margin-bottom: 5px;
+
+    .vxe-form--item-title-label:before {
+      content: '';
+      width: 1px;
+      height: 80%;
+      margin-right: 5px;
+      border-left: var(--roy-color-primary) 3px solid;
+    }
+  }
+
+  .vxe-form--item {
+    float: inherit !important;
+  }
+
+  .vxe-input--inner {
+    border-radius: unset;
+    background: transparent;
+    color: var(--roy-text-color-primary);
+    border-color: var(--roy-border-color);
+  }
+}
+</style>

+ 179 - 0
src/components/PageToc.vue

@@ -0,0 +1,179 @@
+<template>
+  <yvan-print-main class="roy-page-toc">
+    <div v-if="componentData.length">
+      <div
+          v-for="(item, index) in componentData"
+          :key="index"
+          :class="{ activated: transformIndex(index) === curComponentIndex }"
+          class="roy-page-toc__list"
+          @click="onClick(transformIndex(index))"
+      >
+        <i :class="getComponent(index).icon" style="padding-right: 5px"></i>
+        <span class="roy-page-toc__label">{{ getComponent(index).label }}</span>
+        <div class="roy-page-toc__buttons">
+          <span class="ri-arrow-up-line" @click="upComponent(transformIndex(index))"></span>
+          <span class="ri-arrow-down-line" @click="downComponent(transformIndex(index))"></span>
+          <span class="ri-delete-bin-4-line" @click="deleteComponent(transformIndex(index))"></span>
+        </div>
+      </div>
+    </div>
+    <div v-else class="roy-page-toc__empty animate__animated animate__headShake">
+      <i class="ri-door-lock-box-line animate__backInUp" style="color: var(--roy-color-warning)"/>
+      <div>当前没有组件,您可以通过拖拽添加组件</div>
+    </div>
+  </yvan-print-main>
+</template>
+
+<script lang="js">
+import {mapState} from 'pinia'
+import commonMixin from '@/mixin/commonMixin'
+import YvanPrintMain from "@/components/yvan-ui/yvan-print-main.vue";
+import {globalStore} from "@/store";
+
+/**
+ * 页面大纲
+ */
+export default {
+  name: 'PageToc',
+  mixins: [commonMixin],
+  components: {YvanPrintMain},
+  props: {},
+  data() {
+    return {}
+  },
+  computed: {
+    ...mapState(globalStore, ['componentData', 'curComponentIndex', "curComponent"])
+  },
+  methods: {
+    initMounted() {
+    },
+    getComponent(index) {
+      return this.componentData[this.componentData.length - 1 - index]
+    },
+
+    transformIndex(index) {
+      return this.componentData.length - 1 - index
+    },
+    onClick(index) {
+      this.setCurComponent(index)
+    },
+    deleteComponent() {
+      setTimeout(() => {
+        this.$store.commit('printTemplateModule/deleteComponent')
+        this.$store.commit('printTemplateModule/recordSnapshot')
+      })
+    },
+
+    upComponent() {
+      setTimeout(() => {
+        this.$store.commit('printTemplateModule/upComponent')
+        this.$store.commit('printTemplateModule/recordSnapshot')
+      })
+    },
+
+    downComponent() {
+      setTimeout(() => {
+        this.$store.commit('printTemplateModule/downComponent')
+        this.$store.commit('printTemplateModule/recordSnapshot')
+      })
+    },
+
+    setCurComponent(index) {
+      this.$store.commit('printTemplateModule/setCurComponent', {
+        component: this.componentData[index],
+        index
+      })
+    }
+  },
+  created() {
+  },
+  mounted() {
+    this.initMounted()
+  },
+  watch: {}
+}
+</script>
+
+<style lang="less" scoped>
+.roy-page-toc {
+  height: 100%;
+  padding: 6px;
+
+  .roy-page-toc__empty {
+    font-size: 10px;
+    height: 100%;
+    width: 100%;
+    display: flex;
+    flex-flow: row wrap;
+
+    i {
+      font-size: 28px;
+      width: 100%;
+      align-self: end;
+      text-align: center;
+    }
+
+    div {
+      text-align: center;
+    }
+  }
+
+  .roy-page-toc__list {
+    height: 30px;
+    cursor: grab;
+    text-align: center;
+    color: var(--roy-text-color-primary);
+    display: flex;
+    align-items: center;
+    font-size: 12px;
+    padding: 0 10px;
+    position: relative;
+    user-select: none;
+
+    .roy-page-toc__label {
+      text-align: left;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      width: 50%;
+      overflow: hidden;
+    }
+
+    i {
+      font-size: 12px;
+    }
+
+    &.activated {
+      color: var(--roy-color-primary);
+      background: var(--roy-color-primary-light-7);
+    }
+
+    &:active {
+      cursor: grabbing;
+    }
+
+    &:hover {
+      background-color: rgba(200, 200, 200, 0.2);
+      color: var(--roy-text-color-primary);
+
+      .roy-page-toc__buttons {
+        display: block;
+      }
+    }
+  }
+
+  .roy-page-toc__buttons {
+    position: absolute;
+    right: 10px;
+    display: none;
+
+    span {
+      cursor: pointer;
+      padding: 5px;
+
+      &:hover {
+        background: var(--roy-menu-item-hover-fill);
+      }
+    }
+  }
+}
+</style>

+ 250 - 0
src/components/config/componentList.ts

@@ -0,0 +1,250 @@
+export const commonStyle = {
+    rotate: 0,
+    opacity: 1
+}
+
+export const commonAttr = {
+    // 当一个组件成为 Group 的子组件时使用
+    groupStyle: {},
+    // 是否锁定组件
+    isLock: false
+}
+
+/**
+ * 布局组件:
+ */
+export const layoutComponentList = [
+    {name: "标题", code: "title"},
+    {name: "页头", code: "page_header"},
+    {name: "列头", code: "column_header"},
+    {name: "详细", code: "detail"},
+    {name: "列脚", code: "column_footer"},
+    {name: "页脚", code: "page_footer"},
+    {name: "最后一页页脚", code: "last_page_footer"},
+    {name: "统计", code: "summary"},
+]
+
+/**
+ * 基础组件
+ */
+export const basicComponentList = [
+    {
+        icon: 'ri-text',
+        code: 'text',
+        name: '文本',
+        component: 'RoySimpleText',
+        propValue: '单击编辑文本',
+        style: {
+            color: '#000000',
+            borderRadius: 'inherit',
+            padding: '0',
+            margin: '0',
+            fontFamily: 'default',
+            lineHeight: '1',
+            letterSpacing: '0',
+            borderWidth: 0,
+            borderColor: '#212121',
+            borderType: 'none',
+            width: 200,
+            height: 50,
+            fontSize: 10,
+            background: null,
+            rotate: 0,
+            justifyContent: 'flex-start',
+            alignItems: 'flex-start',
+            fontWeight: 'normal',
+            fontStyle: 'normal',
+            isUnderLine: false,
+            isDelLine: false
+        },
+        groupStyle: {}
+    },
+    {
+        icon: 'ri-t-box-line',
+        code: 'long-text',
+        name: '长文本',
+        component: 'RoyText',
+        propValue:
+            '<p><span style="font-size: 16pt;">双击</span><span style="color: rgb(255, 255, 255); background-color: #009688; font-size: 16pt; font-family: 仿宋;">编辑</span><span style="font-size: 16pt;">文本</span></p>',
+        style: {
+            width: 500,
+            height: 200,
+            fontSize: 12,
+            background: null,
+            rotate: 0,
+            padding: '0'
+        },
+        groupStyle: {}
+    },
+    {
+        icon: 'ri-table-2',
+        code: 'table',
+        name: '单元格',
+        component: 'RoySimpleTable',
+        propValue: {},
+        style: {
+            width: 'auto',
+            height: 'auto',
+            fontSize: 12,
+            background: '#FFFFFF',
+            borderWidth: 2,
+            borderColor: '#212121',
+            rotate: 0
+        },
+        groupStyle: {}
+    },
+    {
+        icon: 'ri-table-line',
+        code: 'complex-table',
+        name: '复杂表格',
+        component: 'RoyComplexTable',
+        propValue: {},
+        showPrefix: true,
+        showHead: true,
+        showFoot: true,
+        showSuffix: true,
+        style: {
+            width: 'auto',
+            height: 'auto',
+            fontSize: 12,
+            fontFamily: 'default',
+            color: '#000000',
+            background: '#FFFFFF',
+            borderWidth: 2,
+            borderColor: '#212121',
+            rotate: 0
+        },
+        groupStyle: {}
+    },
+    {
+        icon: 'ri-subtract-line',
+        code: 'line',
+        name: '直线',
+        component: 'RoyLine',
+        propValue: '',
+        style: {
+            width: 200,
+            height: 1,
+            background: '#000',
+            rotate: 0
+        },
+        groupStyle: {}
+    },
+    {
+        icon: 'ri-checkbox-blank-line',
+        code: 'rectangle',
+        name: '矩形',
+        component: 'RoyRect',
+        propValue: '',
+        style: {
+            borderRadius: 'inherit',
+            borderWidth: 1,
+            borderColor: '#212121',
+            borderType: 'solid',
+            width: 200,
+            height: 200,
+            background: null,
+            rotate: 0
+        },
+        groupStyle: {}
+    },
+    {
+        icon: 'ri-checkbox-blank-circle-line',
+        code: 'circle',
+        name: '圆形',
+        component: 'RoyCircle',
+        propValue: '',
+        style: {
+            borderWidth: 1,
+            borderColor: '#212121',
+            borderType: 'solid',
+            width: 200,
+            height: 200,
+            background: '#ffffff',
+            rotate: 0
+        },
+        groupStyle: {}
+    },
+    {
+        icon: 'ri-star-line',
+        code: 'star',
+        name: '五角星',
+        component: 'RoyStar',
+        propValue: '',
+        style: {
+            width: 200,
+            height: 200,
+            background: '#FF0000',
+            icon: 'icon-shiwujiaoxing',
+            rotate: 0
+        },
+        groupStyle: {}
+    },
+    {
+        icon: 'ri-image-2-line',
+        code: 'image',
+        name: '图片',
+        component: 'RoyImage',
+        src: '',
+        title: '默认图片',
+        style: {
+            borderRadius: 'inherit',
+            borderWidth: 0,
+            borderColor: '#212121',
+            borderType: 'none',
+            width: 200,
+            height: 200,
+            background: null,
+            rotate: 0
+        },
+        groupStyle: {}
+    }
+]
+
+/**
+ * 复合组件
+ */
+export const compositeComponentList = []
+
+/**
+ * 其他组件
+ */
+export const otherComponentList = []
+
+/**
+ * 组件类型: 布局组件/基础组件/复合组件/其他组件
+ */
+export const componentTypeList = [
+    {
+        name: "layout_component",
+        title: "布局组件",
+        componentList: layoutComponentList
+    },
+    {
+        name: "basic_component",
+        title: "基础组件",
+        componentList: basicComponentList
+    },
+    {
+        name: "composite_component",
+        title: "复合组件",
+        componentList: compositeComponentList
+    },
+    {
+        name: "other_component",
+        title: "其他组件",
+        componentList: otherComponentList
+    }
+]
+
+
+export const getComponentMap = function () {
+    const componentMap = new Map()
+    for (const componentType of componentTypeList){
+        for (const component of componentType.componentList){
+            componentMap.set(component.code, component)
+        }
+    }
+    console.log(" >>> ", componentMap)
+    return componentMap
+}

File diff suppressed because it is too large
+ 122 - 0
src/components/config/editorConfig.js


+ 47 - 0
src/components/config/menuConfig.ts

@@ -0,0 +1,47 @@
+import {markRaw} from "vue";
+import PageComponent from '@/components/PageComponent.vue';
+import PageToc from '@/components/PageToc.vue';
+import PagePalette from '@/components/PagePalette.vue';
+import DataSource from '@/components/DataSource.vue';
+import GlobalSetting from '@/components/GlobalSetting.vue';
+
+export default {
+    menuList: [
+        {
+            title: '组件',
+            code: 'component',
+            icon: 'fa fa-cube',
+            activeIcon: 'fa fa-cube',
+            isActive: true,
+            relativeComponent: markRaw(PageComponent)
+        },
+        {
+            title: '结构',
+            code: 'toc',
+            icon: 'fa fa-reorder',
+            activeIcon: 'fa fa-reorder',
+            relativeComponent: markRaw(PageToc)
+        },
+        {
+            title: '属性',
+            code: 'palette',
+            icon: 'fa fa-bullseye',
+            activeIcon: 'fa fa-bullseye',
+            relativeComponent: markRaw(PagePalette)
+        },
+        {
+            title: '数据源',
+            code: 'datasource',
+            icon: 'fa fa-database',
+            activeIcon: 'fa fa-database',
+            relativeComponent: markRaw(DataSource)
+        },
+        {
+            title: '全局',
+            code: 'setting',
+            icon: 'fa fa-gear',
+            activeIcon: 'fa fa-gear',
+            relativeComponent: markRaw(GlobalSetting)
+        }
+    ]
+}

+ 14 - 0
src/components/config/pageFormatConfig.ts

@@ -0,0 +1,14 @@
+/**
+ * 纸张大小
+ */
+export const pageSizeList = [
+    {name: "A4", width: 210, height: 297}
+]
+
+/**
+ * 纸张方向
+ */
+export const pageDirectionList = [
+    {label: "垂直方向", value: "vertical"},
+    {label: "水平方向", value: "horizontal"},
+]

File diff suppressed because it is too large
+ 1532 - 0
src/components/config/paletteConfig.js


+ 47 - 0
src/components/config/toolbarConfig.ts

@@ -0,0 +1,47 @@
+import {ruleStore} from "@/store";
+import YvanPrintPageFormat from "@/components/yvan-print-page-format.vue";
+
+export default [
+    // {
+    //   name: '撤销',
+    //   icon: 'ri-arrow-go-back-fill',
+    //   event: () => {
+    //     this.$store.commit('printTemplateModule/undo')
+    //   }
+    // },
+    // {
+    //   name: '恢复',
+    //   icon: 'ri-arrow-go-forward-fill',
+    //   event: () => {
+    //     this.$store.commit('printTemplateModule/redo')
+    //   }
+    // },
+    {
+        name: '显示/隐藏标尺',
+        icon: 'fa fa-map',
+        event: () => {
+            ruleStore().toggleRuler()
+        }
+    },
+    {
+        name: '切换纸张方向',
+        icon: 'fa fa-exchange',
+        event: () => {
+            ruleStore().rotateRect()
+            // ruleStore().setReDrawRuler()
+        }
+    },
+    {
+        name: '页面设置',
+        icon: 'fa fa-gear',
+        event: () => {
+            System.showDialog(YvanPrintPageFormat, {title: "页面设置", width: 40}).then((res) => {
+                console.log(" >>> then", res)
+            }).catch((res) => {
+                console.log(" >>> catch", res)
+            }).finally(() => {
+                console.log(" >>> finally")
+            })
+        }
+    },
+]

File diff suppressed because it is too large
+ 147 - 0
src/components/sketch-ruler/README.MD


+ 175 - 0
src/components/sketch-ruler/app.vue

@@ -0,0 +1,175 @@
+<template>
+  <div class="wrapper">
+    <SketchRule
+      :cornerActive="true"
+      :height="482"
+      :horLineArr="lines.h"
+      :lang="lang"
+      :scale="scale"
+      :shadow="shadow"
+      :startX="startX"
+      :startY="startY"
+      :thick="thick"
+      :verLineArr="lines.v"
+      :width="582"
+      @handleLine="handleLine"
+      @onCornerClick="handleCornerClick"
+    >
+    </SketchRule>
+    <div id="screens" ref="screensRef" @scroll="handleScroll" @wheel="handleWheel">
+      <div ref="containerRef" class="screen-container">
+        <div id="canvas" :style="canvasStyle" />
+      </div>
+    </div>
+  </div>
+</template>
+<script>
+import Vue from 'vue'
+import SketchRule from './sketchRuler.vue'
+
+const rectWidth = 160
+const rectHeight = 200
+export default Vue.extend({
+  data() {
+    return {
+      scale: 2, //658813476562495, //1,
+      startX: 0,
+      startY: 0,
+      lines: {
+        h: [100, 200],
+        v: [100, 200]
+      },
+      thick: 20,
+      lang: 'zh-CN', // 中英文
+      isShowRuler: true, // 显示标尺
+      isShowReferLine: true // 显示参考线
+    }
+  },
+  components: {
+    SketchRule
+  },
+  computed: {
+    shadow() {
+      return {
+        x: 0,
+        y: 0,
+        width: rectWidth,
+        height: rectHeight
+      }
+    },
+    canvasStyle() {
+      return {
+        width: rectWidth,
+        height: rectHeight,
+        transform: `scale(${this.scale})`
+      }
+    }
+  },
+  methods: {
+    handleLine(lines) {
+      this.lines = lines
+    },
+    handleCornerClick() {
+      return
+    },
+    handleScroll() {
+      const screensRect = document.querySelector('#screens').getBoundingClientRect()
+      const canvasRect = document.querySelector('#canvas').getBoundingClientRect()
+
+      // 标尺开始的刻度
+      const startX = (screensRect.left + this.thick - canvasRect.left) / this.scale
+      const startY = (screensRect.top + this.thick - canvasRect.top) / this.scale
+
+      this.startX = startX
+      this.startY = startY
+    },
+    // 控制缩放值
+    handleWheel(e) {
+      if (e.ctrlKey || e.metaKey) {
+        e.preventDefault()
+        const nextScale = parseFloat(Math.max(0.2, this.scale - e.deltaY / 500).toFixed(2))
+        this.scale = nextScale
+      }
+      this.$nextTick(() => {
+        this.handleScroll()
+      })
+    }
+  },
+  mounted() {
+    // 滚动居中
+    this.$refs.screensRef.scrollLeft =
+      this.$refs.containerRef.getBoundingClientRect().width / 2 - 300 // 300 = #screens.width / 2
+  }
+})
+</script>
+<style lang="less">
+body {
+  margin: 0;
+  padding: 0;
+  font-family: sans-serif;
+  overflow: hidden;
+}
+
+body * {
+  box-sizing: border-box;
+  user-select: none;
+}
+
+.wrapper {
+  background-color: #f5f5f5;
+  position: absolute;
+  top: 100px;
+  left: 100px;
+  width: 600px;
+  height: 500px;
+  border: 1px solid #dadadc;
+}
+
+#screens {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  overflow: auto;
+}
+
+.screen-container {
+  position: absolute;
+  width: 5000px;
+  height: 3000px;
+}
+
+.scale-value {
+  position: absolute;
+  left: 0;
+  bottom: 100%;
+}
+
+.button {
+  position: absolute;
+  left: 100px;
+  bottom: 100%;
+}
+
+.button-ch {
+  position: absolute;
+  left: 200px;
+  bottom: 100%;
+}
+
+.button-en {
+  position: absolute;
+  left: 230px;
+  bottom: 100%;
+}
+
+#canvas {
+  position: absolute;
+  top: 80px;
+  left: 50%;
+  margin-left: -80px;
+  width: 160px;
+  height: 200px;
+  background: lightblue;
+  transform-origin: 50% 0;
+}
+</style>

+ 114 - 0
src/components/sketch-ruler/canvasRuler/canvasRuler.vue

@@ -0,0 +1,114 @@
+<template>
+  <canvas
+    ref="canvasRef"
+    class="ruler"
+    @click="handleClick"
+    @mouseenter="handleEnter"
+    @mouseleave="handleLeave"
+    @mousemove="handleMove"
+  />
+</template>
+<script>
+import { drawHorizontalRuler, drawVerticalRuler } from './utils'
+
+const getValueByOffset = (offset, start, scale) => Math.round(start + offset / scale)
+export default {
+  name: 'CanvasRuler',
+  data() {
+    return {
+      canvasRef: {},
+      canvasContext: {}
+    }
+  },
+  props: {
+    vertical: Boolean,
+    start: Number,
+    scale: Number,
+    width: Number,
+    height: Number,
+    canvasConfigs: Object,
+    selectStart: Number,
+    selectLength: Number
+  },
+  watch: {
+    start() {
+      this.drawRuler()
+    },
+    width() {
+      this.updateCanvasContext()
+      this.drawRuler()
+    },
+    height() {
+      this.updateCanvasContext()
+      this.drawRuler()
+    }
+  },
+  methods: {
+    initCanvasRef() {
+      this.canvasRef = this.$refs.canvasRef
+      this.canvasContext = this.canvasRef && this.canvasRef.getContext('2d')
+    },
+    updateCanvasContext() {
+      const { ratio } = this.canvasConfigs
+      // 比例宽高
+      this.canvasRef.width = this.width * ratio
+      this.canvasRef.height = this.height * ratio
+
+      const ctx = this.canvasRef.getContext('2d')
+      ctx.font = `${12 * ratio}px -apple-system,
+                "Helvetica Neue", ".SFNSText-Regular",
+                "SF UI Text", Arial, "PingFang SC", "Hiragino Sans GB",
+                "Microsoft YaHei", "WenQuanYi Zen Hei", sans-serif`
+      ctx.lineWidth = 1
+      ctx.textBaseline = 'middle'
+    },
+    drawRuler() {
+      const options = {
+        scale: this.scale,
+        width: this.width,
+        height: this.height,
+        canvasConfigs: this.canvasConfigs
+      }
+
+      if (this.vertical) {
+        drawVerticalRuler(
+          this.canvasContext,
+          this.start,
+          { y: this.selectStart, height: this.selectLength },
+          options
+        )
+      } else {
+        drawHorizontalRuler(
+          this.canvasContext,
+          this.start,
+          { x: this.selectStart, width: this.selectLength },
+          options
+        )
+      }
+    },
+    handleClick(e) {
+      const offset = this.vertical ? e.offsetY : e.offsetX
+      const value = getValueByOffset(offset, this.start, this.scale)
+      this.$emit('onAddLine', value)
+    },
+    handleEnter(e) {
+      const offset = this.vertical ? e.offsetY : e.offsetX
+      const value = getValueByOffset(offset, this.start, this.scale)
+      this.$emit('onIndicatorShow', value)
+    },
+    handleMove(e) {
+      const offset = this.vertical ? e.offsetY : e.offsetX
+      const value = getValueByOffset(offset, this.start, this.scale)
+      this.$emit('onIndicatorMove', value)
+    },
+    handleLeave() {
+      this.$emit('onIndicatorHide')
+    }
+  },
+  mounted() {
+    this.initCanvasRef()
+    this.updateCanvasContext()
+    this.drawRuler()
+  }
+}
+</script>

+ 163 - 0
src/components/sketch-ruler/canvasRuler/utils.js

@@ -0,0 +1,163 @@
+// 标尺中每小格代表的宽度(根据scale的不同实时变化)
+const getGridSize = (scale) => {
+  if (scale <= 0.25) {
+    return 40
+  }
+  if (scale <= 0.5) {
+    return 20
+  }
+  if (scale <= 1) {
+    return 10
+  }
+  if (scale <= 2) {
+    return 5
+  }
+  if (scale <= 4) {
+    return 2
+  }
+  return 1
+}
+
+const FONT_SCALE = 0.83 // 10 / 12
+
+export const drawHorizontalRuler = (ctx, start, shadow, options) => {
+  const { scale, width, height, canvasConfigs } = options
+  const { bgColor, fontColor, shadowColor, ratio, longfgColor, shortfgColor } = canvasConfigs
+
+  // 缩放ctx, 以简化计算
+  ctx.scale(ratio, ratio)
+  ctx.clearRect(0, 0, width, height)
+
+  // 1. 画标尺底色
+  ctx.fillStyle = bgColor
+  ctx.fillRect(0, 0, width, height)
+
+  // 2. 画阴影
+  if (shadow) {
+    const shadowX = (shadow.x - start) * scale * 1.02 // 阴影起点坐标
+    const shadowWidth = shadow.width * scale * 1.02 // 阴影宽度
+    ctx.fillStyle = shadowColor
+    ctx.fillRect(shadowX, 0, shadowWidth, (height * 3) / 8)
+  }
+
+  const gridSize = getGridSize(scale) // 每小格表示的宽度
+  const gridPixel = gridSize * scale * 1.02
+  const gridSize_10 = gridSize * 10 // 每大格表示的宽度
+  const gridPixel_10 = gridSize_10 * scale * 1.02
+
+  const startValue = Math.floor(start / gridSize) * gridSize // 绘制起点的刻度(略小于start, 且是gridSize的整数倍)
+  const startValue_10 = Math.floor(start / gridSize_10) * gridSize_10 // 长间隔绘制起点的刻度(略小于start, 且是gridSize_10的整数倍)
+
+  const offsetX = ((startValue - start) / gridSize) * gridPixel // 起点刻度距离ctx原点(start)的px距离
+  const offsetX_10 = ((startValue_10 - start) / gridSize_10) * gridPixel_10 // 长间隔起点刻度距离ctx原点(start)的px距离
+  const endValue = start + Math.ceil(width / scale) // 终点刻度(略超出标尺宽度即可)
+
+  // 3. 画刻度和文字(因为刻度遮住了阴影)
+  ctx.beginPath() // 一定要记得开关路径,因为clearRect并不能清除掉路径,如果不关闭路径下次绘制时会接着上次的绘制
+
+  ctx.fillStyle = fontColor
+  ctx.strokeStyle = longfgColor
+
+  // 长间隔和短间隔需要两次绘制,才可以完成不同颜色的设置;分开放到两个for循环是为了节省性能,因为如果放到一个for循环的话,每次循环都会重新绘制操作dom
+  // 绘制长间隔和文字
+  for (let value = startValue_10, count = 0; value < endValue; value += gridSize_10, count++) {
+    const x = offsetX_10 + count * gridPixel_10 + 0.5 // prevent canvas 1px line blurry
+    ctx.moveTo(x, 0)
+    ctx.save()
+    ctx.translate(x, height * 0.4)
+    ctx.scale(FONT_SCALE / ratio, FONT_SCALE / ratio)
+    ctx.fillText(value, 4 * ratio, 7 * ratio)
+    ctx.restore()
+    ctx.lineTo(x, (height * 9) / 16)
+  }
+  ctx.stroke()
+  ctx.closePath()
+
+  // 绘制短间隔
+  ctx.beginPath()
+  ctx.strokeStyle = shortfgColor
+  for (let value = startValue, count = 0; value < endValue; value += gridSize, count++) {
+    const x = offsetX + count * gridPixel + 0.5 // prevent canvas 1px line blurry
+    ctx.moveTo(x, 0)
+    if (value % gridSize_10 !== 0) {
+      ctx.lineTo(x, (height * 1) / 4)
+    }
+  }
+  ctx.stroke()
+  ctx.closePath()
+
+  // 恢复ctx matrix
+  ctx.setTransform(1, 0, 0, 1, 0, 0)
+}
+
+export const drawVerticalRuler = (ctx, start, shadow, options) => {
+  const { scale, width, height, canvasConfigs } = options
+  const { bgColor, fontColor, shadowColor, ratio, longfgColor, shortfgColor } = canvasConfigs
+
+  // 缩放ctx, 以简化计算
+  ctx.scale(ratio, ratio)
+  ctx.clearRect(0, 0, width, height)
+
+  // 1. 画标尺底色
+  ctx.fillStyle = bgColor
+  ctx.fillRect(0, 0, width, height)
+
+  // 2. 画阴影
+  if (shadow) {
+    // 阴影起点坐标
+    const posY = (shadow.y - start) * scale * 1.03
+    // 阴影高度
+    const shadowHeight = shadow.height * scale * 1.03
+    ctx.fillStyle = shadowColor
+    ctx.fillRect(0, posY, (width * 3) / 8, shadowHeight)
+  }
+
+  const gridSize = getGridSize(scale) // 每小格表示的宽度
+  const gridPixel = gridSize * scale * 1.03
+  const gridSize_10 = gridSize * 10 // 每大格表示的宽度
+  const gridPixel_10 = gridSize_10 * scale * 1.03
+
+  const startValue = Math.floor(start / gridSize) * gridSize // 绘制起点的刻度(略小于start, 且是gridSize的整数倍)
+  const startValue_10 = Math.floor(start / gridSize_10) * gridSize_10 // 长间隔单独绘制起点的刻度
+
+  const offsetY = ((startValue - start) / gridSize) * gridPixel // 起点刻度距离ctx原点(start)的px距离
+  const offsetY_10 = ((startValue_10 - start) / gridSize_10) * gridPixel_10 // 长间隔起点刻度距离ctx原点(start)的px距离
+  const endValue = start + Math.ceil(height / scale) // 终点刻度(略超出标尺宽度即可)
+
+  // 3. 画刻度和文字(因为刻度遮住了阴影)
+  ctx.beginPath() // 一定要记得开关路径,因为clearRect并不能清除掉路径,如果不关闭路径下次绘制时会接着上次的绘制
+
+  ctx.fillStyle = fontColor
+  ctx.strokeStyle = longfgColor // 设置长间隔的颜色
+
+  for (let value = startValue_10, count = 0; value < endValue; value += gridSize_10, count++) {
+    const y = offsetY_10 + count * gridPixel_10 + 0.5
+    ctx.moveTo(0, y)
+    ctx.save() // 这里先保存一下状态
+    ctx.translate(width * 0.4, y) // 将原点转移到当前画笔所在点
+    ctx.rotate(-Math.PI / 2) // 旋转 -90 度
+    ctx.scale(FONT_SCALE / ratio, FONT_SCALE / ratio) // 缩放至10px
+    ctx.fillText(value, 4 * ratio, 7 * ratio) // 绘制文字
+    // 回复刚刚保存的状态
+    ctx.restore()
+    ctx.lineTo((width * 9) / 16, y)
+  }
+  ctx.stroke() // 绘制
+  ctx.closePath() // 长间隔和文字绘制关闭
+
+  ctx.beginPath() // 开始绘制短间隔
+  ctx.strokeStyle = shortfgColor
+
+  for (let value = startValue, count = 0; value < endValue; value += gridSize, count++) {
+    const y = offsetY + count * gridPixel + 0.5
+    ctx.moveTo(0, y)
+    if (value % gridSize_10 !== 0) {
+      ctx.lineTo((width * 1) / 4, y)
+    }
+  }
+  ctx.stroke()
+  ctx.closePath()
+
+  // 恢复ctx matrix
+  ctx.setTransform(1, 0, 0, 1, 0, 0)
+}

+ 30 - 0
src/components/sketch-ruler/index.js

@@ -0,0 +1,30 @@
+// Import vue component
+import SketchRuler from './sketchRuler.vue'
+
+// Declare install function executed by Vue.use()
+export function install(Vue) {
+  if (install.installed) {
+    return
+  }
+  install.installed = true
+  Vue.component(SketchRuler.name, SketchRuler)
+}
+
+// Create module definition for Vue.use()
+const plugin = {
+  install
+}
+
+// Auto-install when vue is found (eg. in browser via <script> tag)
+let GlobalVue = null
+if (typeof window !== 'undefined') {
+  GlobalVue = window.Vue
+} else if (typeof global !== 'undefined') {
+  GlobalVue = global.Vue
+}
+if (GlobalVue) {
+  GlobalVue.use(plugin)
+}
+
+// To allow use as module (npm/webpack/etc.) export component
+export default SketchRuler

+ 144 - 0
src/components/sketch-ruler/line.vue

@@ -0,0 +1,144 @@
+<template>
+  <div v-show="showLine" :style="[offset, borderCursor]" class="line" @mousedown="handleDown">
+    <div :style="actionStyle" class="action">
+      <span class="del" @click="this.handleRemove">&times;</span>
+      <span class="value">{{ startValue }}</span>
+    </div>
+  </div>
+</template>
+<script>
+export default {
+  name: 'LineRuler',
+  data() {
+    return {
+      startValue: 0
+    }
+  },
+  props: {
+    index: Number,
+    start: Number,
+    vertical: Boolean,
+    scale: Number,
+    value: Number,
+    palette: Object,
+    isShowReferLine: Boolean,
+    thick: Number
+  },
+  computed: {
+    offset() {
+      const offset = (this.startValue - this.start) * this.scale
+      const positionValue = offset + 'px'
+      return this.vertical ? { top: positionValue } : { left: positionValue }
+    },
+    showLine() {
+      const offset = (this.startValue - this.start) * this.scale
+      return offset >= 0
+    },
+    borderCursor() {
+      const borderValue = `1px solid ${this.palette.lineColor}`
+      const border = this.vertical ? { borderTop: borderValue } : { borderLeft: borderValue }
+
+      const cursorValue = this.isShowReferLine
+        ? this.vertical
+          ? 'ns-resize'
+          : 'ew-resize'
+        : 'none'
+      return {
+        cursor: cursorValue,
+        ...border
+      }
+    },
+    actionStyle() {
+      return this.vertical ? { left: this.thick + 'px' } : { top: this.thick + 'px' }
+    }
+  },
+  methods: {
+    handleDown(e) {
+      const startD = this.vertical ? e.clientY : e.clientX
+      const initValue = this.startValue
+      this.$emit('onMouseDown')
+      const onMove = (e) => {
+        const currentD = this.vertical ? e.clientY : e.clientX
+        this.startValue = Math.round(initValue + (currentD - startD) / this.scale)
+      }
+      const onEnd = () => {
+        this.$emit('onRelease', this.startValue, this.index)
+        document.removeEventListener('mousemove', onMove)
+        document.removeEventListener('mouseup', onEnd)
+      }
+      document.addEventListener('mousemove', onMove)
+      document.addEventListener('mouseup', onEnd)
+    },
+    handleRemove() {
+      this.$emit('onRemove', this.index)
+    },
+    initStartValue() {
+      this.startValue = this.value
+    }
+  },
+  mounted() {
+    this.initStartValue()
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.line {
+  position: absolute;
+
+  .action {
+    position: absolute;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+
+  .value {
+    pointer-events: none;
+    transform: scale(0.83);
+  }
+
+  .del {
+    cursor: pointer;
+    padding: 3px 5px;
+    visibility: hidden;
+  }
+
+  &:hover .del {
+    visibility: visible;
+  }
+}
+
+.h-container {
+  .line {
+    height: 100vh;
+    top: 0;
+    padding-left: 5px;
+
+    .action {
+      transform: translateX(-24px);
+
+      .value {
+        margin-left: 4px;
+      }
+    }
+  }
+}
+
+.v-container {
+  .line {
+    width: 100vw;
+    left: 0;
+    padding-top: 5px;
+
+    .action {
+      transform: translateY(-24px);
+      flex-direction: column;
+
+      .value {
+        margin-top: 4px;
+      }
+    }
+  }
+}
+</style>

+ 237 - 0
src/components/sketch-ruler/rulerWrapper.vue

@@ -0,0 +1,237 @@
+<template>
+  <div :class="rwClassName" :style="rwStyle">
+    <CanvasRuler
+      :canvasConfigs="canvasConfigs"
+      :height="height"
+      :scale="scale"
+      :selectLength="selectLength"
+      :selectStart="selectStart"
+      :start="start"
+      :vertical="vertical"
+      :width="width"
+      @onAddLine="handleNewLine"
+      @onIndicatorHide="handleIndicatorHide"
+      @onIndicatorMove="handleIndicatorMove"
+      @onIndicatorShow="handleIndicatorShow"
+    >
+    </CanvasRuler>
+    <div v-show="isShowReferLine" class="lines">
+      <LineRuler
+        v-for="(v, i) in lines"
+        :key="v + i"
+        :index="i"
+        :isShowReferLine="isShowReferLine"
+        :palette="palette"
+        :scale="scale"
+        :start="start"
+        :thick="thick"
+        :value="v >> 0"
+        :vertical="vertical"
+        @onMouseDown="handleLineDown"
+        @onRelease="handleLineRelease"
+        @onRemove="handleLineRemove"
+      >
+      </LineRuler>
+    </div>
+    <div v-show="showIndicator" :style="indicatorStyle" class="indicator">
+      <div class="value">{{ value }}</div>
+    </div>
+  </div>
+</template>
+
+<script>
+import LineRuler from './line.vue'
+import CanvasRuler from './canvasRuler/canvasRuler.vue'
+
+export default {
+  name: 'RulerWrapper',
+  components: {
+    CanvasRuler,
+    LineRuler
+  },
+  props: {
+    vertical: Boolean,
+    scale: Number,
+    width: Number,
+    thick: Number,
+    height: Number,
+    start: Number,
+    lines: Array,
+    selectStart: Number,
+    selectLength: Number,
+    canvasConfigs: Object,
+    palette: Object,
+    isShowReferLine: Boolean,
+    onShowRightMenu: Function,
+    handleShowReferLine: Function
+  },
+  data() {
+    return {
+      isDraggingLine: false,
+      showIndicator: false,
+      value: 0
+    }
+  },
+  computed: {
+    rwClassName() {
+      return this.vertical ? 'v-container' : 'h-container'
+    },
+    rwStyle() {
+      const hContainer = {
+        width: `calc(100% - ${this.thick}px)`,
+        height: `${this.thick + 1}px`,
+        left: `${this.thick}` + 'px'
+      }
+      const vContainer = {
+        width: `${this.thick + 1}px`,
+        height: `calc(100% - ${this.thick}px)`,
+        top: `${this.thick}` + 'px'
+      }
+      return this.vertical ? vContainer : hContainer
+    },
+    lineStyle() {
+      return {
+        borderTop: `1px solid ${this.palette.lineColor}`,
+        cursor: this.isShowReferLine ? 'ns-resize' : 'none'
+      }
+    },
+    indicatorStyle() {
+      const indicatorOffset = (this.value - this.start) * this.scale
+      let positionKey = 'top'
+      let boderKey = 'borderLeft'
+      positionKey = this.vertical ? 'top' : 'left'
+      boderKey = this.vertical ? 'borderBottom' : 'borderLeft'
+      return {
+        [positionKey]: indicatorOffset + 'px',
+        [boderKey]: `1px solid ${this.palette.lineColor}`
+      }
+    }
+  },
+  methods: {
+    handleNewLine(value) {
+      // eslint-disable-next-line vue/no-mutating-props
+      this.lines.push(value)
+      this.$emit('onLineChange', this.lines, this.vertical)
+      // !isShowReferLine && handleShowReferLine()
+    },
+    handleIndicatorShow(value) {
+      if (!this.isDraggingLine) {
+        this.showIndicator = true
+        this.value = value
+      }
+    },
+    handleIndicatorMove(value) {
+      if (this.showIndicator) {
+        this.value = value
+      }
+    },
+    handleIndicatorHide() {
+      this.showIndicator = false
+    },
+    handleLineDown() {
+      this.isDraggingLine = true
+    },
+    handleLineRelease(value, index) {
+      this.isDraggingLine = false
+      // 左右或上下超出时, 删除该条对齐线
+      const offset = value - this.start
+      const maxOffset = (this.vertical ? this.height : this.width) / this.scale
+
+      if (offset < 0 || offset > maxOffset) {
+        this.handleLineRemove(index)
+      } else {
+        // eslint-disable-next-line vue/no-mutating-props
+        this.lines[index] = value
+        this.$emit('onLineChange', this.lines, this.vertical)
+      }
+    },
+    handleLineRemove(index) {
+      // eslint-disable-next-line vue/no-mutating-props
+      this.lines.splice(index, 1)
+      this.$emit('onLineChange', this.lines, this.vertical)
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.line {
+  position: absolute;
+}
+
+.h-container,
+.v-container {
+  position: absolute;
+
+  .lines {
+    pointer-events: none;
+  }
+
+  &:hover .lines {
+    pointer-events: auto;
+  }
+}
+
+.h-container {
+  top: 0;
+
+  .line {
+    height: 100vh;
+    top: 0;
+    padding-left: 5px;
+
+    .action {
+      transform: translateX(-24px);
+
+      .value {
+        margin-left: 4px;
+      }
+    }
+  }
+
+  .indicator {
+    top: 0;
+    height: 100vw;
+
+    .value {
+      padding: 0 2px;
+      width: auto;
+      margin-left: 4px;
+      margin-top: 4px;
+    }
+  }
+}
+
+.v-container {
+  left: 0;
+
+  .line {
+    width: 100vw;
+    left: 0;
+    padding-top: 5px;
+
+    .action {
+      transform: translateY(-24px);
+      flex-direction: column;
+
+      .value {
+        margin-top: 4px;
+      }
+    }
+  }
+
+  .indicator {
+    width: 100vw;
+
+    .value {
+      padding: 0 2px;
+      width: auto;
+      left: 0;
+      margin-left: 2px;
+      margin-top: -5px;
+      transform-origin: 0 0;
+      transform: rotate(-90deg);
+    }
+  }
+}
+</style>

+ 260 - 0
src/components/sketch-ruler/sketchRuler.vue

@@ -0,0 +1,260 @@
+<template>
+  <div id="mb-ruler" class="style-ruler mb-ruler">
+    <!-- 水平方向 -->
+    <RulerWrapper
+      :canvasConfigs="canvasConfigs"
+      :height="thick"
+      :isShowReferLine="isShowReferLine"
+      :lines="horLineArr"
+      :palette="palette"
+      :scale="scale"
+      :selectLength="shadow.width"
+      :selectStart="shadow.x"
+      :start="startX"
+      :thick="thick"
+      :vertical="false"
+      :width="width"
+      @onLineChange="handleLineChange"
+    />
+    <!-- 竖直方向 -->
+    <RulerWrapper
+      :canvasConfigs="canvasConfigs"
+      :height="height"
+      :isShowReferLine="isShowReferLine"
+      :lines="verLineArr"
+      :palette="palette"
+      :scale="scale"
+      :selectLength="shadow.height"
+      :selectStart="shadow.y"
+      :start="startY"
+      :thick="thick"
+      :vertical="true"
+      :width="thick"
+      @onLineChange="handleLineChange"
+    />
+    <a :class="cornerActiveClass" :style="cornerStyle" class="corner" @click="onCornerClick"></a>
+  </div>
+</template>
+
+<script>
+import RulerWrapper from './rulerWrapper.vue'
+
+const DEFAULTMENU = {
+  bgColor: '#fff',
+  dividerColor: '#DBDBDB',
+  listItem: {
+    textColor: '#415058',
+    hoverTextColor: '#298DF8',
+    disabledTextColor: 'rgba(65, 80, 88, 0.4)',
+    bgColor: '#fff',
+    hoverBgColor: '#F2F2F2'
+  }
+}
+export default {
+  name: 'SketchRuler',
+  components: {
+    RulerWrapper
+  },
+  data() {
+    return {
+      vertical: true
+    }
+  },
+  props: {
+    scale: {
+      type: Number,
+      default: 1
+    },
+    ratio: {
+      type: Number,
+      default: window.devicePixelRatio || 1
+    },
+    thick: {
+      type: Number,
+      default: 16
+    },
+    width: Number,
+    height: Number,
+    startX: {
+      type: Number,
+      default: 0
+    },
+    startY: {
+      type: Number,
+      default: 0
+    },
+    shadow: {
+      type: Object,
+      default: () => {
+        return {
+          x: 200,
+          y: 100,
+          width: 200,
+          height: 400
+        }
+      }
+    },
+    horLineArr: {
+      type: Array,
+      default: () => {
+        return [100, 200]
+      }
+    },
+    verLineArr: {
+      type: Array,
+      default: () => {
+        return [100, 200]
+      }
+    },
+    cornerActive: Boolean,
+    lang: String,
+    isOpenMenuFeature: {
+      type: Boolean,
+      default: false
+    },
+    handleShowRuler: {
+      type: Function,
+      default: () => {
+        return () => {}
+      }
+    },
+    isShowReferLine: {
+      type: Boolean,
+      default: true
+    },
+    handleShowReferLine: {
+      type: Function,
+      default: () => {
+        return () => {}
+      }
+    },
+    palette: {
+      type: Object,
+      default: () => {
+        return {
+          bgColor: 'rgba(225,225,225, 0)', // ruler bg color
+          longfgColor: '#BABBBC', // ruler longer mark color
+          shortfgColor: '#C8CDD0', // ruler shorter mark color
+          fontColor: '#7D8694', // ruler font color
+          shadowColor: '#E8E8E8', // ruler shadow color
+          lineColor: '#EB5648',
+          borderColor: '#DADADC',
+          cornerActiveColor: 'rgb(235, 86, 72, 0.6)',
+          menu: DEFAULTMENU
+        }
+      }
+    }
+  },
+  computed: {
+    cornerActiveClass() {
+      return this.cornerActive ? ' active' : ''
+    },
+    cornerStyle() {
+      return {
+        backgroundColor: this.palette.bgColor,
+        width: this.thick + 'px',
+        height: this.thick + 'px',
+        borderRight: `1px solid ${this.palette.borderColor}`,
+        borderBottom: `1px solid ${this.palette.borderColor}`
+      }
+    },
+    canvasConfigs() {
+      const {
+        bgColor,
+        longfgColor,
+        shortfgColor,
+        fontColor,
+        shadowColor,
+        lineColor,
+        borderColor,
+        cornerActiveColor
+      } = this.palette
+      return {
+        ratio: this.ratio,
+        bgColor,
+        longfgColor,
+        shortfgColor,
+        fontColor,
+        shadowColor,
+        lineColor,
+        borderColor,
+        cornerActiveColor
+      }
+    }
+  },
+  methods: {
+    onCornerClick(e) {
+      this.$emit('onCornerClick', e)
+    },
+    handleLineChange(arr, vertical) {
+      const newLines = vertical
+        ? { h: this.horLineArr, v: [...arr] }
+        : { h: [...arr], v: this.verLineArr }
+      this.$emit('handleLine', newLines)
+    }
+  },
+  mounted() {
+    console.log("this.props", this.$props)
+  }
+}
+</script>
+
+<style lang="less">
+.style-ruler {
+  position: absolute;
+  width: 100%; /* scrollbar width */
+  height: 100%;
+  z-index: 3; /* 需要比resizer高 */
+  pointer-events: none;
+  font-size: 12px;
+  overflow: hidden;
+
+  span {
+    line-height: 1;
+  }
+}
+
+.corner {
+  position: absolute;
+  left: 0;
+  top: 0;
+  pointer-events: auto;
+  cursor: pointer;
+  transition: all 0.2s ease-in-out;
+  box-sizing: content-box;
+  // &.active {
+  //     background-color: ${props => props.cornerActiveColor} !important;
+  // }
+}
+
+.indicator {
+  position: absolute;
+  pointer-events: none;
+
+  .value {
+    position: absolute;
+    background: white;
+  }
+}
+
+.corner {
+  position: absolute;
+  left: 0;
+  top: 0;
+
+  pointer-events: auto;
+  cursor: pointer;
+  transition: all 0.2s ease-in-out;
+  box-sizing: content-box;
+
+  &.active {
+    // background-color: ${props => props.cornerActiveColor} !important;
+  }
+}
+
+.ruler {
+  width: 100%;
+  height: 100%;
+  pointer-events: auto;
+}
+</style>

+ 299 - 0
src/components/yvan-context/components/Context.vue

@@ -0,0 +1,299 @@
+<template>
+  <ul v-show="visible" ref="contextmenu" :class="contextmenuCls" :style="style">
+    <slot></slot>
+  </ul>
+</template>
+
+<script>
+export default {
+  name: 'RoyContext',
+
+  provide() {
+    return {
+      $$contextmenu: this
+    }
+  },
+
+  props: {
+    eventType: {
+      type: String,
+      default: 'contextmenu'
+    },
+    theme: {
+      type: String,
+      default: 'default'
+    },
+    autoPlacement: {
+      type: Boolean,
+      default: true
+    },
+    disabled: Boolean
+  },
+
+  data() {
+    return {
+      visible: false,
+      references: [],
+      style: {
+        top: 0,
+        left: 0
+      }
+    }
+  },
+  computed: {
+    clickOutsideHandler() {
+      return this.visible ? this.hide : () => {}
+    },
+    isClick() {
+      return this.eventType === 'click'
+    },
+    contextmenuCls() {
+      return ['roy-context', `roy-context--${this.theme}`]
+    }
+  },
+
+  watch: {
+    visible(value) {
+      if (value) {
+        this.$emit('show', this)
+
+        document.body.addEventListener('click', this.handleBodyClick)
+      } else {
+        this.$emit('hide', this)
+
+        document.body.removeEventListener('click', this.handleBodyClick)
+      }
+    }
+  },
+  mounted() {
+    document.body.appendChild(this.$el)
+
+    if (window.$$VContextmenu) {
+      window.$$VContextmenu[this.$contextmenuId] = this
+    } else {
+      window.$$VContextmenu = { [this.$contextmenuId]: this }
+    }
+  },
+  beforeDestroy() {
+    document.body.removeChild(this.$el)
+
+    delete window.$$VContextmenu[this.$contextmenuId]
+
+    this.references.forEach((ref) => {
+      ref.el.removeEventListener(this.eventType, this.handleReferenceContextmenu)
+    })
+
+    document.body.removeEventListener('click', this.handleBodyClick)
+  },
+
+  methods: {
+    addRef(ref) {
+      // FIXME: 如何处理 removeRef?
+      this.references.push(ref)
+
+      ref.el.addEventListener(this.eventType, this.handleReferenceContextmenu)
+    },
+    handleReferenceContextmenu(event) {
+      event.preventDefault()
+
+      if (this.disabled) {
+        return
+      }
+
+      const reference = this.references.find((ref) => ref.el.contains(event.target))
+
+      this.$emit('contextmenu', reference ? reference.vnode : null)
+
+      const eventX = event.pageX
+      const eventY = event.pageY
+
+      this.show()
+
+      this.$nextTick(() => {
+        const contextmenuPosition = {
+          top: eventY,
+          left: eventX
+        }
+
+        if (this.autoPlacement) {
+          const contextmenuWidth = this.$refs.contextmenu.clientWidth
+          const contextmenuHeight = this.$refs.contextmenu.clientHeight
+
+          if (contextmenuHeight + eventY >= window.innerHeight) {
+            contextmenuPosition.top -= contextmenuHeight
+          }
+
+          if (contextmenuWidth + eventX >= window.innerWidth) {
+            contextmenuPosition.left -= contextmenuWidth
+          }
+        }
+
+        this.style = {
+          top: `${contextmenuPosition.top}px`,
+          left: `${contextmenuPosition.left}px`
+        }
+      })
+    },
+    handleBodyClick(event) {
+      const notOutside =
+        this.$el.contains(event.target) ||
+        (this.isClick && this.references.some((ref) => ref.el.contains(event.target)))
+
+      if (!notOutside) {
+        this.visible = false
+      }
+    },
+    show(position) {
+      Object.keys(window.$$VContextmenu).forEach((key) => {
+        if (key !== this.$contextmenuId) {
+          window.$$VContextmenu[key].hide()
+        }
+      })
+
+      if (position) {
+        this.style = {
+          top: `${position.top}px`,
+          left: `${position.left}px`
+        }
+      }
+
+      this.visible = true
+    },
+    hide() {
+      this.visible = false
+    },
+    hideAll() {
+      Object.keys(window.$$VContextmenu).forEach((key) => {
+        window.$$VContextmenu[key].hide()
+      })
+    }
+  }
+}
+</script>
+
+<style>
+.roy-context {
+  position: absolute;
+  padding: 5px 0;
+  margin: 0;
+  background-color: #fff;
+  border: 1px solid #e8e8e8;
+  border-radius: 4px;
+  box-shadow: 2px 2px 8px 0 rgba(150, 150, 150, 0.2);
+  list-style: none;
+  font-size: 14px;
+  white-space: nowrap;
+  cursor: pointer;
+  z-index: 2800;
+  -webkit-tap-highlight-color: transparent;
+}
+
+.roy-context .roy-context-item {
+  padding: 5px 14px;
+  line-height: 1;
+  color: #333;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+}
+
+.roy-context .roy-context-item.roy-context-item--hover {
+  color: #fff;
+}
+
+.roy-context .roy-context-item.roy-context-item--disabled {
+  color: #ccc;
+  cursor: not-allowed;
+}
+
+.roy-context .roy-context-divider {
+  height: 0;
+  margin: 5px 0;
+  border-bottom: 1px solid #e8e8e8;
+}
+
+.roy-context .roy-context-group__menus {
+  padding: 0 5px;
+  margin: 0;
+  list-style: none;
+}
+
+.roy-context .roy-context-group__menus .roy-context-item {
+  display: inline-block;
+  padding: 5px 9px;
+}
+
+.roy-context .roy-context-submenu {
+  position: relative;
+}
+
+.roy-context .roy-context-submenu > .roy-context {
+  position: absolute;
+}
+
+.roy-context .roy-context-submenu > .roy-context.left {
+  left: 0;
+  transform: translateX(-100%);
+}
+
+.roy-context .roy-context-submenu > .roy-context.right {
+  right: 0;
+  transform: translateX(100%);
+}
+
+.roy-context .roy-context-submenu > .roy-context.top {
+  top: -6px;
+}
+
+.roy-context .roy-context-submenu > .roy-context.bottom {
+  bottom: -6px;
+}
+
+.roy-context .roy-context-submenu .roy-context-submenu__title {
+  margin-right: 10px;
+}
+
+.roy-context .roy-context-submenu .roy-context-submenu__icon {
+  position: absolute;
+  right: 5px;
+}
+
+.roy-context .roy-context-submenu .roy-context-submenu__icon::before {
+  content: '\e622';
+}
+
+.roy-context--default .roy-context-item--hover {
+  background-color: #4579e1;
+}
+
+.roy-context--bright .roy-context-item--hover {
+  background-color: #ef5350;
+}
+
+.roy-context--dark .roy-context-item--hover {
+  background-color: #2d3035;
+}
+
+.roy-context--dark {
+  background: #222222;
+  box-shadow: 2px 2px 8px 0 rgba(0, 0, 0, 0.2);
+  border: 1px solid #111111;
+}
+
+.roy-context--dark .roy-context-item {
+  color: #ccc;
+}
+
+.roy-context-item i {
+  padding: 0 10px 0 0;
+}
+
+.roy-context--danger {
+  color: #f54536 !important;
+}
+
+.roy-context--danger:hover {
+  background: #f54536;
+  color: #fff !important;
+}
+</style>

+ 33 - 0
src/components/yvan-context/components/ContextGroup.vue

@@ -0,0 +1,33 @@
+<template>
+  <li class="v-contextmenu-group">
+    <ul :style="menusStyle" class="v-contextmenu-group__menus">
+      <slot></slot>
+    </ul>
+  </li>
+</template>
+
+<script>
+export default {
+  name: 'ContextGroup',
+
+  props: {
+    maxWidth: {
+      type: [Number, String],
+      default: undefined
+    }
+  },
+
+  computed: {
+    menusStyle() {
+      if (!this.maxWidth) {
+        return null
+      }
+
+      return {
+        'max-width': typeof this.maxWidth === 'number' ? `${this.maxWidth}px` : this.maxWidth,
+        'overflow-x': 'auto'
+      }
+    }
+  }
+}
+</script>

+ 76 - 0
src/components/yvan-context/components/ContextItem.vue

@@ -0,0 +1,76 @@
+<template>
+  <li v-if="divider" class="roy-context-divider"></li>
+
+  <li
+    v-else
+    :class="classname"
+    @click="handleClick"
+    @mouseenter="handleMouseenter"
+    @mouseleave="handleMouseleave"
+  >
+    <slot></slot>
+  </li>
+</template>
+
+<script>
+export default {
+  name: 'ContextItem',
+
+  inject: ['$$contextmenu'],
+
+  props: {
+    divider: Boolean,
+    disabled: Boolean,
+    autoHide: {
+      type: Boolean,
+      default: true
+    }
+  },
+
+  data() {
+    return {
+      hover: false
+    }
+  },
+  computed: {
+    classname() {
+      return {
+        'roy-context-item': !this.divider,
+        'roy-context-item--hover': this.hover,
+        'roy-context-item--disabled': this.disabled
+      }
+    }
+  },
+
+  methods: {
+    handleMouseenter(event) {
+      if (this.disabled) {
+        return
+      }
+
+      this.hover = true
+
+      this.$emit('mouseenter', this, event)
+    },
+    handleMouseleave(event) {
+      if (this.disabled) {
+        return
+      }
+
+      this.hover = false
+
+      this.$emit('mouseleave', this, event)
+    },
+
+    handleClick(event) {
+      if (this.disabled) {
+        return
+      }
+
+      this.$emit('click', this, event)
+
+      this.autoHide && this.$$contextmenu.hide()
+    }
+  }
+}
+</script>

+ 91 - 0
src/components/yvan-context/components/ContextSubmenu.vue

@@ -0,0 +1,91 @@
+<template>
+  <li :class="classname" @mouseenter="handleMouseenter" @mouseleave="handleMouseleave">
+    <span class="v-contextmenu-submenu__title">
+      <slot name="title">{{ title }}</slot>
+
+      <span class="v-contextmenu-iconfont v-contextmenu-submenu__icon"></span>
+    </span>
+
+    <ul v-show="hover" ref="submenu" :class="submenuCls">
+      <slot></slot>
+    </ul>
+  </li>
+</template>
+
+<script>
+export default {
+  name: 'VContextmenuSubmenu',
+
+  props: {
+    title: {
+      type: String,
+      default: ''
+    },
+    disabled: Boolean
+  },
+
+  data() {
+    return {
+      hover: false,
+      submenuPlacement: []
+    }
+  },
+  computed: {
+    classname() {
+      return {
+        'v-contextmenu-item': true,
+        'v-contextmenu-submenu': true,
+        'v-contextmenu-item--hover': this.hover,
+        'v-contextmenu-item--disabled': this.disabled
+      }
+    },
+    submenuCls() {
+      return ['v-contextmenu', ...this.submenuPlacement]
+    }
+  },
+
+  methods: {
+    handleMouseenter(event) {
+      if (this.disabled) {
+        return
+      }
+
+      const { target } = event
+      const targetDimension = target.getBoundingClientRect()
+
+      this.hover = true
+
+      this.$emit('mouseenter', this, event)
+
+      this.$nextTick(() => {
+        const submenuWidth = this.$refs.submenu.clientWidth
+        const submenuHeight = this.$refs.submenu.clientHeight
+        const submenuPlacement = []
+
+        if (targetDimension.right + submenuWidth >= window.innerWidth) {
+          submenuPlacement.push('left')
+        } else {
+          submenuPlacement.push('right')
+        }
+
+        if (targetDimension.bottom + submenuHeight >= window.innerHeight) {
+          submenuPlacement.push('bottom')
+        } else {
+          submenuPlacement.push('top')
+        }
+
+        this.submenuPlacement = submenuPlacement
+      })
+    },
+    handleMouseleave(event) {
+      if (this.disabled) {
+        return
+      }
+
+      this.hover = false
+
+      this.$emit('mouseleave', this, event)
+    }
+  }
+}
+</script>

+ 10 - 0
src/components/yvan-context/directive.js

@@ -0,0 +1,10 @@
+/* eslint-disable no-param-reassign */
+export default {
+  // 之所以用 inserted 而不是 bind,是需要确保 contentmenu mounted 后才进行 addRef 操作
+  inserted(el, binding, vnode) {
+    const node = vnode.context.$refs[binding.arg] || vnode.context.$refs[binding.value]
+    const contextmenu = Object.prototype.toString.call(node) === '[object Array]' ? node[0] : node
+    contextmenu.addRef({ el, vnode })
+    contextmenu.$contextmenuId = el.id || contextmenu._uid // eslint-disable-line no-underscore-dangle
+  }
+}

+ 7 - 0
src/components/yvan-context/index.js

@@ -0,0 +1,7 @@
+import directive from './directive'
+import Context from './components/Context.vue'
+import ContextItem from './components/ContextItem.vue'
+import ContextGroup from './components/ContextGroup.vue'
+import ContextSubmenu from './components/ContextSubmenu.vue'
+
+export { directive, Context, ContextItem, ContextGroup, ContextSubmenu }

+ 39 - 0
src/components/yvan-editor/Area.vue

@@ -0,0 +1,39 @@
+<template>
+  <div
+    :style="{
+      left: start.x + 'px',
+      top: start.y + 'px',
+      width: width + 'px',
+      height: height + 'px'
+    }"
+    class="roy-designer-area"
+  ></div>
+</template>
+
+<script>
+export default {
+  name: 'RoyArea',
+  props: {
+    start: {
+      type: Object,
+      default: () => {}
+    },
+    width: {
+      type: Number,
+      default: 0
+    },
+    height: {
+      type: Number,
+      default: 0
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.roy-designer-area {
+  border: 1px solid var(--roy-color-primary-light-5);
+  background: rgba(var(--roy-color-primary-rgb), 0.1);
+  position: absolute;
+}
+</style>

+ 532 - 0
src/components/yvan-editor/ComponentAdjuster.vue

@@ -0,0 +1,532 @@
+<template>
+  <div
+      ref="adjuster"
+      :style="adjusterStyle"
+      class="roy-component-adjuster"
+      @click="selectCurComponent"
+      @mousedown="handleMouseDownOnShape"
+  >
+    <span
+        v-show="isActive && showRotate"
+        class="ri-checkbox-blank-circle-line roy-component-adjuster__rotate"
+        @mousedown="handleRotate"
+    ></span>
+    <span v-show="element.isLock" class="ri-lock-fill roy-component-adjuster__lock"></span>
+    <span
+        v-show="element.bindValue"
+        class="ri-link-unlink-m roy-component-adjuster__bind"
+        @click="unlinkElement"
+    ></span>
+    <span
+        :class="element.icon"
+        class="roy-component-adjuster__move"
+        @mousedown="handleMouseMoveItem"
+    ></span>
+    <div
+        v-for="item in isActive ? pointList : []"
+        :key="item"
+        :class="`roy-component-adjuster__shape-point--${item}`"
+        :style="getPointStyle(item)"
+        @mousedown="handleMouseDownOnPoint(item, $event)"
+    ></div>
+    <div ref="slot" class="adjuster-container">
+      <slot></slot>
+    </div>
+  </div>
+</template>
+
+<script>
+import {mapState} from "pinia";
+import commonMixin from '@/mixin/commonMixin'
+import {mod360} from '@/utils/translate'
+import calculateComponentPositionAndSize from '@/utils/calculateComponentPositonAndSize'
+import {isPreventDrop} from '@/utils/html-util'
+import eventBus from '@/utils/eventBus'
+import {globalStore} from "@/store";
+
+/**
+ * 组件小圆角
+ */
+export default {
+  name: 'ComponentAdjuster',
+  mixins: [commonMixin],
+  components: {},
+  props: {
+    active: {
+      type: Boolean,
+      default: false
+    },
+    element: {
+      required: true,
+      type: Object,
+      default: () => {
+      }
+    },
+    defaultStyle: {
+      required: true,
+      type: Object,
+      default: () => {
+      }
+    },
+    index: {
+      required: true,
+      type: [Number, String],
+      default: 0
+    },
+    scale: {
+      required: true,
+      type: [Number, String],
+      default: 1
+    }
+  },
+  data() {
+    return {
+      cursors: {},
+      initialAngle: {
+        // 每个点对应的初始角度
+        lt: 0,
+        t: 45,
+        rt: 90,
+        r: 135,
+        rb: 180,
+        b: 225,
+        lb: 270,
+        l: 315
+      },
+      angleToCursor: [
+        // 每个范围的角度对应的光标
+        {start: 338, end: 23, cursor: 'nw'},
+        {start: 23, end: 68, cursor: 'n'},
+        {start: 68, end: 113, cursor: 'ne'},
+        {start: 113, end: 158, cursor: 'e'},
+        {start: 158, end: 203, cursor: 'se'},
+        {start: 203, end: 248, cursor: 's'},
+        {start: 248, end: 293, cursor: 'sw'},
+        {start: 293, end: 338, cursor: 'w'}
+      ]
+    }
+  },
+  computed: {
+    ...mapState(globalStore, {
+      editor: (state) => state.editor,
+      curComponent: (state) => state.curComponent
+    }),
+    pointList() {
+      const isTable = ['RoySimpleTable', 'RoyComplexTable'].includes(this.element.component)
+      if (isTable) {
+        return []
+      }
+      if (['RoyLine'].includes(this.element.component)) {
+        return ['r', 'l']
+      }
+      return ['lt', 't', 'rt', 'r', 'rb', 'b', 'lb', 'l']
+    },
+    isActive() {
+      return this.active && !this.element.isLock
+    },
+    showRotate() {
+      return !['RoySimpleTable', 'RoyComplexTable'].includes(this.element?.component || '')
+    },
+    adjusterStyle() {
+      return {
+        border: this.isActive ? '0.5px dashed var(--roy-text-color-secondary)' : undefined
+      }
+    }
+  },
+  methods: {
+    initMounted() {
+      this.cursors = this.getCursor()
+    },
+    getPointStyle(point) {
+      const {width, height} = this.defaultStyle
+      const hasT = /t/.test(point)
+      const hasB = /b/.test(point)
+      const hasL = /l/.test(point)
+      const hasR = /r/.test(point)
+      let newLeft = 0
+      let newTop = 0
+
+      // 四个角的点
+      if (point.length === 2) {
+        newLeft = hasL ? 0 : width
+        newTop = hasT ? 0 : height
+      } else {
+        // 上下两点的点,宽度居中
+        if (hasT || hasB) {
+          newLeft = width / 2
+          newTop = hasT ? 0 : height
+        }
+
+        // 左右两边的点,高度居中
+        if (hasL || hasR) {
+          newLeft = hasL ? 0 : width
+          newTop = Math.floor(height / 2)
+        }
+      }
+
+      return {
+        marginLeft: '-4px',
+        marginTop: '-4px',
+        left: `${newLeft}px`,
+        top: `${newTop}px`,
+        cursor: this.cursors[point]
+      }
+    },
+    getCursor() {
+      const rotate = mod360(this.curComponent?.style?.rotate || 0) // 取余 360
+      const result = {}
+      let lastMatchIndex = -1 // 从上一个命中的角度的索引开始匹配下一个,降低时间复杂度
+
+      this.pointList.forEach((point) => {
+        const angle = mod360(this.initialAngle[point] + rotate)
+        const len = this.angleToCursor.length
+        // eslint-disable-next-line no-constant-condition
+        while (true) {
+          lastMatchIndex = (lastMatchIndex + 1) % len
+          const angleLimit = this.angleToCursor[lastMatchIndex]
+          if (angle < 23 || angle >= 338) {
+            result[point] = 'nw-resize'
+
+            return
+          }
+
+          if (angleLimit.start <= angle && angle < angleLimit.end) {
+            result[point] = angleLimit.cursor + '-resize'
+
+            return
+          }
+        }
+      })
+
+      return result
+    },
+    handleRotate(e) {
+      globalStore().setClickComponentStatus(true)
+      e.preventDefault()
+      e.stopPropagation()
+      // 初始坐标和初始角度
+      const pos = {...this.defaultStyle}
+      const startY = e.clientY
+      const startX = e.clientX
+      const startRotate = pos.rotate
+
+      // 获取元素中心点位置
+      const rect = this.$el.getBoundingClientRect()
+      const centerX = rect.left + rect.width / 2
+      const centerY = rect.top + rect.height / 2
+
+      // 旋转前的角度
+      const rotateDegreeBefore = Math.atan2(startY - centerY, startX - centerX) / (Math.PI / 180)
+
+      // 如果元素没有移动,则不保存快照
+      let hasMove = false
+      const move = (moveEvent) => {
+        hasMove = true
+        const curX = moveEvent.clientX
+        const curY = moveEvent.clientY
+        // 旋转后的角度
+        const rotateDegreeAfter = Math.atan2(curY - centerY, curX - centerX) / (Math.PI / 180)
+        // 获取旋转的角度值
+        pos.rotate = startRotate + rotateDegreeAfter - rotateDegreeBefore
+        // 修改当前组件样式
+        this.$store.commit('printTemplateModule/setShapeStyle', pos)
+      }
+
+      const up = () => {
+        hasMove && this.$store.commit('printTemplateModule/recordSnapshot')
+        document.removeEventListener('mousemove', move)
+        document.removeEventListener('mouseup', up)
+        this.cursors = this.getCursor() // 根据旋转角度获取光标位置
+      }
+
+      document.addEventListener('mousemove', move)
+      document.addEventListener('mouseup', up)
+    },
+    unlinkElement() {
+      globalStore().setBindValue({
+        id: this.element.id,
+        bindValue: null
+      })
+      globalStore().setPropValue({
+        id: this.element.id,
+        propValue: ''
+      })
+    },
+    handleMouseDownOnPoint(point, e) {
+      globalStore().setInEditorStatus(true)
+      globalStore().setClickComponentStatus(true)
+      e.stopPropagation()
+      e.preventDefault()
+
+      const style = {...this.defaultStyle}
+
+      // 组件宽高比
+      const proportion = style.width / style.height
+
+      // 组件中心点
+      const center = {
+        x: style.left + style.width / 2,
+        y: style.top + style.height / 2
+      }
+
+      // 获取画布位移信息
+      const editorRectInfo = this.editor.getBoundingClientRect()
+
+      // 获取 point 与实际拖动基准点的差值
+      const pointRect = e.target.getBoundingClientRect()
+      // 当前点击圆点相对于画布的中心坐标
+      const curPoint = {
+        x: Math.round(
+            pointRect.left / this.scale -
+            editorRectInfo.left / this.scale +
+            e.target.offsetWidth / this.scale / 2
+        ),
+        y: Math.round(
+            pointRect.top / this.scale -
+            editorRectInfo.top / this.scale +
+            e.target.offsetHeight / this.scale / 2
+        )
+      }
+
+      // 获取对称点的坐标
+      const symmetricPoint = {
+        x: center.x - (curPoint.x - center.x),
+        y: center.y - (curPoint.y - center.y)
+      }
+
+      // 是否需要保存快照
+      let needSave = false
+      let isFirst = true
+
+      const needLockProportion = this.isNeedLockProportion()
+      const move = (moveEvent) => {
+        // 第一次点击时也会触发 move,所以会有“刚点击组件但未移动,组件的大小却改变了”的情况发生
+        // 因此第一次点击时不触发 move 事件
+        if (isFirst) {
+          isFirst = false
+          return
+        }
+        needSave = true
+        const curPosition = {
+          x: (moveEvent.clientX - Math.round(editorRectInfo.left)) / this.scale,
+          y: (moveEvent.clientY - Math.round(editorRectInfo.top)) / this.scale
+        }
+        calculateComponentPositionAndSize(
+            point,
+            style,
+            curPosition,
+            proportion,
+            needLockProportion,
+            {
+              center,
+              curPoint,
+              symmetricPoint
+            }
+        )
+        globalStore().setShapeStyle(style)
+      }
+
+      const up = () => {
+        document.removeEventListener('mousemove', move)
+        document.removeEventListener('mouseup', up)
+        needSave && globalStore().recordSnapshot()
+      }
+
+      document.addEventListener('mousemove', move)
+      document.addEventListener('mouseup', up)
+    },
+    isNeedLockProportion() {
+      if (this.element.component !== 'RoyGroup') {
+        return false
+      }
+      const rotates = [0, 90, 180, 360]
+      for (const component of this.element.propValue) {
+        if (!rotates.includes(mod360(parseInt(component.style.rotate)))) {
+          return true
+        }
+      }
+      return false
+    },
+    selectCurComponent(e) {
+      // 阻止向父组件冒泡
+      e.stopPropagation()
+      e.preventDefault()
+    },
+    handleMouseDownOnShape(e) {
+      this.$nextTick(() => eventBus.emit('componentClick'))
+
+      globalStore().setInEditorStatus(true)
+      globalStore().setClickComponentStatus(true)
+      if (isPreventDrop(this.element.component)) {
+        e.preventDefault()
+      }
+
+      e.stopPropagation()
+      globalStore().setCurComponent({
+        component: this.element,
+        index: this.index
+      })
+      if (this.element.isLock) {
+        return
+      }
+
+      this.cursors = this.getCursor() // 根据旋转角度获取光标位置
+    },
+    handleMouseMoveItem(e) {
+      if (!this.isActive) {
+        return
+      }
+      let adjuster = this.$refs.slot
+      const pos = {...this.defaultStyle}
+      const startY = e.clientY
+      const startX = e.clientX
+      // 如果直接修改属性,值的类型会变为字符串,所以要转为数值型
+      const startTop = Number(pos.top)
+      const startLeft = Number(pos.left)
+
+      // 如果元素没有移动,则不保存快照
+      let hasMove = false
+      const move = (moveEvent) => {
+        hasMove = true
+        const curX = moveEvent.clientX
+        const curY = moveEvent.clientY
+        const editorRectInfo = this.editor
+        pos.top = Math.min(
+            Math.max(0, (curY - startY) / this.scale + startTop),
+            editorRectInfo.offsetHeight - adjuster.offsetHeight
+        )
+        pos.left = Math.min(
+            Math.max(0, (curX - startX) / this.scale + startLeft),
+            editorRectInfo.offsetWidth - adjuster.offsetWidth
+        )
+
+        // 修改当前组件样式
+        globalStore().setShapeStyle(pos)
+        // 等更新完当前组件的样式并绘制到屏幕后再判断是否需要吸附
+        // 如果不使用 $nextTick,吸附后将无法移动
+        this.$nextTick(() => {
+          // 触发元素移动事件,用于显示标线、吸附功能
+          // 后面两个参数代表鼠标移动方向
+          // curY - startY > 0 true 表示向下移动 false 表示向上移动
+          // curX - startX > 0 true 表示向右移动 false 表示向左移动
+          eventBus.emit('move', curY - startY > 0, curX - startX > 0, curX, curY)
+        })
+      }
+
+      const up = () => {
+        hasMove && globalStore().recordSnapshot()
+        // 触发元素停止移动事件,用于隐藏标线
+        eventBus.emit('unmove')
+        document.removeEventListener('mousemove', move)
+        document.removeEventListener('mouseup', up)
+      }
+
+      document.addEventListener('mousemove', move)
+      document.addEventListener('mouseup', up)
+    }
+  },
+  created() {
+  },
+  mounted() {
+    this.initMounted()
+  },
+  watch: {}
+}
+</script>
+
+<style lang="less">
+.roy-component-adjuster {
+  margin: 0;
+  padding: 0;
+  position: absolute;
+  user-select: none;
+
+  &:hover {
+    border: 0.5px dotted var(--roy-text-color-secondary);
+  }
+
+  .roy-component-adjuster__rotate {
+    position: absolute;
+    top: -28px;
+    left: 50%;
+    transform: translateX(-50%);
+    cursor: grab;
+    color: var(--roy-text-color-regular);
+    font-size: 10px;
+    font-weight: 400;
+
+    &:active {
+      cursor: grabbing;
+    }
+
+    &:after {
+      content: '';
+      height: 20px;
+      border-right: var(--roy-text-color-regular) dashed 1px;
+      position: absolute;
+      left: 5.5px;
+      top: 12px;
+    }
+  }
+
+  .roy-component-adjuster__lock {
+    position: absolute;
+    top: -15px;
+    right: 0;
+    z-index: 9;
+  }
+
+  .roy-component-adjuster__move {
+    position: absolute;
+    top: 0;
+    left: -25px;
+    z-index: 9;
+    padding: 2px;
+    font-size: 10px;
+    border-radius: 2px;
+    font-weight: 100;
+    cursor: move;
+    background: var(--roy-color-primary-light-7);
+    color: #aaaaaa;
+
+    &:hover {
+      color: #212121;
+    }
+
+    &:active {
+      color: #212121;
+      background: var(--roy-color-primary-light-3);
+    }
+  }
+
+  .roy-component-adjuster__bind {
+    position: absolute;
+    top: 20px;
+    left: -25px;
+    z-index: 9;
+    cursor: pointer;
+    border-radius: 2px;
+    padding: 2px;
+    font-size: 10px;
+    background: #ffffff;
+    color: #999;
+  }
+
+  [class^='roy-component-adjuster__shape-point--'] {
+    position: absolute;
+    border: 1px solid var(--roy-color-primary);
+    box-shadow: 0 0 2px #bbb;
+    background: #fff;
+    width: 6px;
+    height: 6px;
+    border-radius: 0;
+    z-index: 9;
+  }
+
+  .adjuster-container {
+    cursor: initial;
+    width: 100%;
+    height: 100%;
+  }
+}
+</style>

+ 666 - 0
src/components/yvan-editor/Editor.vue

@@ -0,0 +1,666 @@
+<template>
+  <div :style="panelWidth" class="yvan-print-designer-main__page">
+    <SketchRuler
+        v-show="showRuler"
+        ref="sketchRuler"
+        :cornerActive="false"
+        :height="rulerHeight"
+        :horLineArr="lines.h"
+        :lang="lang"
+        :palette="palette"
+        :scale="realScale"
+        :shadow="shadow"
+        :startX="startX"
+        :startY="startY"
+        :thick="thick"
+        :verLineArr="lines.v"
+        :width="rulerWidth"
+        @handleLine="handleLine"
+        @onCornerClick="handleCornerClick"
+    >
+    </SketchRuler>
+    <div id="screens" ref="screensRef" @scroll="handleScroll" @wheel="handleWheel">
+      <div ref="containerRef" class="screen-container">
+        <div
+            id="designer-page"
+            v-contextmenu="'contextmenu'"
+            :style="canvasStyle"
+            @contextmenu="handleContextMenu"
+            @mousedown="handleMouseDown"
+        >
+          <ComponentAdjuster
+              v-for="(item, index) in componentData"
+              :key="item.id"
+              :active="item.id === (curComponent || {}).id"
+              :class="{ lock: item.isLock }"
+              :default-style="item.style"
+              :element="item"
+              :index="index"
+              :scale="scale"
+              :style="getShapeStyle(item.style)"
+          >
+            <component
+                :is="item.component"
+                :id="'yvan-print-component-' + item.id"
+                :active="item.id === (curComponent || {}).id"
+                :bind-value="item.bindValue"
+                :element="item"
+                :prop-value="item.propValue"
+                :scale="scale"
+            />
+          </ComponentAdjuster>
+          <!-- 选中区域 -->
+          <Area v-show="isShowArea" :height="height" :start="start" :width="width"/>
+          <!-- 标线 -->
+          <EditorLine/>
+          <!-- 上下边距线 -->
+          <div
+              :style="`top: ${pageConfig?.pageMarginTop * realScale}px`"
+              class="yvan-print-margin-top-line"
+          ></div>
+          <div
+              :style="`bottom: ${pageConfig?.pageMarginBottom * realScale}px`"
+              class="yvan-print-margin-bottom-line"
+          ></div>
+        </div>
+      </div>
+    </div>
+    <Context ref="contextmenu" :theme="contextTheme">
+      <ContextItem
+          v-for="item in contextMenu"
+          :key="item.code"
+          :class="`yvan-print-context--${item.status}`"
+          @click="item.event"
+      >
+        <i :class="item.icon"></i>
+        <span>{{ item.label }}</span>
+      </ContextItem>
+    </Context>
+    <!-- 坐标-->
+    <EditorCoordinate/>
+  </div>
+</template>
+
+<script>
+import {mapActions, mapState} from "pinia";
+import {globalStore, ruleStore, modeStore} from "@/store";
+import SketchRuler from '../sketch-ruler/sketchRuler.vue'
+import CONSTANT from '@/utils/constant.js'
+import {Context, ContextItem, directive} from '@/components/yvan-context'
+import ComponentAdjuster from '@/components/yvan-editor/ComponentAdjuster.vue'
+import {getComponentRotatedStyle, getShapeStyle} from '@/utils/style-util.js'
+import Big from 'big.js'
+import RoyText from '@/components/PageComponents/RoyText.vue'
+import RoySimpleText from '@/components/PageComponents/RoySimpleText.vue'
+// import RoyRect from '@/components/PageComponents/RoyRect'
+import RoyLine from '@/components/PageComponents/RoyLine.vue'
+// import RoyImage from '@/components/PageComponents/RoyImage'
+// import RoyStar from '@/components/PageComponents/RoyStar'
+// import RoyCircle from '@/components/PageComponents/RoyCircle'
+// import RoySimpleTable from '@/components/PageComponents/RoyTable/RoySimpleTable.vue'
+// import RoyComplexTable from '@/components/PageComponents/RoyTable/RoyComplexTable'
+// import RoyGroup from '@/components/PageComponents/RoyGroup'
+import Area from '@/components/yvan-editor/Area.vue'
+import commonMixin from '@/mixin/commonMixin'
+import {$, isPreventDrop} from '@/utils/html-util.js'
+import EditorLine from '@/components/yvan-editor/EditorLine.vue'
+import EditorCoordinate from '@/components/yvan-editor/EditorCoordinate.vue'
+
+const {MIN_SCALE, MAX_SCALE} = CONSTANT
+
+export default {
+  name: 'yvan-print-designer-editor',
+  directives: {
+    contextmenu: directive
+  },
+  mixins: [commonMixin],
+  components: {
+    EditorCoordinate,
+    EditorLine,
+    SketchRuler,
+    Context,
+    ContextItem,
+    ComponentAdjuster,
+    RoyText,
+    RoySimpleText,
+    // RoyGroup,
+    // RoyRect,
+    RoyLine,
+    // RoyCircle,
+    // RoyStar,
+    // RoyImage,
+    // RoySimpleTable,
+    // RoyComplexTable,
+    Area
+  },
+  props: {
+    showRight: {
+      type: Boolean,
+      default: true
+    }
+  },
+  data() {
+    return {
+      rulerWidth: 0,
+      rulerHeight: 0,
+      startX: -19,
+      startY: -25,
+      lines: {
+        h: [],
+        v: []
+      },
+      thick: 20,
+      lang: 'zh-CN', // 中英文
+      isShowRuler: true, // 显示标尺
+      isShowReferLine: true, // 显示参考线
+      palette: {
+        bgColor: 'rgba(225,225,225, 0)',
+        longfgColor: '#BABBBC',
+        shortfgColor: '#C8CDD0',
+        fontColor: '#7D8694',
+        shadowColor: '#E8E8E8',
+        lineColor: '#4579e1',
+        borderColor: '#DADADC',
+        cornerActiveColor: '#4579e1'
+      },
+      editorX: 0,
+      editorY: 0,
+      start: {
+        // 选中区域的起点
+        x: 0,
+        y: 0
+      },
+      width: 0,
+      height: 0,
+      isShowArea: false,
+      svgFilterAttrs: ['width', 'height', 'top', 'left', 'rotate'],
+    }
+  },
+  computed: {
+    ...mapState(globalStore, {
+      componentData: (state) => state.componentData,
+      curComponent: (state) => state.curComponent,
+      editor: (state) => state.editor,
+      pageConfig: (state) => state.pageConfig
+    }),
+    ...mapState(ruleStore, {
+      realScale: (state) => state.scale,
+      rectWidth: (state) => state.rectWidth,
+      rectHeight: (state) => state.rectHeight,
+      needReDrawRuler: (state) => state.needReDrawRuler,
+      showRuler: (state) => state.showRuler,
+    }),
+    contextTheme() {
+      return this.isNightMode ? 'dark' : 'default'
+    },
+    contextMenu() {
+      if (!this.curComponent) {
+        return [
+          {
+            code: 'setting',
+            icon: 'ri-list-settings-line',
+            label: '属性',
+            status: 'default',
+            event: () => {
+              // this.$store.commit('printTemplateModule/setGlobalCount')
+            }
+          },
+          {
+            code: 'paste',
+            icon: 'ri-clipboard-line',
+            label: '粘贴',
+            status: 'default',
+            event: () => {
+              // this.$store.commit('printTemplateModule/paste', true)
+              // this.$store.commit('printTemplateModule/recordSnapshot')
+            }
+          }
+        ]
+      }
+      if (this.curComponent.isLock) {
+        return [
+          {
+            code: 'unlock',
+            icon: 'ri-lock-unlock-line',
+            label: '解锁',
+            status: 'default',
+            event: () => {
+              // this.$store.commit('printTemplateModule/unlock')
+            }
+          }
+        ]
+      }
+      return [
+        {
+          code: 'setting',
+          icon: 'ri-list-settings-line',
+          label: '属性',
+          status: 'default',
+          event: () => {
+            // this.$store.commit('printTemplateModule/setPaletteCount')
+          }
+        },
+        {
+          code: 'copy',
+          icon: 'ri-file-copy-line',
+          label: '复制',
+          status: 'default',
+          event: () => {
+            // this.$store.commit('printTemplateModule/copy')
+          }
+        },
+        {
+          code: 'cut',
+          icon: 'ri-scissors-cut-line',
+          label: '剪切',
+          status: 'default',
+          event: () => {
+            // this.$store.commit('printTemplateModule/cut')
+          }
+        },
+        {
+          code: 'del',
+          icon: 'ri-delete-bin-line',
+          label: '删除',
+          status: 'danger',
+          event: () => {
+            // this.$store.commit('printTemplateModule/deleteComponent')
+            // this.$store.commit('printTemplateModule/recordSnapshot')
+          }
+        },
+        {
+          code: 'lock',
+          icon: 'ri-lock-line',
+          label: '锁定',
+          status: 'default',
+          event: () => {
+            // this.$store.commit('printTemplateModule/lock')
+          }
+        },
+        {
+          code: 'top',
+          icon: 'ri-align-top',
+          label: '置顶',
+          status: 'default',
+          event: () => {
+            // this.$store.commit('printTemplateModule/topComponent')
+            // this.$store.commit('printTemplateModule/recordSnapshot')
+          }
+        },
+        {
+          code: 'bottom',
+          icon: 'ri-align-bottom',
+          label: '置底',
+          status: 'default',
+          event: () => {
+            // this.$store.commit('printTemplateModule/bottomComponent')
+            // this.$store.commit('printTemplateModule/recordSnapshot')
+          }
+        },
+        {
+          code: 'up',
+          icon: 'ri-arrow-up-line',
+          label: '上移',
+          status: 'default',
+          event: () => {
+            // this.$store.commit('printTemplateModule/upComponent')
+            // this.$store.commit('printTemplateModule/recordSnapshot')
+          }
+        },
+        {
+          code: 'down',
+          icon: 'ri-arrow-down-line',
+          label: '下移',
+          status: 'default',
+          event: () => {
+            // this.$store.commit('printTemplateModule/downComponent')
+            // this.$store.commit('printTemplateModule/recordSnapshot')
+          }
+        }
+      ]
+    },
+    scale() {
+      return new Big(this.realScale).div(new Big(5)).toNumber()
+    },
+    shadow() {
+      return {
+        x: 0,
+        y: 0,
+        width: this.rectWidth,
+        height: this.rectHeight
+      }
+    },
+    canvasStyle() {
+      return {
+        width: `${this.rectWidth * 5}px`,
+        height: `${this.rectHeight * 5}px`,
+        transform: `scale(${this.scale})`,
+        background: this.pageConfig?.background,
+        color: this.pageConfig?.color,
+        fontFamily: this.pageConfig?.fontFamily,
+        fontSize: this.pageConfig?.fontSize,
+        lineHeight: this.pageConfig?.lineHeight
+      }
+    },
+    isNightMode() {
+      return modeStore().isNightMode
+    },
+    panelWidth() {
+      return this.showRight ? 'width: calc(100% - 330px - 310px);' : 'width: calc(100% - 95px - 95px);'
+    }
+  },
+  methods: {
+    ...mapActions(ruleStore, ['setReDrawRuler', 'setScale']),
+    getShapeStyle,
+    handleMouseDown(e) {
+      // 如果没有选中组件 在画布上点击时需要调用 e.preventDefault() 防止触发 drop 事件
+      if (!this.curComponent || isPreventDrop(this.curComponent.component)) {
+        e.preventDefault()
+      }
+
+      this.hideArea()
+
+      // 获取编辑器的位移信息,每次点击时都需要获取一次。主要是为了方便开发时调试用。
+      const rectInfo = this.editor.getBoundingClientRect()
+      this.editorX = rectInfo.x
+      this.editorY = rectInfo.y
+
+      const startX = e.clientX
+      const startY = e.clientY
+      this.start.x = (startX - this.editorX) / this.scale
+      this.start.y = (startY - this.editorY) / this.scale
+      // 展示选中区域
+      this.isShowArea = true
+
+      const move = (moveEvent) => {
+        this.width = Math.abs((moveEvent.clientX - startX) / this.scale)
+        this.height = Math.abs((moveEvent.clientY - startY) / this.scale)
+        if (moveEvent.clientX < startX) {
+          this.start.x = (moveEvent.clientX - this.editorX) / this.scale
+        }
+
+        if (moveEvent.clientY < startY) {
+          this.start.y = (moveEvent.clientY - this.editorY) / this.scale
+        }
+      }
+
+      const up = (e) => {
+        document.removeEventListener('mousemove', move)
+        document.removeEventListener('mouseup', up)
+
+        if (e.clientX === startX && e.clientY === startY) {
+          this.hideArea()
+          return
+        }
+
+        this.createGroup()
+      }
+
+      document.addEventListener('mousemove', move)
+      document.addEventListener('mouseup', up)
+    },
+    handleContextMenu(e) {
+      e.stopPropagation()
+      e.preventDefault()
+      let top = e.offsetY
+      let left = e.offsetX
+      globalStore().showContextMenu(globalStore, {top, left})
+    },
+    hideArea() {
+      this.isShowArea = 0
+      this.width = 0
+      this.height = 0
+
+      globalStore().setAreaData({
+        style: {
+          left: 0,
+          top: 0,
+          width: 0,
+          height: 0
+        },
+        components: []
+      })
+    },
+    createGroup() {
+      // 获取选中区域的组件数据
+      const areaData = this.getSelectArea()
+      if (areaData.length <= 1) {
+        this.hideArea()
+        return
+      }
+
+      // 根据选中区域和区域中每个组件的位移信息来创建 Group 组件
+      // 要遍历选择区域的每个组件,获取它们的 left top right bottom 信息来进行比较
+      let top = Infinity,
+          left = Infinity
+      let right = -Infinity,
+          bottom = -Infinity
+      areaData.forEach((component) => {
+        let style = {}
+        if (component.component === 'Group') {
+          component.propValue.forEach((item) => {
+            const rectInfo = $(`#roy-component-${item.id}`).getBoundingClientRect()
+            style.left = rectInfo.left - this.editorX
+            style.top = rectInfo.top - this.editorY
+            style.right = rectInfo.right - this.editorX
+            style.bottom = rectInfo.bottom - this.editorY
+
+            if (style.left < left) {
+              left = style.left
+            }
+            if (style.top < top) {
+              top = style.top
+            }
+            if (style.right > right) {
+              right = style.right
+            }
+            if (style.bottom > bottom) {
+              bottom = style.bottom
+            }
+          })
+        } else {
+          style = getComponentRotatedStyle(component.style)
+        }
+
+        if (style.left < left) {
+          left = style.left
+        }
+        if (style.top < top) {
+          top = style.top
+        }
+        if (style.right > right) {
+          right = style.right
+        }
+        if (style.bottom > bottom) {
+          bottom = style.bottom
+        }
+      })
+
+      this.start.x = left
+      this.start.y = top
+      this.width = right - left
+      this.height = bottom - top
+
+      // 设置选中区域位移大小信息和区域内的组件数据
+      globalStore().setAreaData({
+        style: {
+          left,
+          top,
+          width: this.width,
+          height: this.height
+        },
+        components: areaData
+      })
+    },
+    getSelectArea() {
+      const result = []
+      // 区域起点坐标
+      const {x, y} = this.start
+      // 计算所有的组件数据,判断是否在选中区域内
+      this.componentData.forEach((component) => {
+        if (component.isLock) {
+          return
+        }
+
+        const {left, top, width, height} = getComponentRotatedStyle(component.style)
+        if (
+            x <= left &&
+            y <= top &&
+            left + width <= x + this.width &&
+            top + height <= y + this.height
+        ) {
+          result.push(component)
+        }
+      })
+
+      // 返回在选中区域内的所有组件
+      return result
+    },
+    handleLine(lines) {
+      this.lines = lines
+    },
+    handleCornerClick() {
+    },
+    handleScroll() {
+      const screensRect = document.querySelector('#screens').getBoundingClientRect()
+      const canvasRect = document.querySelector('#designer-page').getBoundingClientRect()
+      // 标尺开始的刻度
+      const startX = (screensRect.left + this.thick - canvasRect.left) / this.realScale
+      const startY = (screensRect.top + this.thick - canvasRect.top) / this.realScale
+
+      this.startX = startX
+      this.startY = startY
+    },
+    // 控制缩放值
+    handleWheel(e) {
+      if (e.ctrlKey || e.metaKey) {
+        e.preventDefault()
+        const nextScale = parseFloat(Math.max(0.2, this.scale - e.deltaY / 500).toFixed(2))
+        if (nextScale <= MAX_SCALE && nextScale >= MIN_SCALE) {
+          this.setScale(nextScale)
+        }
+      }
+      this.$nextTick(() => {
+        this.handleScroll()
+      })
+    }
+  },
+  mounted() {
+    console.log("mounted >>> editor")
+    this.rulerWidth = this.$el.offsetWidth
+    this.rulerHeight = this.$el.offsetHeight
+    this.$refs.screensRef.scrollLeft =
+        this.$refs.containerRef.getBoundingClientRect().width / 2 - this.rectWidth
+    // 获取编辑器元素
+    // this.$store.commit('printTemplateModule/getEditor')
+    globalStore().getEditor()
+  },
+  watch: {
+    isNightMode: {
+      handler(newVal) {
+        // this.palette = {
+        //   bgColor: 'rgba(225,225,225, 0)',
+        //   longfgColor: '#BABBBC',
+        //   shortfgColor: '#C8CDD0',
+        //   fontColor: '#7D8694',
+        //   shadowColor: newVal ? '#444444' : '#E8E8E8',
+        //   lineColor: '#4579e1',
+        //   borderColor: newVal ? '#636466' : '#DADADC',
+        //   cornerActiveColor: '#4579e1'
+        // }
+        // this.setReDrawRuler()
+      }
+    },
+    showRight: {
+      handler() {
+        this.$nextTick(() => {
+          this.rulerWidth = this.$el.offsetWidth
+          this.rulerHeight = this.$el.offsetHeight
+          this.$refs.screensRef.scrollLeft =
+              this.$refs.containerRef.getBoundingClientRect().width / 2 - this.rectWidth
+          this.setReDrawRuler()
+        })
+      }
+    },
+    needReDrawRuler: {
+      handler() {
+        console.log("needReDrawRuler >>> ", this)
+        this.$nextTick(() => {
+          this.handleScroll()
+          this.$refs.sketchRuler?.$children[0]?.$children[0]?.drawRuler()
+          this.$refs.sketchRuler?.$children[1]?.$children[0]?.drawRuler()
+        })
+      }
+    }
+  }
+}
+</script>
+<style lang="less">
+#screens {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  overflow: auto;
+}
+
+.screen-container {
+  position: absolute;
+  width: 5000px;
+  height: 5000px;
+}
+
+.yvan-print-designer-main__page {
+  position: absolute;
+  height: calc(100% - 100px);
+  border: 1px solid var(--yvan-border-color);
+  padding: 0 !important;
+  margin: 0;
+  background-color: rgb(255, 255, 255);
+  background-image: linear-gradient(45deg, rgb(247, 247, 247) 25%, transparent 25%),
+  linear-gradient(-45deg, rgb(247, 247, 247) 25%, transparent 25%),
+  linear-gradient(45deg, transparent 75%, rgb(247, 247, 247) 75%),
+  linear-gradient(-45deg, transparent 75%, rgb(247, 247, 247) 75%);
+  background-size: 20px 20px;
+  background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
+
+  #designer-page {
+    background: #fff;
+    box-shadow: rgba(0, 0, 0, 0.12) 0 1px 3px, rgba(0, 0, 0, 0.24) 0 1px 2px;
+    position: absolute;
+    top: 60px;
+    transform-origin: 50% 0;
+    margin-left: -80px;
+    left: 50%;
+
+    .lock {
+      opacity: 0.5;
+
+      &:hover {
+        cursor: not-allowed;
+      }
+    }
+  }
+
+  .yvan-print-margin-top-line,
+  .yvan-print-margin-bottom-line {
+    position: absolute;
+    height: 0;
+    width: 100%;
+    border-top: 1px dashed #ccc;
+  }
+}
+
+#yvan-print-print-template-designer[theme='dark'] {
+  .yvan-print-designer-main__page {
+    background-color: #1c1c1c;
+    background-image: linear-gradient(45deg, #212121 25%, transparent 25%),
+    linear-gradient(-45deg, #232323 25%, transparent 25%),
+    linear-gradient(45deg, transparent 75%, #232323 75%),
+    linear-gradient(-45deg, transparent 75%, #232323 75%);
+  }
+
+  #designer-page {
+    filter: brightness(0.6);
+  }
+}
+</style>

+ 76 - 0
src/components/yvan-editor/EditorCoordinate.vue

@@ -0,0 +1,76 @@
+<template>
+  <div v-show="showCoordinate" :style="coordinateStyle" class="roy-editor-coordinate">
+    {{ left }},{{ top }}
+  </div>
+</template>
+
+<script>
+import {mapState} from 'pinia'
+import {globalStore} from "@/store";
+import eventBus from '@/utils/eventBus'
+import commonMixin from '@/mixin/commonMixin'
+import Big from 'big.js'
+
+/**
+ * 坐标
+ */
+export default {
+  name: 'EditorCoordinate',
+  mixins: [commonMixin],
+  components: {},
+  props: {},
+  computed: {
+    ...mapState(globalStore, {
+      curComponent: (state) => state.curComponent
+    }),
+    coordinateStyle() {
+      return `left: ${this.x + 10}px; top: ${this.y + 10}px`
+    }
+  },
+  data() {
+    return {
+      x: 0,
+      y: 0,
+      left: 0,
+      top: 0,
+      showCoordinate: false
+    }
+  },
+  methods: {
+    initMounted() {
+      // 监听元素移动和不移动的事件
+      eventBus.on('move', (isDownward, isRightward, curX, curY) => {
+        this.showCoordinate = true
+        this.x = curX
+        this.y = curY
+        this.left = new Big(this.curComponent.style.left).div(new Big(5)).toNumber()
+        this.top = new Big(this.curComponent.style.top).div(new Big(5)).toNumber()
+      })
+
+      eventBus.on('unmove', () => {
+        this.showCoordinate = false
+      })
+    }
+  },
+  created() {},
+  mounted() {
+    this.initMounted()
+  },
+  watch: {}
+}
+</script>
+
+<style lang="less">
+.roy-editor-coordinate {
+  height: 14px;
+  font-size: 10px;
+  line-height: 14px;
+  text-align: center;
+  padding: 3px 6px;
+  background: var(--roy-color-primary);
+  color: #ffffff;
+  position: fixed;
+  border-radius: 2px;
+  transform: scale(0.9);
+}
+</style>

+ 268 - 0
src/components/yvan-editor/EditorLine.vue

@@ -0,0 +1,268 @@
+<template>
+  <div class="roy-mark-line">
+    <div
+        v-for="line in lines"
+        v-show="lineStatus[line] || false"
+        :id="`rot-editor-line-${line}`"
+        :key="line"
+        :class="line.includes('x') ? 'roy-mark-line--xline' : 'roy-mark-line--yline'"
+        class="roy-mark-line__line"
+    ></div>
+  </div>
+</template>
+
+<script>
+import {mapState} from 'pinia'
+import {globalStore} from "@/store";
+import eventBus from '@/utils/eventBus'
+import {getComponentRotatedStyle} from '@/utils/style-util.js'
+
+export default {
+  name: 'EditorLine',
+  data() {
+    return {
+      lines: ['xt', 'xc', 'xb', 'yl', 'yc', 'yr'], // 分别对应三条横线和三条竖线
+      diff: 3, // 相距 dff 像素将自动吸附
+      lineStatus: {
+        xt: false,
+        xc: false,
+        xb: false,
+        yl: false,
+        yc: false,
+        yr: false
+      }
+    }
+  },
+  computed: {
+    ...mapState(globalStore, {
+      componentData: (state) => state.componentData,
+      curComponent: (state) => state.curComponent
+    })
+  },
+  mounted() {
+    // 监听元素移动和不移动的事件
+    eventBus.on('move', (isDownward, isRightward) => {
+      this.showLineMove(isDownward, isRightward)
+    })
+
+    eventBus.on('unmove', () => {
+      this.hideLine()
+    })
+  },
+  methods: {
+    hideLine() {
+      Object.keys(this.lineStatus).forEach((line) => {
+        this.lineStatus[line] = false
+      })
+    },
+
+    showLineMove(isDownward, isRightward) {
+      const components = this.componentData || []
+      const curComponentStyle = getComponentRotatedStyle(this.curComponent?.style || {})
+      const curComponentHalfWidth = curComponentStyle.width / 2
+      const curComponentHalfHeight = curComponentStyle.height / 2
+
+      this.hideLine()
+      components.forEach((component) => {
+        if (component === this.curComponent) {
+          return
+        }
+        const componentStyle = getComponentRotatedStyle(component.style)
+        const {top, left, bottom, right} = componentStyle
+        const componentHalfWidth = componentStyle.width / 2
+        const componentHalfHeight = componentStyle.height / 2
+
+        const conditions = {
+          top: [
+            {
+              isNearly: this.isNearly(curComponentStyle.top, top),
+              lineNode: this.$el.querySelector('#rot-editor-line-xt'), // xt
+              line: 'xt',
+              dragShift: top,
+              lineShift: top
+            },
+            {
+              isNearly: this.isNearly(curComponentStyle.bottom, top),
+              lineNode: this.$el.querySelector('#rot-editor-line-xt'), // xt
+              line: 'xt',
+              dragShift: top - curComponentStyle.height,
+              lineShift: top
+            },
+            {
+              // 组件与拖拽节点的中间是否对齐
+              isNearly: this.isNearly(
+                  curComponentStyle.top + curComponentHalfHeight,
+                  top + componentHalfHeight
+              ),
+              lineNode: this.$el.querySelector('#rot-editor-line-xc'), // xc
+              line: 'xc',
+              dragShift: top + componentHalfHeight - curComponentHalfHeight,
+              lineShift: top + componentHalfHeight
+            },
+            {
+              isNearly: this.isNearly(curComponentStyle.top, bottom),
+              lineNode: this.$el.querySelector('#rot-editor-line-xb'), // xb
+              line: 'xb',
+              dragShift: bottom,
+              lineShift: bottom
+            },
+            {
+              isNearly: this.isNearly(curComponentStyle.bottom, bottom),
+              lineNode: this.$el.querySelector('#rot-editor-line-xb'), // xb
+              line: 'xb',
+              dragShift: bottom - curComponentStyle.height,
+              lineShift: bottom
+            }
+          ],
+          left: [
+            {
+              isNearly: this.isNearly(curComponentStyle.left, left),
+              lineNode: this.$el.querySelector('#rot-editor-line-yl'), // yl
+              line: 'yl',
+              dragShift: left,
+              lineShift: left
+            },
+            {
+              isNearly: this.isNearly(curComponentStyle.right, left),
+              lineNode: this.$el.querySelector('#rot-editor-line-yl'), // yl
+              line: 'yl',
+              dragShift: left - curComponentStyle.width,
+              lineShift: left
+            },
+            {
+              // 组件与拖拽节点的中间是否对齐
+              isNearly: this.isNearly(
+                  curComponentStyle.left + curComponentHalfWidth,
+                  left + componentHalfWidth
+              ),
+              lineNode: this.$el.querySelector('#rot-editor-line-yc'), // yc
+              line: 'yc',
+              dragShift: left + componentHalfWidth - curComponentHalfWidth,
+              lineShift: left + componentHalfWidth
+            },
+            {
+              isNearly: this.isNearly(curComponentStyle.left, right),
+              lineNode: this.$el.querySelector('#rot-editor-line-yr'), // yr
+              line: 'yr',
+              dragShift: right,
+              lineShift: right
+            },
+            {
+              isNearly: this.isNearly(curComponentStyle.right, right),
+              lineNode: this.$el.querySelector('#rot-editor-line-yr'), // yr
+              line: 'yr',
+              dragShift: right - curComponentStyle.width,
+              lineShift: right
+            }
+          ]
+        }
+
+        const needToShow = []
+        const {rotate} = this.curComponent.style
+        Object.keys(conditions).forEach((key) => {
+          // 遍历符合的条件并处理
+          conditions[key].forEach((condition) => {
+            if (!condition.isNearly) {
+              return
+            }
+            // 修改当前组件位移
+            this.$store.commit('printTemplateModule/setShapeSingleStyle', {
+              key,
+              value:
+                  rotate !== 0
+                      ? this.translatecurComponentShift(key, condition, curComponentStyle)
+                      : condition.dragShift
+            })
+
+            condition.lineNode.style[key] = `${condition.lineShift}px`
+            needToShow.push(condition.line)
+          })
+        })
+
+        // 同一方向上同时显示三条线可能不太美观,因此才有了这个解决方案
+        // 同一方向上的线只显示一条,例如多条横条只显示一条横线
+        if (needToShow.length) {
+          this.chooseTheTureLine(needToShow, isDownward, isRightward)
+        }
+      })
+    },
+
+    translatecurComponentShift(key, condition, curComponentStyle) {
+      const {width, height} = this.curComponent.style
+      if (key === 'top') {
+        return Math.round(condition.dragShift - (height - curComponentStyle.height) / 2)
+      }
+
+      return Math.round(condition.dragShift - (width - curComponentStyle.width) / 2)
+    },
+
+    chooseTheTureLine(needToShow, isDownward, isRightward) {
+      // 如果鼠标向右移动 则按从右到左的顺序显示竖线 否则按相反顺序显示
+      // 如果鼠标向下移动 则按从下到上的顺序显示横线 否则按相反顺序显示
+      if (isRightward) {
+        if (needToShow.includes('yr')) {
+          this.lineStatus.yr = true
+        } else if (needToShow.includes('yc')) {
+          this.lineStatus.yc = true
+        } else if (needToShow.includes('yl')) {
+          this.lineStatus.yl = true
+        }
+      } else {
+        // eslint-disable-next-line no-lonely-if
+        if (needToShow.includes('yl')) {
+          this.lineStatus.yl = true
+        } else if (needToShow.includes('yc')) {
+          this.lineStatus.yc = true
+        } else if (needToShow.includes('yr')) {
+          this.lineStatus.yr = true
+        }
+      }
+
+      if (isDownward) {
+        if (needToShow.includes('xb')) {
+          this.lineStatus.xb = true
+        } else if (needToShow.includes('xc')) {
+          this.lineStatus.xc = true
+        } else if (needToShow.includes('xt')) {
+          this.lineStatus.xt = true
+        }
+      } else {
+        // eslint-disable-next-line no-lonely-if
+        if (needToShow.includes('xt')) {
+          this.lineStatus.xt = true
+        } else if (needToShow.includes('xc')) {
+          this.lineStatus.xc = true
+        } else if (needToShow.includes('xb')) {
+          this.lineStatus.xb = true
+        }
+      }
+    },
+
+    isNearly(dragValue, targetValue) {
+      return Math.abs(dragValue - targetValue) <= this.diff
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.roy-mark-line {
+  height: 100%;
+}
+
+.roy-mark-line__line {
+  background: var(--roy-color-primary);
+  position: absolute;
+  z-index: 1000;
+}
+
+.roy-mark-line--xline {
+  width: 100%;
+  height: 0.5px;
+}
+
+.roy-mark-line--yline {
+  width: 0.5px;
+  height: 100%;
+}
+</style>

+ 152 - 0
src/components/yvan-print-designer-left.vue

@@ -0,0 +1,152 @@
+<template>
+  <section :style="asideStyle" class="yvan-print-designer-left__main">
+    <div class="yvan-print-designer-left__title">
+      <i class="fa fa-cube vsm--icon"></i>
+      <span>组件列表</span>
+    </div>
+    <div class="yvan-print-designer-left__content">
+      <el-collapse v-model="collapseOptions.activeName">
+        <el-collapse-item
+            v-for="componentType in componentTypeList"
+            :title="componentType.title"
+            :name="componentType.name">
+          <div class="yvan-print-designer-left__collapse_item">
+            <el-space wrap @dragstart="handleDragStart">
+              <div
+                  v-for="component in componentType.componentList"
+                  :key="component.code"
+                  :data-code="component.code"
+                  draggable="true">
+                <el-button> {{ component.name }}</el-button>
+              </div>
+            </el-space>
+          </div>
+        </el-collapse-item>
+      </el-collapse>
+    </div>
+  </section>
+</template>
+
+<script>
+import commonMixin from '@/mixin/commonMixin'
+import menuConfig from "@/components/config/menuConfig";
+import {componentTypeList} from "@/components/config/componentList";
+import YvanPrintSidebarMenu from "@/components/yvan-ui/yvan-sidebar-menu/SidebarMenu.vue";
+
+export default {
+  name: 'yvan-print-designer-left',
+  components: {YvanPrintSidebarMenu},
+  mixins: [commonMixin],
+  data() {
+    return {
+      isNightMode: false,
+      componentTypeList,
+      menuList: menuConfig.menuList,
+      curActiveComponent: null,
+      curActiveComponentCode: '',
+      collapseOptions: {
+        activeName: componentTypeList.map(componentType => componentType.name)
+      }
+    }
+  },
+  props: {
+    showRight: {
+      type: Boolean,
+      default: true
+    }
+  },
+  computed: {
+    // ...mapState({
+    //   paletteCount: (state) => state.printTemplateModule.paletteCount,
+    //   globalCount: (state) => state.printTemplateModule.globalCount,
+    //   isNightMode: (state) => state.printTemplateModule.nightMode.isNightMode
+    // }),
+    asideStyle() {
+      return this.showRight ? 'width: 300px' : 'width: 65px'
+    }
+  },
+  methods: {
+    handleDragStart(e) {
+      console.log(' >>>> ', e, e.target.dataset.code)
+      e.dataTransfer.setData('componentCode', e.target.dataset.code)
+    },
+
+    onMenuSelect(e, item) {
+      this.curActiveComponent = item.relativeComponent
+      this.curActiveComponentCode = item.code
+      this.menuList.forEach((mItem) => {
+        if (item.code === mItem.code) {
+          mItem.isActive = true
+          return
+        }
+        mItem.isActive = false
+      })
+    },
+    clickPaletteMenu() {
+      this.$refs.sideMenu.$refs.menuItems.forEach((item) => {
+        if (item.item.code === 'palette' && this.curActiveComponentCode !== 'palette') {
+          this.onMenuSelect(null, item.item)
+        }
+      })
+    },
+    clickGlobalMenu() {
+      this.$refs.sideMenu.$refs.menuItems.forEach((item) => {
+        if (item.item.code === 'setting' && this.curActiveComponentCode !== 'setting') {
+          this.onMenuSelect(null, item.item)
+        }
+      })
+    }
+  },
+  mounted() {
+    const currentMenu = this.menuList[0]
+    this.curActiveComponent = currentMenu?.relativeComponent
+    this.curActiveComponentCode = currentMenu?.code
+  },
+  watch: {
+    paletteCount() {
+      this.clickPaletteMenu()
+    },
+    globalCount() {
+      this.clickGlobalMenu()
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.yvan-print-designer-left__main {
+  height: 100%;
+  width: 100%;
+
+  .yvan-print-designer-left__title {
+    width: 100%;
+    height: 45px;
+    text-align: left;
+    line-height: 45px;
+    background: var(--yvan-menu-bar-background);
+    color: #fff;
+    align-items: center;
+
+    i {
+      margin: 0 5px 0 10px;
+    }
+  }
+
+  .yvan-print-designer-left__content {
+    padding: 0 5px;
+    background: var(--yvan-bg-color-overlay);
+
+    .yvan-print-designer-left__collapse_item {
+      margin: 0 10px;
+    }
+  }
+
+  .yvan-print-designer-left__right_panel {
+    width: calc(100% - 64px);
+    background: var(--yvan-bg-color-overlay);
+    position: absolute;
+    right: 0;
+    top: 0;
+  }
+}
+</style>

+ 103 - 0
src/components/yvan-print-designer-main.vue

@@ -0,0 +1,103 @@
+<template>
+  <section class="height-all">
+    <yvan-print-toolbar>
+      <template v-slot:yvan-designer-toolbar-slot>-->
+        <slot name="yvan-designer-toolbar-slot"></slot>
+      </template>
+    </yvan-print-toolbar>
+    <div
+      @dragover="handleDragOver"
+      @drop="handleDrop"
+      @mousedown="handleMouseDown"
+      @mouseup="deselectCurComponent"
+    >
+      <yvan-print-designer-editor :show-right="showRight" />
+    </div>
+  </section>
+</template>
+
+<script>
+import {mapState} from "pinia";
+import {globalStore} from "@/store";
+import commonMixin from '@/mixin/commonMixin'
+import YvanPrintToolbar from "@/components/yvan-ui/yvan-print-toolbar.vue";
+import YvanPrintDesignerEditor from "@/components/yvan-editor/Editor.vue";
+import { getComponentMap } from '@/components/config/componentList';
+
+/**
+ * 主操作视图
+ */
+export default {
+  name: 'yvan-print-designer-main',
+  mixins: [commonMixin],
+  components: {
+    YvanPrintDesignerEditor,
+    YvanPrintToolbar
+  },
+  props: {
+    showRight: {
+      type: Boolean,
+      default: true
+    }
+  },
+  data() {
+    return {}
+  },
+  computed: {
+    ...mapState(globalStore, {
+      isClickComponent: (store) => store.isClickComponent
+    })
+  },
+  methods: {
+    initMounted() {},
+    handleDrop(e) {
+      e.preventDefault()
+      e.stopPropagation()
+
+      const componentCode = e.dataTransfer.getData('componentCode')
+      const rectInfo = document.querySelector('#designer-page').getBoundingClientRect()
+      if (componentCode) {
+        const component = this.deepCopy(getComponentMap()?.get(componentCode))
+        component.style = component.style ?? {}
+        component.style.top = e.clientY - rectInfo.y
+        component.style.left = e.clientX - rectInfo.x
+        component.id = this.getUuid()
+        component.label = `${component.name}-${component.id}`
+        globalStore().addComponent({ component })
+        globalStore().recordSnapshot()
+      } else {
+        system.toast('拖拽元素非页面组件,此次拖拽无效', 'info')
+      }
+    },
+
+    handleDragOver(e) {
+      e.preventDefault()
+      e.dataTransfer.dropEffect = 'copy'
+    },
+
+    handleMouseDown(e) {
+      e.stopPropagation()
+      globalStore().setClickComponentStatus(false)
+      globalStore().setInEditorStatus(false)
+    },
+
+    deselectCurComponent() {
+      if (!this.isClickComponent) {
+        globalStore().setCurComponent({
+          component: null,
+          index: null
+        })
+      //   this.$store.commit('printTemplateModule/setCurComponent', {
+      //     component: null,
+      //     index: null
+      //   })
+      }
+    }
+  },
+  created() {},
+  mounted() {
+    this.initMounted()
+  },
+  watch: {}
+}
+</script>

+ 160 - 0
src/components/yvan-print-designer-right.vue

@@ -0,0 +1,160 @@
+<template>
+  <section :style="asideStyle" class="yvan-print-designer-right__main">
+    <div class="yvan-print-designer-right__title">
+      <i class="fa fa-reorder vsm--icon"></i>
+      <span>组件属性</span>
+    </div>
+    <div class="yvan-print-designer-right__content">
+<!--      <yvan-print-sidebar-menu-->
+<!--          ref="sideMenu"-->
+<!--          :collapsed="true"-->
+<!--          :menu="menuList"-->
+<!--          :theme="isNightMode ? '' : 'white-theme'"-->
+<!--          width="150px"-->
+<!--          @item-click="onMenuSelect"-->
+<!--      />-->
+      <!--      <keep-alive>-->
+      <!--        <component-->
+      <!--            :is="curActiveComponent"-->
+      <!--            v-show="showRight"-->
+      <!--            :key="curActiveComponentCode"-->
+      <!--            class="yvan-print-designer-aside__right_panel"-->
+      <!--        />-->
+      <!--      </keep-alive>-->
+    </div>
+  </section>
+</template>
+
+<script>
+import commonMixin from '@/mixin/commonMixin'
+import menuConfig from "@/components/config/menuConfig";
+import YvanPrintSidebarMenu from "@/components/yvan-ui/yvan-sidebar-menu/SidebarMenu.vue";
+
+export default {
+  name: 'yvan-print-designer-aside',
+  components: {YvanPrintSidebarMenu},
+  mixins: [commonMixin],
+  data() {
+    return {
+      isNightMode: false,
+      menuList: menuConfig.menuList,
+      curActiveComponent: null,
+      curActiveComponentCode: ''
+    }
+  },
+  props: {
+    showRight: {
+      type: Boolean,
+      default: true
+    }
+  },
+  computed: {
+    // ...mapState({
+    //   paletteCount: (state) => state.printTemplateModule.paletteCount,
+    //   globalCount: (state) => state.printTemplateModule.globalCount,
+    //   isNightMode: (state) => state.printTemplateModule.nightMode.isNightMode
+    // }),
+    asideStyle() {
+      return this.showRight ? 'width: 300px' : 'width: 65px'
+    }
+  },
+  methods: {
+    onMenuSelect(e, item) {
+      this.curActiveComponent = item.relativeComponent
+      this.curActiveComponentCode = item.code
+      this.menuList.forEach((mItem) => {
+        if (item.code === mItem.code) {
+          mItem.isActive = true
+          return
+        }
+        mItem.isActive = false
+      })
+    },
+    clickPaletteMenu() {
+      this.$refs.sideMenu.$refs.menuItems.forEach((item) => {
+        if (item.item.code === 'palette' && this.curActiveComponentCode !== 'palette') {
+          this.onMenuSelect(null, item.item)
+        }
+      })
+    },
+    clickGlobalMenu() {
+      this.$refs.sideMenu.$refs.menuItems.forEach((item) => {
+        if (item.item.code === 'setting' && this.curActiveComponentCode !== 'setting') {
+          this.onMenuSelect(null, item.item)
+        }
+      })
+    }
+  },
+  mounted() {
+    const currentMenu = this.menuList[0]
+    this.curActiveComponent = currentMenu?.relativeComponent
+    this.curActiveComponentCode = currentMenu?.code
+  },
+  watch: {
+    paletteCount() {
+      this.clickPaletteMenu()
+    },
+    globalCount() {
+      this.clickGlobalMenu()
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.yvan-print-designer-right__main {
+  height: 100%;
+  width: 100%;
+  background: var(--yvan-bg-color-overlay);
+
+  .yvan-print-designer-right__title {
+    width: 100%;
+    height: 45px;
+    text-align: left;
+    line-height: 45px;
+    background: var(--yvan-menu-bar-background);
+    color: #fff;
+    align-items: center;
+
+    i {
+      margin: 0 5px 0 10px;
+    }
+  }
+
+
+  .yvan-print-designer-right__menu {
+    height: 100%;
+    z-index: 1;
+
+    .yvan-print-designer-aside__menu__icon {
+      display: grid;
+      top: -7px;
+      position: relative;
+
+      i {
+        padding: 0;
+        margin: 0;
+        font-size: 20px;
+      }
+
+      span {
+        line-height: 14px;
+        visibility: visible;
+        height: auto;
+        width: auto;
+        font-size: 8px;
+        top: -14px;
+        position: relative;
+      }
+    }
+  }
+
+  .yvan-print-designer-aside__right_panel {
+    width: calc(100% - 64px);
+    background: var(--yvan-bg-color-overlay);
+    position: absolute;
+    right: 0;
+    top: 0;
+  }
+}
+</style>

+ 256 - 0
src/components/yvan-print-designer.vue

@@ -0,0 +1,256 @@
+<template>
+  <yvan-print-container id="yvan-print-designer" class="yvan-print-designer-container" theme="day" direction="vertical">
+    <yvan-print-header class="yvan-print-header" height="40px">
+      <div class="yvan-print-header__text">
+        <i class="fa fa-snowflake-o"></i>
+        <span>打印模板设计器 | 新建打印模板</span>
+      </div>
+      <div class="yvan-print-header__right">
+        <div>
+          <slot name="roy-designer-header-slot"></slot>
+        </div>
+        <div class="yvan-print-night-mode">
+          <i
+              v-for="(tool, index) in headIconConfig"
+              :key="index"
+              :class="tool.icon"
+              :title="tool.name"
+              @click="tool.event"
+          ></i>
+          <i
+              v-if="configIn.toolbarConfig.showNightMode && isNightMode"
+              class="fa fa-toggle-on"
+              title="切换到日间模式"
+              @click="dayNightChange"
+          ></i>
+          <i
+              v-if="configIn.toolbarConfig.showNightMode && !isNightMode"
+              class="fa fa-toggle-off"
+              title="切换到夜间模式"
+              @click="dayNightChange"
+          ></i>
+        </div>
+      </div>
+    </yvan-print-header>
+    <yvan-print-container style="height: calc(100% - 40px)">
+      <yvan-print-aside class="yvan-print-designer-aside" width="auto">
+        <yvan-print-designer-left :show-right.sync="defaultExpendAside"/>
+      </yvan-print-aside>
+      <yvan-print-main class="yvan-print-designer-main">
+        <yvan-print-designer-main :show-right="defaultExpendAside">
+          <template v-slot:yvan-print-designer-toolbar-slot>-->
+            <slot name="yvan-print-designer-toolbar-slot"></slot>
+          </template>
+        </yvan-print-designer-main>
+      </yvan-print-main>
+      <yvan-print-aside class="yvan-print-designer-aside" width="auto">
+        <yvan-print-designer-right :show-right.sync="defaultExpendAside"/>
+      </yvan-print-aside>
+    </yvan-print-container>
+  </yvan-print-container>
+</template>
+
+<script>
+import {mapActions} from "pinia";
+import {modeStore} from "@/store";
+import YvanPrintContainer from "@/components/yvan-ui/yvan-print-container.vue";
+import YvanPrintHeader from "@/components/yvan-ui/yvan-print-header.vue";
+import YvanPrintAside from "@/components/yvan-ui/yvan-print-aside.vue";
+import YvanPrintMain from "@/components/yvan-ui/yvan-print-main.vue";
+import YvanPrintDesignerLeft from "@/components/yvan-print-designer-left.vue";
+import YvanPrintDesignerMain from "@/components/yvan-print-designer-main.vue";
+import YvanPrintDesignerRight from "@/components/yvan-print-designer-right.vue";
+
+/**
+ * 主页
+ */
+export default {
+  name: 'yvan-print-designer',
+  components: {
+    YvanPrintDesignerLeft,
+    YvanPrintDesignerMain,
+    YvanPrintDesignerRight,
+    YvanPrintMain,
+    YvanPrintAside,
+    YvanPrintHeader,
+    YvanPrintContainer,
+  },
+  props: {
+    preComponentData: {
+      type: [Array, Boolean],
+      default: false
+    },
+    prePageConfig: {
+      type: [Object, Boolean],
+      default: false
+    },
+    preDataSource: {
+      type: [Array, Boolean],
+      default: false
+    },
+    preDataSet: {
+      type: [Object, Boolean],
+      default: false
+    },
+    config: {
+      type: Object,
+      default: () => {
+        return {}
+      }
+    }
+  },
+  data() {
+    return {
+      defaultExpendAside: true,
+      configIn: {
+        toolbarConfig: {
+          buttons: ['guide', 'exportTemplate', 'importTemplate', 'pageFormat', 'logout'],
+          showNightMode: true
+        }
+      },
+      headIcons: [
+        {
+          code: 'guide',
+          name: '界面指引',
+          icon: 'fa fa-question-circle-o',
+          event: this.showUserGuide
+        },
+        {
+          code: 'exportTemplate',
+          name: '保存模板为文件',
+          icon: 'fa fa-cloud-download',
+          event: this.exportJSON
+        },
+        {
+          code: 'importTemplate',
+          name: '从模板文件导入',
+          icon: 'fa fa-cloud-upload',
+          event: this.importFile
+        },
+        {
+          code: 'logout',
+          name: '登出',
+          icon: 'fa fa-sign-out',
+          event: this.importFile
+        }
+      ]
+    }
+  },
+  computed: {
+    isNightMode() {
+      return false;
+    },
+    headIconConfig() {
+      return this.headIcons.filter((item) => {
+        return (
+            this.configIn.toolbarConfig.buttons &&
+            this.configIn.toolbarConfig.buttons.includes(item.code)
+        )
+      })
+    }
+  },
+  methods: {
+    ...mapActions(modeStore, ['toggleNightMode']),
+    async initMounted() {
+
+    },
+    registerTableRender(renderers) {
+
+    },
+    initConfig() {
+
+    },
+    dayNightChange() {
+      this.toggleNightMode(!this.isNightMode)
+    },
+    showUserGuide() {
+
+    },
+    async importFile() {
+
+    },
+    exportJSON() {
+
+    }
+  },
+  created() {
+  },
+  mounted() {
+    this.initMounted()
+  },
+  watch: {}
+}
+</script>
+
+<style lang="less" scoped>
+.yvan-print-designer-container {
+  background: var(--prism-background);
+  width: 100%;
+  height: 100%;
+
+  .yvan-print-header {
+    background: var(--yvan-menu-bar-background);
+    width: 100%;
+    display: flex;
+    justify-content: space-between;
+
+    .yvan-print-header__text {
+      color: #fff;
+      display: flex;
+      height: 100%;
+      align-items: center;
+
+      i {
+        margin-right: 5px;
+      }
+    }
+
+    .yvan-print-header__right {
+      float: right;
+      color: #fff;
+      width: 50%;
+      display: flex;
+      align-items: flex-end;
+      justify-content: flex-end;
+      height: 100%;
+      line-height: 40px;
+    }
+
+    .yvan-print-night-mode {
+      color: #fff;
+      line-height: 40px;
+      height: 40px;
+      overflow: hidden;
+      cursor: pointer;
+
+      i {
+        float: left;
+        color: #fff;
+        display: flex;
+        align-items: flex-end;
+        justify-content: flex-end;
+        height: 100%;
+        line-height: 40px;
+        padding: 0 8px;
+        cursor: pointer;
+
+        &:hover {
+          background: var(--yvan-color-primary-light-3);
+        }
+      }
+    }
+  }
+
+  .yvan-print-designer-aside {
+    margin: 10px 5px 10px 10px;
+    height: calc(100% - 20px);
+  }
+
+  .yvan-print-designer-main {
+    margin: 5px;
+    border-radius: 2px;
+    overflow: auto;
+    padding: 0;
+  }
+}
+</style>

+ 122 - 0
src/components/yvan-print-page-format.vue

@@ -0,0 +1,122 @@
+<template>
+  <div class="yvan-print-page-format">
+    <div class="yvan-print-page-format__content">
+      <el-form label-width="80px">
+        <el-form-item label="纸张大小">
+          <el-select v-model="pageFormat.pageSize" class="m-2" placeholder="请选择纸张大小" clearable>
+            <el-option
+                v-for="pageSize in pageSizeList"
+                :key="pageSize.name"
+                :label="pageSize.name"
+                :value="pageSize.name"/>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="纸张方向">
+          <el-radio-group v-model="pageFormat.pageDirection">
+            <el-radio-button
+                v-for="pageDirection in pageDirectionList"
+                :label="pageDirection.label"/>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="宽度">
+          <el-input-number v-model="pageFormat.width" controls-position="right"/>
+          (mm)
+        </el-form-item>
+        <el-form-item label="高度">
+          <el-input-number v-model="pageFormat.height" controls-position="right"/>
+          (mm)
+        </el-form-item>
+        <!--        <el-form-item label="单位">-->
+        <!--          <el-select class="m-2" placeholder="Select">-->
+        <!--            <el-option key="item.value" label="item.label" value="item.value"/>-->
+        <!--          </el-select>-->
+        <!--        </el-form-item>-->
+        <el-form-item label="外边距" class="yvan-print-page-format__margins">
+          <el-input-number v-model="pageFormat.margins.top" placeholder="上" controls-position="right"/>
+          <el-input-number v-model="pageFormat.margins.right" placeholder="右" controls-position="right"/>
+          <el-input-number v-model="pageFormat.margins.bottom" placeholder="下" controls-position="right"/>
+          <el-input-number v-model="pageFormat.margins.left" placeholder="左" controls-position="right"/>
+        </el-form-item>
+      </el-form>
+    </div>
+    <div class="yvan-print-page-format__content_display">
+      <div class="yvan-print-page-format__content_display_detail"/>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import {pageSizeList, pageDirectionList} from '@/components/config/pageFormatConfig'
+import {reactive, ref} from "vue";
+
+const defaultPageSize = pageSizeList[0]
+const defaultPageDirection = pageDirectionList[0]
+
+const pageFormat = reactive<PageFormat>({
+  pageSize: defaultPageSize,
+  pageDirection: defaultPageDirection.label,
+  width: defaultPageSize.width,
+  height: defaultPageSize.height,
+  unit: "mm",
+  margins: {
+    top: 10,
+    bottom: 10,
+    left: 10,
+    right: 10
+  }
+})
+
+interface PageFormat {
+  pageSize,
+  pageDirection: string,
+  width: number,
+  height: number,
+  unit: string,
+  margins: Margins
+}
+
+interface Margins {
+  top: number,
+  bottom: number,
+  left: number,
+  right: number
+}
+
+
+</script>
+
+<style lang="less">
+.yvan-print-page-format {
+
+  .yvan-print-page-format__content {
+    display: flex;
+
+    .yvan-print-page-format__margins {
+
+      .el-input-number {
+        width: 80px;
+      }
+    }
+  }
+
+
+  .yvan-print-page-format__content_display {
+    background: #ffffff;
+    position: absolute;
+    right: 125px;
+    width: 168px;
+    height: 237.6px;
+    border: 1px solid;
+    box-shadow: -2px -2px #aaaaaa, 2px -2px #aaaaaa, -2px 2px #aaaaaa, 2px 2px #aaaaaa;
+
+    .yvan-print-page-format__content_display_detail {
+      width: 158px;
+      height: 227.6px;
+      margin: 5px;
+      border: 1px solid #aaaaaa;
+    }
+  }
+
+
+}
+</style>

+ 76 - 0
src/components/yvan-ui/yvan-model/RoyModal.vue

@@ -0,0 +1,76 @@
+<template>
+  <vxe-modal
+    v-model="visibleIn"
+    :height="height"
+    :show-footer="showFooter"
+    :title="title"
+    :transfer="appendToBody"
+    :width="width"
+    remember
+    resize
+    show-zoom
+    @close="close"
+  >
+    <slot></slot>
+    <template v-slot:footer>
+      <slot name="footer"></slot>
+    </template>
+  </vxe-modal>
+</template>
+
+<script>
+export default {
+  name: 'RoyModal',
+  components: {},
+  props: {
+    appendToBody: {
+      type: Boolean,
+      default: true
+    },
+    show: {
+      type: Boolean,
+      default: false
+    },
+    width: {
+      type: String,
+      default: '50%'
+    },
+    height: {
+      type: String,
+      default: '50%'
+    },
+    title: {
+      type: String,
+      default: '消息'
+    },
+    showFooter: {
+      type: Boolean,
+      default: false
+    }
+  },
+  data() {
+    return {
+      visibleIn: this.show
+    }
+  },
+  methods: {
+    initMounted() {},
+    close() {
+      this.$emit('close')
+      this.$emit('update:show', false)
+    }
+  },
+  created() {},
+  mounted() {
+    this.initMounted()
+  },
+  destroyed() {},
+  watch: {
+    show() {
+      this.visibleIn = this.show
+    }
+  }
+}
+</script>
+
+<style lang="less"></style>

+ 27 - 0
src/components/yvan-ui/yvan-print-aside.vue

@@ -0,0 +1,27 @@
+<template>
+  <aside :style="{ width }" class="yvan-print-aside">
+    <slot></slot>
+  </aside>
+</template>
+
+<script>
+export default {
+  name: 'yvan-print-aside',
+  componentName: 'yvan-print-aside',
+  props: {
+    width: {
+      type: String,
+      default: '300px'
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.yvan-print-aside {
+  overflow: auto;
+  -webkit-box-sizing: border-box;
+  box-sizing: border-box;
+  flex-shrink: 0;
+}
+</style>

+ 51 - 0
src/components/yvan-ui/yvan-print-container.vue

@@ -0,0 +1,51 @@
+<template>
+  <section :class="{ 'is-vertical': isVertical }" class="yvan-print-container">
+    <slot></slot>
+  </section>
+</template>
+
+<script>
+export default {
+  name: 'yvan-print-container',
+  componentName: 'yvan-print-container',
+  components: {},
+  props: {
+    direction: String
+  },
+  data() {
+    return {}
+  },
+  computed: {
+    isVertical() {
+      if (this.direction === 'vertical') {
+        return true
+      } else if (this.direction === 'horizontal') {
+        return false
+      }
+      return false
+    }
+  },
+  methods: {},
+  created() {
+  },
+  mounted() {
+  },
+  watch: {}
+}
+</script>
+
+
+<style lang="less" scoped>
+.yvan-print-container {
+  display: flex;
+  flex-direction: row;
+  flex: 1;
+  flex-basis: auto;
+  box-sizing: border-box;
+  min-width: 0;
+}
+
+.yvan-print-container.is-vertical {
+  flex-direction: column;
+}
+</style>

+ 84 - 0
src/components/yvan-ui/yvan-print-dialog/dialog.vue

@@ -0,0 +1,84 @@
+<template>
+  <el-dialog v-model="visible" :title="title" :width="computedWidth">
+    <component :is="componentName"></component>
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button type="primary" @click="_handleConfirm">确定</el-button>
+        <el-button @click="_handleClose">取消</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import {computed, ref} from "vue";
+
+const props = defineProps({
+  visible: {
+    type: Boolean,
+    default: false
+  },
+  componentName: {},
+  title: {
+    type: String,
+    default: "提示"
+  },
+  width: {
+    type: Number,
+    default: 30
+  },
+  handleConfirm: {
+    type: Function,
+    default: () => {
+    }
+  },
+  handleClose: {
+    type: Function,
+    default: () => {
+    }
+  }
+})
+
+const visible = ref(props.visible)
+
+const computedWidth = computed(() => {
+  return props.width + "%"
+})
+
+/**
+ * 提交Dialog
+ */
+const _handleConfirm = () => {
+  visible.value = false;
+  if (typeof props.handleConfirm === 'function') {
+    props.handleConfirm.apply(this);
+  }
+}
+
+/**
+ * 关闭Dialog
+ */
+const _handleClose = () => {
+  visible.value = false;
+  if (typeof props.handleClose === 'function') {
+    props.handleClose.apply(this);
+  }
+}
+
+</script>
+
+<style lang="less">
+.el-dialog {
+  background: var(--prism-background);
+
+  .el-dialog__header {
+    margin-right: 0;
+    background: var(--yvan-menu-bar-background);
+
+    .el-dialog__title, .el-dialog__headerbtn > i {
+      color: #fff;
+      align-items: center;
+    }
+  }
+}
+</style>

+ 32 - 0
src/components/yvan-ui/yvan-print-dialog/index.ts

@@ -0,0 +1,32 @@
+import {createVNode, render} from "vue";
+import dialog from "@/components/yvan-ui/yvan-print-dialog/dialog.vue";
+
+export default (options) => {
+    return new Promise((resolve, reject) => {
+        // 创建一个空的div
+        const mountNode = document.createElement("div");
+        const handleConfirm = (res) => {
+            // 调用完毕后应该清空节点
+            render(null, mountNode)
+            mountNode.remove();
+            resolve(res)
+        }
+        const handleClose = (res) => {
+            render(null, mountNode);
+            mountNode.remove();
+            reject(res)
+        };
+        // 将options参数传入,并将dialog组件转换成虚拟DOM,并赋值给app
+        const app = createVNode(dialog, {
+            ...options,
+            visible: true,
+            handleConfirm: handleConfirm,
+            handleClose: handleClose
+        });
+
+        // render函数的作用就是将dialog组件的虚拟DOM转换成真实DOM并插入到mountNode元素里
+        render(app, mountNode);
+        // 然后把转换成真实DOM的dialog组件插入到body里
+        document.body.appendChild(mountNode);
+    })
+};

+ 27 - 0
src/components/yvan-ui/yvan-print-header.vue

@@ -0,0 +1,27 @@
+<template>
+  <header :style="{ height }" class="yvan-print-header">
+    <slot></slot>
+  </header>
+</template>
+
+<script lang="ts">
+export default {
+  name: 'yvan-print-header',
+  componentName: 'yvan-print-header',
+  props: {
+    height: {
+      type: String,
+      default: '40px'
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.yvan-print-header {
+  padding: 0 20px;
+  -webkit-box-sizing: border-box;
+  box-sizing: border-box;
+  flex-shrink: 0;
+}
+</style>

+ 26 - 0
src/components/yvan-ui/yvan-print-main.vue

@@ -0,0 +1,26 @@
+<template>
+  <main class="yvan-print-main">
+    <slot></slot>
+  </main>
+</template>
+
+<script>
+export default {
+  name: 'yvan-print-main',
+  componentName: 'yvan-print-main'
+}
+</script>
+
+<style lang="less" scoped>
+.yvan-print-main {
+  display: block;
+  -webkit-box-flex: 1;
+  -ms-flex: 1;
+  flex: 1;
+  flex-basis: auto;
+  overflow: auto;
+  -webkit-box-sizing: border-box;
+  box-sizing: border-box;
+  padding: 20px;
+}
+</style>

+ 31 - 0
src/components/yvan-ui/yvan-print-toast/index.ts

@@ -0,0 +1,31 @@
+import {createVNode, render} from "vue";
+import toast from "@/components/yvan-ui/yvan-print-toast/toast.vue";
+
+let mountNode = null;
+
+export default (options) => {
+    const duration = options.duration | 3000;
+    //确保只存在一个弹框,如果前一个弹窗还在,就移除
+    if (mountNode) {
+        document.body.removeChild(mountNode);
+        mountNode = null;
+    }
+    //将options参数传入,并将toast组件转换成虚拟DOM,并赋值给app
+    const app = createVNode(toast, {
+        ...options,
+        duration,
+        visible: true,
+    });
+    //创建定时器,duration时间后将mountNode移除
+    let timer = setTimeout(() => {
+        document.body.removeChild(mountNode);
+        mountNode = null;
+        clearTimeout(timer);
+    }, duration);
+    //创建一个空的div
+    mountNode = document.createElement("div");
+    //render函数的作用就是将toast组件的虚拟DOM转换成真实DOM并插入到mountNode元素里
+    render(app, mountNode);
+    //然后把转换成真实DOM的toast组件插入到body里
+    document.body.appendChild(mountNode);
+};

+ 263 - 0
src/components/yvan-ui/yvan-print-toast/toast.vue

@@ -0,0 +1,263 @@
+<template>
+  <div
+      v-if="visible"
+      :class="`roy-toast__status--${type} ${isPaused ? 'roy-toast--hover-tab' : ''}`"
+      :style="positionStyle"
+      class="roy-toast roy-toast--center"
+      style="width: auto; opacity: 1"
+      @mouseenter="doPause"
+      @mouseleave="doContinue"
+  >
+    <div></div>
+    <div class="roy-toast__main">
+      <i :class="icon"></i>
+      <span v-if="dangerouslyUseHTMLString" class="roy-toast__message" v-html="message"></span>
+      <span v-else class="roy-toast__message">{{ message }}</span>
+    </div>
+    <i class="roy-toast__close ri-close-fill" @click="close"></i>
+  </div>
+</template>
+
+<script lang="js">
+export default {
+  name: 'yvan-print-toast',
+  props: {
+    visible: {
+      type: Boolean,
+      default: false
+    },
+    type: {
+      type: String,
+      default: "warning"
+    },
+    message: {
+      type: String,
+      default: ""
+    },
+    duration: {
+      type: Number,
+      default: 3000
+    }
+  },
+  data() {
+    return {
+      dangerouslyUseHTMLString: false,
+      copiedDuration: 0,
+      onClose: null,
+      verticalOffset: 0,
+      timer: null,
+      pauseTimer: null,
+      isPaused: false,
+      tikDownInterval: null
+    }
+  },
+  computed: {
+    icon() {
+      switch (this.type) {
+        case 'info':
+          return 'ri-information-line'
+        case 'success':
+          return 'ri-checkbox-circle-line'
+        case 'warning':
+          return 'ri-error-warning-line'
+        case 'error':
+          return 'ri-close-circle-line'
+        default:
+          return ''
+      }
+    },
+    positionStyle() {
+      return {
+        top: `${this.verticalOffset}px`,
+        '--duration': `${Math.floor(this.duration / 1000)}s`
+      }
+    }
+  },
+  methods: {
+    close() {
+      this.visible = false
+      this.onClose(this)
+      this.$destroy()
+      this.$el.parentNode.removeChild(this.$el)
+    },
+    clearTimer() {
+      if (this.timer !== null) {
+        clearTimeout(this.timer)
+      }
+      if (this.pauseTimer !== null) {
+        clearTimeout(this.pauseTimer)
+      }
+      if (this.tikDownInterval !== null) {
+        clearInterval(this.tikDownInterval)
+      }
+    },
+    doPause() {
+      this.pauseTimer = setTimeout(() => {
+        this.doRestart(true)
+      }, 2000)
+    },
+    doContinue() {
+      if (this.timer !== null) {
+        clearTimeout(this.pauseTimer)
+      }
+      this.doRestart(false)
+      this.pauseTimer = null
+    },
+    doRestart(isIn) {
+      if (isIn) {
+        this.clearTimer()
+        this.isPaused = true
+      } else {
+        if (this.isPaused) {
+          this.startTimer()
+          this.isPaused = false
+        }
+      }
+    },
+    startTimer() {
+      if (this.copiedDuration === 0) {
+        this.copiedDuration = this.duration
+      }
+      if (this.duration > 0) {
+        this.timer = setTimeout(() => {
+          this.close()
+        }, this.copiedDuration)
+        this.tikDownInterval = setInterval(() => {
+          if (this.copiedDuration > 1000) {
+            this.copiedDuration -= 1000
+          }
+        }, 1000)
+      }
+    }
+  },
+  created() {
+  },
+  mounted() {
+    this.startTimer()
+  },
+  beforeDestroy() {
+    this.clearTimer()
+    this.timer = null
+    this.pauseTimer = null
+  },
+  watch: {}
+}
+</script>
+
+<style lang="less">
+.roy-toast {
+  background: #4579e1;
+  color: #fff;
+  border-radius: 0;
+  display: flex;
+  justify-content: space-between;
+  max-width: none;
+  min-width: 100%;
+  margin: 0;
+  left: 0;
+  transform: none;
+  transition: all 0.5s ease;
+  transition-property: top, right, bottom, left, opacity;
+  font-size: 14px;
+  min-height: 30px;
+  max-height: 100px;
+  position: fixed;
+  align-items: center;
+  padding: 5px 24px;
+  bottom: -100px;
+  top: -100px;
+  opacity: 0;
+  z-index: 9999;
+
+  &.roy-toast--center {
+    left: 50%;
+    transform: translate(-50%, 0);
+    bottom: auto;
+    top: 0;
+  }
+
+  &.roy-toast__status--warning {
+    color: #ffffff;
+    background: #ffa522;
+  }
+
+  &.roy-toast__status--error {
+    color: #fff;
+    background: #ff4843;
+  }
+
+  &.roy-toast__status--success {
+    color: #fff;
+    background: #009688;
+  }
+
+  .roy-toast__main {
+    width: 70%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+
+  .roy-toast__message {
+    max-height: 80px;
+    max-width: 100%;
+    overflow-y: auto;
+    overflow-x: hidden;
+    word-break: break-all;
+    height: 100%;
+    line-height: 30px;
+    padding: 0;
+  }
+
+  span {
+    margin: 0;
+    padding: 0;
+    line-height: 1em;
+  }
+
+  i {
+    padding-right: 8px;
+  }
+
+  &::after {
+    position: absolute;
+    width: 0;
+    height: 100%;
+    left: 0;
+    top: 0;
+    background: #fff;
+    opacity: 0.1;
+    content: '';
+    animation: roy-toast__snackbar-progress var(--duration) linear forwards;
+    pointer-events: none;
+  }
+
+  &.roy-toast--hover-tab {
+    &::after {
+      animation-play-state: paused;
+    }
+  }
+
+  .roy-toast__close {
+    font-weight: bold;
+    border-radius: 2px;
+    padding: 0 5px;
+    margin-right: 15px;
+    cursor: pointer;
+
+    &:hover {
+      background: rgba(#fff, 0.4);
+    }
+  }
+
+  @keyframes roy-toast__snackbar-progress {
+    from {
+      width: 0;
+    }
+
+    to {
+      width: 100%;
+    }
+  }
+}
+</style>

+ 173 - 0
src/components/yvan-ui/yvan-print-toolbar.vue

@@ -0,0 +1,173 @@
+<template>
+  <yvan-print-header :height="'45px'" class="yvan-print-designer-main__toolbar">
+    <section class="yvan-print-designer-main__toolbar_left">
+      <div
+          v-for="(toolbar, index) in toolbarConfig"
+          :key="index"
+          :class="toolbar.disabled ? `yvan-print-designer-main__toolbar__item--disabled` : ``"
+          :title="toolbar.name"
+          class="yvan-print-designer-main__toolbar__item"
+          @click="toolbar.event"
+      >
+        <i :class="toolbar.icon"></i>
+      </div>
+    </section>
+    <section>
+      <slot name="yvan-print-designer-toolbar-slot"></slot>
+    </section>
+    <section class="yvan-print-designer-main__toolbar_right">
+      <div class="yvan-print-designer-main__toolbar__setting">
+        <span>{{ rectWidth }}/{{ rectHeight }}</span>
+      </div>
+      <div class="yvan-print-designer-main__toolbar__zoom">
+        <div class="yvan-print-designer-main__toolbar__item" @click.stop.prevent="smallerScale">
+          <i class="fa fa-search-minus"></i>
+        </div>
+        <span>{{ scale100 }}%</span>
+        <div class="yvan-print-designer-main__toolbar__item" @click.stop.prevent="biggerScale">
+          <i class="fa fa-search-plus"></i>
+        </div>
+      </div>
+    </section>
+  </yvan-print-header>
+</template>
+
+<script>
+import {mapActions, mapState} from "pinia";
+import {globalStore, ruleStore} from "@/store";
+import commonMixin from '@/mixin/commonMixin'
+import Big from 'big.js'
+import toolbarConfig from "@/components/config/toolbarConfig";
+import YvanPrintHeader from "@/components/yvan-ui/yvan-print-header.vue";
+
+/**
+ * 顶部工具栏
+ */
+export default {
+  name: 'yvan-print-toolbar',
+  mixins: [commonMixin],
+  components: {YvanPrintHeader},
+  props: {},
+  data() {
+    return {
+      toolbarConfig
+    }
+  },
+  computed: {
+    ...mapState(globalStore, {
+      curComponent: (store) => store.curComponent,
+      areaData: (store) => store.areaData
+    }),
+    ...mapState(ruleStore, {
+      scale: (store) => store.scale,
+      rectWidth: (store) => store.rectWidth,
+      rectHeight: (store) => store.rectHeight,
+    }),
+    scale100() {
+      let currentScale = new Big(this.scale).div(new Big(5))
+      let num100 = new Big(100)
+      return currentScale.mul(num100).toNumber()
+    },
+  },
+  methods: {
+    ...mapActions(ruleStore, ['setBiggerScale', 'setSmallerScale', 'setReDrawRuler', 'setRect', 'rotateRect', 'toggleRuler']),
+    smallerScale() {
+      this.setSmallerScale()
+      this.setReDrawRuler()
+    },
+    biggerScale() {
+      this.setBiggerScale()
+      this.setReDrawRuler()
+    },
+    // rotatePage() {
+      //   // this.$store.commit('printTemplateModule/togglePageDirection')
+      // this.rotateRect()
+      // this.setReDrawRuler()
+    // },
+    initMounted() {
+    }
+  },
+  created() {
+  },
+  mounted() {
+    this.initMounted()
+  },
+  watch: {}
+}
+</script>
+
+<style lang="less" scoped>
+.yvan-print-designer-main__toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  .yvan-print-designer-main__toolbar_left {
+    display: flex;
+    justify-content: flex-start;
+
+    .yvan-print-designer-main__toolbar__divide {
+      width: 1px;
+      height: 18px;
+      padding: 0 2px;
+      border-right: 1px solid var(--yvan-border-color-dark);
+    }
+  }
+
+  .yvan-print-designer-main__toolbar_right {
+    display: flex;
+    justify-content: flex-end;
+
+    .yvan-print-designer-main__toolbar__zoom,
+    .yvan-print-designer-main__toolbar__setting {
+      display: flex;
+      justify-content: flex-start;
+      align-items: center;
+      position: relative;
+
+      span {
+        padding: 0 10px;
+      }
+    }
+  }
+
+  .yvan-print-designer-main__toolbar__item {
+    cursor: pointer;
+    background: var(--yvan-bg-color);
+    display: grid;
+    width: 18px;
+    height: 18px;
+    font-size: 10px;
+    line-height: 18px;
+    box-shadow: rgba(99, 99, 99, 0.2) 0 2px 8px 0;
+    padding: 4px;
+    text-align: center;
+    border-radius: 4px;
+
+    &.yvan-print-designer-main__toolbar__item--disabled {
+      opacity: 0.8;
+      cursor: not-allowed;
+      pointer-events: none;
+    }
+
+    & + .yvan-print-designer-main__toolbar__item {
+      margin-left: 5px;
+    }
+
+    &:hover {
+      background: var(--yvan-bg-color-page);
+    }
+
+    &:active {
+      color: var(--yvan-color-primary);
+      box-shadow: rgba(99, 99, 99, 0.4) 0 2px 8px 0;
+    }
+
+    i {
+      padding: 0;
+      margin: 0;
+      font-size: 14px;
+    }
+  }
+}
+</style>

+ 246 - 0
src/components/yvan-ui/yvan-sidebar-menu/SidebarMenu.vue

@@ -0,0 +1,246 @@
+<template>
+  <div
+    :class="sidebarClass"
+    :style="[{ 'max-width': sidebarWidth }]"
+    class="v-sidebar-menu"
+  >
+    <div
+      :style="isCollapsed && [rtl ? { 'margin-left': '-17px' } : { 'margin-right': '-17px' }]"
+      class="vsm--scroll-wrapper"
+    >
+      <div class="vsm--list">
+        <sidebar-menu-item
+            v-for="(item, index) in menu"
+            ref="menuItems"
+            :key="index"
+            :active-show="activeShow"
+            :disable-hover="disableHover"
+            :item="item"
+            :mobile-item="mobileItem"
+            :rtl="rtl"
+            :show-child="showChild"
+            :show-one-child="showOneChild"
+            @set-mobile-item="setMobileItem"
+            @unset-mobile-item="unsetMobileItem"
+        >
+          <slot slot="dropdown-icon" name="dropdown-icon" />
+        </sidebar-menu-item>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import SidebarMenuItem from './SidebarMenuItem.vue'
+
+export default {
+  name: 'yvan-print-sidebar-menu',
+  components: {
+    SidebarMenuItem
+  },
+  props: {
+    menu: {
+      type: Array,
+      required: true
+    },
+    collapsed: {
+      type: Boolean,
+      default: false
+    },
+    width: {
+      type: String,
+      default: '350px'
+    },
+    widthCollapsed: {
+      type: String,
+      default: '50px'
+    },
+    showChild: {
+      type: Boolean,
+      default: false
+    },
+    theme: {
+      type: String,
+      default: ''
+    },
+    showOneChild: {
+      type: Boolean,
+      default: false
+    },
+    rtl: {
+      type: Boolean,
+      default: false
+    },
+    relative: {
+      type: Boolean,
+      default: false
+    },
+    hideToggle: {
+      type: Boolean,
+      default: false
+    },
+    disableHover: {
+      type: Boolean,
+      default: false
+    }
+  },
+  data() {
+    return {
+      isCollapsed: this.collapsed,
+      mobileItem: null,
+      mobileItemPos: 0,
+      mobileItemHeight: 0,
+      mobileItemTimeout: null,
+      activeShow: null,
+      parentHeight: 0,
+      parentWidth: 0,
+      parentOffsetTop: 0,
+      parentOffsetLeft: 0
+    }
+  },
+  computed: {
+    sidebarWidth() {
+      return this.isCollapsed ? this.widthCollapsed : this.width
+    },
+    sidebarClass() {
+      return [
+        !this.isCollapsed ? 'vsm_expanded' : 'vsm_collapsed',
+        this.theme ? `vsm_${this.theme}` : '',
+        this.rtl ? 'vsm_rtl' : '',
+        this.relative ? 'vsm_relative' : ''
+      ]
+    },
+    mobileItemStyle() {
+      return {
+        item: [
+          { position: 'absolute' },
+          { top: `${this.mobileItemPos}px` },
+          this.rtl ? { right: '0px' } : { left: '0px' },
+          this.rtl ? { 'padding-right': this.sidebarWidth } : { 'padding-left': this.sidebarWidth },
+          this.rtl && { direction: 'rtl' },
+          { 'z-index': 0 },
+          { width: `${this.parentWidth - this.parentOffsetLeft}px` },
+          { 'max-width': this.width }
+        ],
+        dropdown: [
+          { position: 'absolute' },
+          { top: `${this.mobileItemHeight}px` },
+          { width: '100%' },
+          {
+            'max-height': `${
+              this.parentHeight -
+              (this.mobileItemPos + this.mobileItemHeight) -
+              this.parentOffsetTop
+            }px`
+          },
+          { 'overflow-y': 'auto' }
+        ],
+        background: [
+          { position: 'absolute' },
+          { top: '0px' },
+          { left: '0px' },
+          { right: '0px' },
+          { width: '100%' },
+          { height: `${this.mobileItemHeight}px` },
+          { 'z-index': -1 }
+        ]
+      }
+    }
+  },
+  watch: {
+    collapsed(val) {
+      if (this.isCollapsed === this.collapsed) {
+        return
+      }
+      this.isCollapsed = val
+      this.mobileItem = null
+    }
+  },
+  methods: {
+    onMouseLeave() {
+      this.unsetMobileItem(false, 300)
+    },
+    onMouseEnter() {
+      if (this.isCollapsed) {
+        if (this.mobileItemTimeout) {
+          clearTimeout(this.mobileItemTimeout)
+        }
+      }
+    },
+    onActiveShow(item) {
+      this.activeShow = item
+    },
+    onItemClick(event, item, node) {
+      this.$emit('item-click', event, item, node)
+    },
+    setMobileItem({ item, itemEl }) {
+      if (this.mobileItem === item) {
+        return
+      }
+      const sidebarTop = this.$el.getBoundingClientRect().top
+      const itemLinkEl = itemEl.children[0]
+      const { top, height } = itemLinkEl.getBoundingClientRect()
+
+      let positionTop = top - sidebarTop
+      this.initParentOffsets()
+      this.mobileItem = item
+      this.mobileItemPos = positionTop
+      this.mobileItemHeight = height
+    },
+    unsetMobileItem(immediate, delay = 800) {
+      if (!this.mobileItem) {
+        return
+      }
+      if (this.mobileItemTimeout) {
+        clearTimeout(this.mobileItemTimeout)
+      }
+      if (immediate) {
+        this.mobileItem = null
+        return
+      }
+      this.mobileItemTimeout = setTimeout(() => {
+        this.mobileItem = null
+      }, delay)
+    },
+    initParentOffsets() {
+      let {
+        top: sidebarTop,
+        left: sidebarLeft,
+        right: sidebarRight
+      } = this.$el.getBoundingClientRect()
+      let parent = this.relative ? this.$el.parentElement : document.documentElement
+      this.parentHeight = parent.clientHeight
+      this.parentWidth = parent.clientWidth
+      if (this.relative) {
+        let { top: parentTop, left: parentLeft } = parent.getBoundingClientRect()
+        this.parentOffsetTop = sidebarTop - (parentTop + parent.clientTop)
+        this.parentOffsetLeft = this.rtl
+          ? this.parentWidth - sidebarRight + (parentLeft + parent.clientLeft)
+          : sidebarLeft - (parentLeft + parent.clientLeft)
+      } else {
+        this.parentOffsetTop = sidebarTop
+        this.parentOffsetLeft = this.rtl ? this.parentWidth - sidebarRight : sidebarLeft
+      }
+    },
+    onItemUpdate(newItem, item) {
+      if (item === this.mobileItem) {
+        this.mobileItem = newItem
+      }
+      if (item === this.activeShow) {
+        this.activeShow = newItem
+      }
+    }
+  },
+  provide() {
+    return {
+      emitActiveShow: this.onActiveShow,
+      emitItemClick: this.onItemClick,
+      emitItemUpdate: this.onItemUpdate
+    }
+  }
+}
+</script>
+
+<style lang="less">
+@import './style/vue-sidebar-menu';
+</style>

+ 22 - 0
src/components/yvan-ui/yvan-sidebar-menu/SidebarMenuBadge.vue

@@ -0,0 +1,22 @@
+<template>
+  <component
+    :is="badge.element ? badge.element : 'span'"
+    :class="badge.class"
+    class="vsm--badge"
+    v-bind="badge.attributes"
+  >
+    {{ badge.text }}
+  </component>
+</template>
+
+<script>
+export default {
+  name: 'SidebarMenuBadge',
+  props: {
+    badge: {
+      type: Object,
+      default: () => {}
+    }
+  }
+}
+</script>

+ 22 - 0
src/components/yvan-ui/yvan-sidebar-menu/SidebarMenuIcon.vue

@@ -0,0 +1,22 @@
+<template>
+  <component
+    :is="icon.element ? icon.element : 'i'"
+    :class="typeof icon === 'string' || icon instanceof String ? icon : icon.class"
+    class="vsm--icon"
+    v-bind="icon.attributes"
+  >
+    {{ icon.text }}
+  </component>
+</template>
+
+<script>
+export default {
+  name: 'SidebarMenuIcon',
+  props: {
+    icon: {
+      type: [String, Object],
+      default: ''
+    }
+  }
+}
+</script>

+ 340 - 0
src/components/yvan-ui/yvan-sidebar-menu/SidebarMenuItem.vue

@@ -0,0 +1,340 @@
+<template>
+  <component :is="item.component" v-if="item.component && !isItemHidden" v-bind="item.props" />
+  <div
+    v-else-if="!isItemHidden"
+    :class="[{ 'vsm--item_open': show }]"
+    class="vsm--item"
+  >
+    <sidebar-menu-link
+      :class="itemLinkClass"
+      :item="item"
+      v-bind="itemLinkAttributes"
+      @click.native="clickEvent"
+    >
+      <sidebar-menu-icon
+        v-if="item.icon && !isMobileItem"
+        :icon="active ? item.activeIcon || item.icon : item.icon"
+      />
+      <span class="vsm--title">{{ item.title }}</span>
+    </sidebar-menu-link>
+  </div>
+</template>
+
+<script>
+// import pathToRegexp from 'path-to-regexp'
+import SidebarMenuLink from './SidebarMenuLink.vue'
+import SidebarMenuIcon from './SidebarMenuIcon.vue'
+import SidebarMenuBadge from './SidebarMenuBadge.vue'
+
+export default {
+  name: 'SidebarMenuItem',
+  components: {
+    SidebarMenuLink,
+    SidebarMenuIcon,
+    SidebarMenuBadge
+  },
+  props: {
+    item: {
+      type: Object,
+      required: true
+    },
+    level: {
+      type: Number,
+      default: 1
+    },
+    isCollapsed: {
+      type: Boolean,
+      default: true
+    },
+    isMobileItem: {
+      type: Boolean,
+      default: false
+    },
+    mobileItem: {
+      type: Object,
+      default: null
+    },
+    activeShow: {
+      type: Object,
+      default: null
+    },
+    showChild: {
+      type: Boolean,
+      default: false
+    },
+    showOneChild: {
+      type: Boolean,
+      default: false
+    },
+    rtl: {
+      type: Boolean,
+      default: false
+    },
+    disableHover: {
+      type: Boolean,
+      default: false
+    },
+    mobileItemStyle: {
+      type: Object,
+      default: null
+    }
+  },
+  data() {
+    return {
+      active: false,
+      exactActive: false,
+      itemShow: false,
+      itemHover: false
+    }
+  },
+  computed: {
+    isFirstLevel() {
+      return this.level === 1
+    },
+    show: {
+      get() {
+        if (!this.itemHasChild) {
+          return false
+        }
+        if (this.showChild || this.isMobileItem) {
+          return true
+        }
+        return this.itemShow
+      },
+      set(show) {
+        if (this.showOneChild) {
+          show ? this.emitActiveShow(this.item) : this.emitActiveShow(null)
+        }
+        this.itemShow = show
+      }
+    },
+    itemLinkClass() {
+      return [
+        'vsm--link',
+        !this.isMobileItem ? `vsm--link_level-${this.level}` : '',
+        { 'vsm--link_mobile-item': this.isMobileItem },
+        { 'vsm--link_hover': this.hover },
+        { 'vsm--link_active': this.active },
+        { 'vsm--link_exact-active': this.exactActive },
+        { 'vsm--link_disabled': this.item.disabled },
+        this.item.class
+      ]
+    },
+    itemLinkAttributes() {
+      const target = this.item.external ? '_blank' : '_self'
+      const tabindex = this.item.disabled ? -1 : null
+
+      return {
+        target,
+        tabindex,
+        ...this.item.attributes
+      }
+    },
+    isItemHidden() {
+      if (this.isCollapsed) {
+        if (this.item.hidden && this.item.hiddenOnCollapse === undefined) {
+          return true
+        } else {
+          return this.item.hiddenOnCollapse === true
+        }
+      } else {
+        return this.item.hidden === true
+      }
+    },
+    hover() {
+      if (this.isCollapsed && this.isFirstLevel) {
+        return this.item === this.mobileItem
+      }
+      return this.itemHover
+    },
+    itemHasChild() {
+      return !!(this.item.child && this.item.child.length > 0)
+    }
+  },
+  watch: {
+    // $route() {
+    //   setTimeout(() => {
+    //     if (this.item.header || this.item.component) {
+    //       return
+    //     }
+    //     this.initState()
+    //   }, 1)
+    // },
+    item(newItem, item) {
+      this.emitItemUpdate(newItem, item)
+    },
+    'item.isActive'() {
+      this.initState()
+    },
+    activeShow() {
+      this.itemShow = this.item === this.activeShow
+    }
+  },
+  created() {
+    if (this.item.header || this.item.component) {
+      return
+    }
+    this.initState()
+  },
+  mounted() {
+    if (!this.$router) {
+      window.addEventListener('hashchange', this.initState)
+    }
+  },
+  destroyed() {
+    if (!this.$router) {
+      window.removeEventListener('hashchange', this.initState)
+    }
+  },
+  methods: {
+    isLinkActive(item) {
+      return (
+        item.isActive ||
+        this.matchRoute(item) ||
+        this.isChildActive(item.child) ||
+        this.isAliasActive(item)
+      )
+    },
+    isLinkExactActive(item) {
+      return this.matchExactRoute(item.href)
+    },
+    isChildActive(child) {
+      if (!child) {
+        return false
+      }
+      return child.some((item) => {
+        return this.isLinkActive(item)
+      })
+    },
+    isAliasActive(item) {
+      if (item.alias) {
+        const current = this.$router
+          ? this.$route.fullPath
+          : window.location.pathname + window.location.search + window.location.hash
+        if (Array.isArray(item.alias)) {
+          return item.alias.some((alias) => {
+            return pathToRegexp(alias).test(current)
+          })
+        } else {
+          return pathToRegexp(item.alias).test(current)
+        }
+      }
+      return false
+    },
+    matchRoute({ href, exactPath }) {
+      if (!href) {
+        return false
+      }
+      if (this.$router) {
+        const { route } = this.$router.resolve(href)
+        return exactPath ? route.path === this.$route.path : this.matchExactRoute(href)
+      } else {
+        return exactPath ? encodeURI(href) === window.location.pathname : this.matchExactRoute(href)
+      }
+    },
+    matchExactRoute(href) {
+      if (!href) {
+        return false
+      }
+      if (this.$router) {
+        const { route } = this.$router.resolve(href)
+        return route.fullPath === this.$route.fullPath
+      } else {
+        return (
+          encodeURI(href) ===
+          window.location.pathname + window.location.search + window.location.hash
+        )
+      }
+    },
+    clickEvent(event) {
+      if (this.item.disabled) {
+        return
+      }
+      if (!this.item.href) {
+        event.preventDefault()
+      }
+
+      this.emitItemClick(event, this.item, this)
+
+      this.emitMobileItem(event, event.currentTarget.offsetParent)
+
+      if (!this.itemHasChild || this.showChild || this.isMobileItem) {
+        return
+      }
+      if (!this.item.href || this.exactActive) {
+        this.show = !this.show
+      }
+    },
+    emitMobileItem(event, itemEl) {
+      if (this.hover) {
+        return
+      }
+      if (!this.isCollapsed || !this.isFirstLevel || this.isMobileItem) {
+        return
+      }
+      this.$emit('unset-mobile-item', true)
+      setTimeout(() => {
+        if (this.mobileItem !== this.item) {
+          this.$emit('set-mobile-item', { item: this.item, itemEl })
+        }
+        if (event.type === 'click' && !this.itemHasChild) {
+          this.$emit('unset-mobile-item', false)
+        }
+      }, 0)
+    },
+    initState() {
+      this.initActiveState()
+      this.initShowState()
+    },
+    initActiveState() {
+      this.active = this.isLinkActive(this.item)
+      this.exactActive = this.isLinkExactActive(this.item)
+    },
+    initShowState() {
+      if (!this.itemHasChild || this.showChild) {
+        return
+      }
+      if ((this.showOneChild && this.active && !this.show) || (this.active && !this.show)) {
+        this.show = true
+      } else if (this.showOneChild && !this.active && this.show) {
+        this.show = false
+      }
+    },
+    mouseOverEvent(event) {
+      if (this.item.disabled) {
+        return
+      }
+      event.stopPropagation()
+      this.itemHover = true
+      if (!this.disableHover) {
+        this.emitMobileItem(event, event.currentTarget)
+      }
+    },
+    mouseOutEvent(event) {
+      event.stopPropagation()
+      this.itemHover = false
+    },
+    expandEnter(el) {
+      el.style.height = el.scrollHeight + 'px'
+    },
+    expandAfterEnter(el) {
+      el.style.height = 'auto'
+    },
+    expandBeforeLeave(el) {
+      if (this.isCollapsed && this.isFirstLevel) {
+        el.style.display = 'none'
+        return
+      }
+      el.style.height = el.scrollHeight + 'px'
+    }
+  },
+  inject: ['emitActiveShow', 'emitItemClick', 'emitItemUpdate']
+}
+</script>
+
+
+<style lang="less">
+.vsm--title {
+  writing-mode: vertical-rl;
+}
+</style>

+ 36 - 0
src/components/yvan-ui/yvan-sidebar-menu/SidebarMenuLink.vue

@@ -0,0 +1,36 @@
+<template>
+  <component :is="tag" v-bind="componentAttrs">
+    <slot />
+  </component>
+</template>
+
+<script>
+export default {
+  name: 'SidebarMenuLink',
+  inheritAttrs: false,
+  props: {
+    item: {
+      type: Object,
+      required: true
+    }
+  },
+  computed: {
+    isRouterLink() {
+      // return !!this.$router && this.item.href && !this.item.external
+      return false
+    },
+    componentAttrs() {
+      return Object.assign(this.isRouterLink ? { to: this.href } : { href: this.href }, this.$attrs)
+    },
+    tag() {
+      return this.isRouterLink ? (this.$nuxt ? 'nuxt-link' : 'router-link') : 'a'
+    },
+    href() {
+      if (!this.item.href) {
+        return '#'
+      }
+      return this.item.href
+    }
+  }
+}
+</script>

+ 216 - 0
src/components/yvan-ui/yvan-sidebar-menu/style/_base.less

@@ -0,0 +1,216 @@
+.v-sidebar-menu {
+  * {
+    box-sizing: border-box;
+  }
+
+  position: relative;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  display: flex;
+  flex-direction: column;
+  z-index: 999;
+  box-sizing: border-box;
+  width: 100%;
+  text-align: left;
+  transition: 0.3s max-width ease;
+
+  .vsm--scroll-wrapper {
+    height: 100%;
+    overflow-y: auto;
+    overflow-x: hidden;
+  }
+
+  .vsm--dropdown > .vsm--list {
+    padding: 5px;
+  }
+
+  .vsm--item {
+    position: relative;
+    display: block;
+    width: 100%;
+    white-space: nowrap;
+  }
+
+  .vsm--link {
+    cursor: pointer;
+    position: relative;
+    display: flex;
+    align-items: center;
+    font-size: @item-font-size;
+    font-weight: 400;
+    padding: @item-padding;
+    line-height: @item-line-height;
+    text-decoration: none;
+    user-select: none;
+    z-index: 20;
+    transition: 0.3s all ease;
+
+    &_exact-active,
+    &_active {
+      font-weight: normal;
+    }
+
+    &_disabled {
+      opacity: 0.4;
+      pointer-events: none;
+    }
+
+    &_level-1 {
+      .vsm--icon {
+        height: @icon-height;
+        line-height: @icon-height;
+        width: @icon-width;
+        flex-shrink: 0;
+        text-align: center;
+        border-radius: 3px;
+      }
+    }
+  }
+
+  .vsm--icon {
+    display: inline-block;
+    margin-right: 10px;
+  }
+
+  .vsm--title {
+    flex-grow: 1;
+  }
+
+  .vsm--arrow {
+    width: 30px;
+    text-align: center;
+    font-style: normal;
+    font-weight: 900;
+    transition: 0.3s transform ease;
+
+    &:after {
+      content: '\f105';
+      font-family: 'Font Awesome 5 Free';
+    }
+
+    &_open {
+      transform: rotate(90deg);
+    }
+
+    &_slot:after {
+      display: none;
+    }
+  }
+
+  .vsm--header {
+    font-size: 14px;
+    font-weight: 600;
+    padding: 10px;
+    white-space: nowrap;
+    text-transform: uppercase;
+  }
+
+  .vsm--badge {
+    &_default {
+      padding: 0px 6px;
+      font-size: 12px;
+      border-radius: 3px;
+      height: 20px;
+      line-height: 20px;
+      font-weight: 600;
+      text-transform: uppercase;
+    }
+  }
+
+  .vsm--toggle-btn {
+    display: block;
+    text-align: center;
+    font-style: normal;
+    font-weight: normal;
+    height: 50px;
+    cursor: pointer;
+    border: none;
+    width: 100%;
+
+    &:after {
+      content: '\EA42';
+      font-family: 'remixicon' !important;
+    }
+
+    &_slot:after {
+      display: none;
+    }
+  }
+
+  &.vsm_collapsed {
+    & .vsm--link_level-1 {
+      &.vsm--link_hover,
+      &:hover {
+        background-color: transparent !important;
+      }
+    }
+  }
+
+  &.vsm_rtl {
+    right: 0;
+    left: inherit;
+    text-align: right;
+    direction: rtl;
+
+    & .vsm--icon {
+      margin-left: 10px;
+      margin-right: 0px;
+    }
+  }
+
+  &.vsm_relative {
+    position: relative;
+    height: 100%;
+  }
+
+  .expand-enter-active,
+  .expand-leave-active {
+    transition: height 0.3s ease;
+    overflow: hidden;
+  }
+
+  .expand-enter,
+  .expand-leave-to {
+    height: 0 !important;
+  }
+
+  .slide-animation-enter-active {
+    transition: width 0.3s ease;
+  }
+
+  .slide-animation-leave-active {
+    transition: width 0.3s ease;
+  }
+
+  .slide-animation-enter,
+  .slide-animation-leave-to {
+    width: 0 !important;
+  }
+
+  .fade-animation-enter-active {
+    transition: opacity 0.3s ease, visibility 0.3s ease;
+  }
+
+  .fade-animation-leave-active {
+    transition: opacity 0.3s ease, visibility 0.3s ease;
+  }
+
+  .fade-animation-enter,
+  .fade-animation-leave-to {
+    opacity: 0 !important;
+    visibility: hidden !important;
+  }
+
+  .vsm--mobile-item > .vsm--item {
+    padding: 0 !important;
+    margin: 0 !important;
+  }
+
+  .vsm--mobile-item > .vsm--item > .vsm--link {
+    margin: 0 !important;
+    background-color: transparent !important;
+    line-height: @icon-height !important;
+  }
+}

+ 36 - 0
src/components/yvan-ui/yvan-sidebar-menu/style/_variables.less

@@ -0,0 +1,36 @@
+@primary-color: var(--yvan-print-color-primary) ;
+@base-bg: #2a2a2e ;
+
+@item-color: #fff ;
+
+@item-active-color: null ;
+@item-active-bg: null ;
+
+@item-open-color: #fff ;
+@item-open-bg: @primary-color ;
+
+@item-hover-color: null ;
+@item-hover-bg: rgba(darken(@base-bg, 5%), 0.5) ;
+
+@icon-color: null ;
+@icon-bg: darken(@base-bg, 5%) ;
+
+@icon-active-color: null ;
+@icon-active-bg: var(--yvan-print-color-primary-light-9) ;
+
+@icon-open-color: null ;
+@icon-open-bg: @item-open-bg ;
+
+@mobile-item-color: #fff ;
+@mobile-item-bg: @primary-color ;
+@mobile-icon-color: @mobile-item-color ;
+@mobile-icon-bg: @mobile-item-bg ;
+
+@dropdown-bg: lighten(@base-bg, 5%) ;
+@dropdown-color: null ;
+
+@item-font-size: 16px ;
+@item-line-height: 30px ;
+@item-padding: 10px ;
+@icon-height: 30px ;
+@icon-width: 30px ;

+ 124 - 0
src/components/yvan-ui/yvan-sidebar-menu/style/themes/default-theme.less

@@ -0,0 +1,124 @@
+.v-sidebar-menu {
+  background-color: @base-bg;
+
+  .vsm--link {
+    color: @item-color;
+
+    &_exact-active,
+    &_active {
+      color: @item-active-color;
+      background-color: @item-active-bg;
+    }
+
+    &_level-1 {
+      &.vsm--link_exact-active,
+      &.vsm--link_active {
+        box-shadow: none;
+
+        &::before {
+          content: '';
+          width: 4px;
+          height: 80%;
+          background: var(--roy-color-primary);
+          position: absolute;
+          top: 10%;
+          border-radius: 0 10px 10px 0;
+          left: 0;
+        }
+
+        & .vsm--icon {
+          color: @icon-active-color;
+          background-color: @icon-active-bg;
+        }
+      }
+
+      & .vsm--icon {
+        background-color: @icon-bg;
+      }
+    }
+
+    &_hover,
+    &:hover {
+      color: @item-hover-color;
+      background-color: @item-hover-bg;
+    }
+
+    &_mobile-item {
+      color: @mobile-item-color;
+
+      &.vsm--link_hover,
+      &:hover {
+        color: @mobile-item-color;
+      }
+    }
+  }
+
+  &.vsm_collapsed {
+    .vsm--link_level-1.vsm--link_hover,
+    .vsm--link_level-1:hover {
+      .vsm--icon {
+        color: @mobile-icon-color;
+        background-color: @mobile-icon-bg;
+      }
+    }
+  }
+
+  .vsm--icon {
+    color: @icon-color;
+  }
+
+  .vsm--dropdown {
+    & .vsm--list {
+      background-color: @dropdown-bg;
+    }
+
+    & .vsm--link {
+      color: @dropdown-color;
+    }
+
+    & .vsm--icon {
+      color: @dropdown-color;
+    }
+  }
+
+  .vsm--mobile-bg {
+    background-color: @mobile-item-bg;
+  }
+
+  &.vsm_expanded {
+    .vsm--item_open {
+      .vsm--link {
+        &_level-1 {
+          color: @item-open-color;
+          background-color: @item-open-bg;
+
+          & .vsm--icon {
+            color: @icon-open-color;
+            background-color: @icon-open-bg;
+          }
+        }
+      }
+    }
+  }
+
+  &.vsm_rtl {
+    .vsm--link_level-1.vsm--link_active,
+    .vsm--link_level-1.vsm--link_exact-active {
+      box-shadow: -3px 0px 0px 0px @primary-color inset;
+    }
+  }
+
+  .vsm--header {
+    color: rgba(@item-color, 0.7);
+  }
+
+  .vsm--badge_default {
+    color: @item-color;
+    background-color: darken(@base-bg, 5%);
+  }
+
+  .vsm--toggle-btn {
+    color: @item-color;
+    background-color: darken(@base-bg, 5%);
+  }
+}

+ 121 - 0
src/components/yvan-ui/yvan-sidebar-menu/style/themes/white-theme.less

@@ -0,0 +1,121 @@
+@base-bg: #fff;
+@item-color: #262626;
+@icon-bg: #ffffff;
+@icon-active-color: var(--roy-color-primary);
+@icon-active-bg: var(--roy-color-primary-light-9);
+@item-hover-bg: rgba(darken(@base-bg, 5%), 0.5);
+@dropdown-bg: #e3e3e3;
+
+.v-sidebar-menu.vsm_white-theme {
+  background-color: @base-bg;
+
+  .vsm--link {
+    color: @item-color;
+
+    &_exact-active,
+    &_active {
+      color: @item-active-color;
+      background-color: @item-active-bg;
+    }
+
+    &_level-1 {
+      &.vsm--link_exact-active,
+      &.vsm--link_active {
+        box-shadow: none;
+
+        & .vsm--icon {
+          color: @icon-active-color;
+          background-color: @icon-active-bg;
+        }
+      }
+
+      & .vsm--icon {
+        background-color: @icon-bg;
+      }
+    }
+
+    &_hover,
+    &:hover {
+      color: @item-hover-color;
+      background-color: @item-hover-bg;
+    }
+
+    &_mobile-item {
+      color: @mobile-item-color;
+
+      &.vsm--link_hover,
+      &:hover {
+        color: @mobile-item-color;
+      }
+    }
+  }
+
+  &.vsm_collapsed {
+    .vsm--link_level-1.vsm--link_hover,
+    .vsm--link_level-1:hover {
+      .vsm--icon {
+        color: @mobile-icon-color;
+        background-color: @mobile-icon-bg;
+      }
+    }
+  }
+
+  .vsm--icon {
+    color: @icon-color;
+  }
+
+  .vsm--dropdown {
+    & .vsm--list {
+      background-color: @dropdown-bg;
+    }
+
+    & .vsm--link {
+      color: @dropdown-color;
+    }
+
+    & .vsm--icon {
+      color: @dropdown-color;
+    }
+  }
+
+  .vsm--mobile-bg {
+    background-color: @mobile-item-bg;
+  }
+
+  &.vsm_expanded {
+    .vsm--item_open {
+      .vsm--link {
+        &_level-1 {
+          color: @item-open-color;
+          background-color: @item-open-bg;
+
+          & .vsm--icon {
+            color: @icon-open-color;
+            background-color: @icon-open-bg;
+          }
+        }
+      }
+    }
+  }
+
+  &.vsm_rtl {
+    .vsm--link_level-1.vsm--link_active,
+    .vsm--link_level-1.vsm--link_exact-active {
+      box-shadow: -3px 0px 0px 0px @primary-color inset;
+    }
+  }
+
+  .vsm--header {
+    color: rgba(@item-color, 0.7);
+  }
+
+  .vsm--badge_default {
+    color: @item-color;
+    background-color: darken(@base-bg, 5%);
+  }
+
+  .vsm--toggle-btn {
+    color: @item-color;
+    background-color: darken(@base-bg, 5%);
+  }
+}

+ 7 - 0
src/components/yvan-ui/yvan-sidebar-menu/style/vue-sidebar-menu.less

@@ -0,0 +1,7 @@
+// base styles
+@import './_variables';
+@import './_base';
+
+// themes
+@import './themes/default-theme';
+@import './themes/white-theme';

+ 15 - 0
src/main.ts

@@ -0,0 +1,15 @@
+import {createApp} from 'vue'
+import {createPinia} from 'pinia'
+import ElementPlus from 'element-plus'
+import '@/style.css'
+import '@/assets/style/main.less'
+import '@/assets/style/variable.css'
+import 'element-plus/theme-chalk/index.css'
+import System from "@/utils/system";
+import App from '@/App.vue'
+
+const Pinia = createPinia()
+
+window['System'] = System;
+
+createApp(App).use(Pinia).use(ElementPlus, {}).mount('#app')

+ 29 - 0
src/mixin/commonMixin.js

@@ -0,0 +1,29 @@
+import Utils from '@/utils/utils'
+import {deepCopy} from '@/utils/html-util'
+
+export default {
+    methods: {
+        deepCopy,
+        getUuid: Utils.getUuid,
+        isBlank(value) {
+            return value === undefined || value === null || value === ''
+        },
+        /**
+         * 通过name查找父组件
+         * @param {*} vueIns
+         * @param {*} name
+         */
+        findParentComponent(vueIns, name) {
+            let parent = vueIns.$parent
+            while (parent) {
+                let componentName = parent.$options.componentName || parent.$options.name
+                if (componentName !== name) {
+                    parent = parent.$parent
+                } else {
+                    return parent
+                }
+            }
+            return false
+        }
+    }
+}

+ 104 - 0
src/store/compose.ts

@@ -0,0 +1,104 @@
+import Utils from '@/utils/utils'
+import eventBus from '@/utils/eventBus'
+import decomposeComponent from '@/utils/decomposeComponent'
+import {$} from '@/utils/html-util'
+import {createGroupStyle} from '@/utils/style-util'
+import {commonAttr, commonStyle} from '@/components/config/componentList'
+
+export default {
+    state: {
+        areaData: {
+            // 选中区域包含的组件以及区域位移信息
+            style: {
+                top: 0,
+                left: 0,
+                width: 0,
+                height: 0
+            },
+            components: []
+        },
+        editor: null
+    },
+    actions: {
+        getEditor() {
+            this.editor = $('#designer-page')
+        },
+
+        setAreaData(data) {
+            this.areaData = data
+        },
+
+        compose({componentData, areaData, editor}) {
+            const components = []
+            areaData.components.forEach((component) => {
+                if (component.component !== 'RoyGroup') {
+                    components.push(component)
+                } else {
+                    // 如果要组合的组件中,已经存在组合数据,则需要提前拆分
+                    const parentStyle = {...component.style}
+                    const subComponents = component.propValue
+                    const editorRect = editor.getBoundingClientRect()
+
+                    subComponents.forEach((component) => {
+                        decomposeComponent(component, editorRect, parentStyle)
+                    })
+
+                    components.push(...component.propValue)
+                }
+            })
+
+            const groupComponent = {
+                id: Utils.getUuid(),
+                component: 'RoyGroup',
+                label: '组合',
+                icon: 'ri-shape-line',
+                ...commonAttr,
+                style: {
+                    ...commonStyle,
+                    ...areaData.style
+                },
+                propValue: components
+            }
+
+            createGroupStyle(groupComponent)
+
+            store.commit('printTemplateModule/addComponent', {
+                component: groupComponent
+            })
+
+            eventBus.emit('hideArea')
+
+            store.commit('printTemplateModule/batchDeleteComponent', areaData.components)
+            store.commit('printTemplateModule/setCurComponent', {
+                component: componentData[componentData.length - 1],
+                index: componentData.length - 1
+            })
+
+            areaData.components = []
+        },
+
+        // 将已经放到 Group 组件数据删除,也就是在 componentData 中删除,因为它们已经从 componentData 挪到 Group 组件中了
+        batchDeleteComponent({componentData}, deleteData) {
+            deleteData.forEach((component) => {
+                for (let i = 0, len = componentData.length; i < len; i++) {
+                    if (component.id === componentData[i].id) {
+                        componentData.splice(i, 1)
+                        break
+                    }
+                }
+            })
+        },
+
+        decompose({curComponent, editor}) {
+            const parentStyle = {...curComponent.style}
+            const components = curComponent.propValue
+            const editorRect = editor.getBoundingClientRect()
+
+            store.commit('printTemplateModule/deleteComponent')
+            components.forEach((component) => {
+                decomposeComponent(component, editorRect, parentStyle)
+                store.commit('printTemplateModule/addComponent', {component})
+            })
+        }
+    }
+}

+ 90 - 0
src/store/copy.ts

@@ -0,0 +1,90 @@
+import Utils from '@/utils/utils'
+import { deepCopy } from '@/utils/html-util'
+
+export default {
+  state: {
+    copyData: null, // 复制粘贴剪切
+    isCut: false,
+    menuTop: 0, // 右击菜单数据
+    menuLeft: 0
+  },
+  actions: {
+    copy(state) {
+      if (!state.curComponent) {
+        // toast('请选择组件')
+        return
+      }
+
+      // 如果有剪切的数据,需要先还原
+      restorePreCutData(state)
+      copyData(state)
+
+      state.isCut = false
+    },
+
+    paste(state, isMouse) {
+      if (!state.copyData) {
+        // toast('请选择组件')
+        return
+      }
+
+      const data = state.copyData.data
+
+      if (isMouse) {
+        data.style.top = state.menuTop
+        data.style.left = state.menuLeft
+      } else {
+        data.style.top += 10
+        data.style.left += 10
+      }
+
+      data.id = Utils.getUuid()
+      data.label = `${data.name}-${data.id}`
+      // store.commit('printTemplateModule/addComponent', {
+      //   component: deepCopy(data)
+      // })
+      if (state.isCut) {
+        state.copyData = null
+      }
+    },
+
+    cut(state) {
+      if (!state.curComponent) {
+        // toast('请选择组件')
+        return
+      }
+
+      // 如果重复剪切,需要恢复上一次剪切的数据
+      restorePreCutData(state)
+      copyData(state)
+
+      // store.commit('printTemplateModule/deleteComponent')
+      state.isCut = true
+    },
+
+    showContextMenu(state, { top, left }) {
+      state.menuTop = top
+      state.menuLeft = left
+    }
+  }
+}
+
+// 恢复上一次剪切的数据
+function restorePreCutData(state) {
+  if (state.isCut && state.copyData) {
+    const data = deepCopy(state.copyData.data)
+    const index = state.copyData.index
+    // store.commit('addComponent', { component: data, index })
+    if (state.curComponentIndex >= index) {
+      // 如果当前组件索引大于等于插入索引,需要加一,因为当前组件往后移了一位
+      state.curComponentIndex++
+    }
+  }
+}
+
+function copyData(state) {
+  state.copyData = {
+    data: deepCopy(state.curComponent),
+    index: state.curComponentIndex
+  }
+}

+ 0 - 0
src/store/global.ts


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